Лаборатория кодера

Справочник · Данные и форматы

SQL: шпаргалка по запросам

Выборка, фильтры, соединения, группировка, оконные функции, изменение данных и индексы. Примеры работают в PostgreSQL, большинство — в MySQL и SQLite.

Обновлено · 7 мин чтения

Все примеры ниже построены на трёх таблицах небольшого магазина. Синтаксис — стандартный SQL в варианте PostgreSQL; где MySQL или SQLite отличаются, это отмечено.

sql
CREATE TABLE customers (
  id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email      text NOT NULL UNIQUE,
  city       text,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE orders (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  customer_id bigint NOT NULL REFERENCES customers(id),
  status      text NOT NULL CHECK (status IN ('new', 'paid', 'shipped', 'cancelled')),
  total       numeric(12, 2) NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE order_items (
  order_id bigint REFERENCES orders(id) ON DELETE CASCADE,
  sku      text,
  qty      int NOT NULL CHECK (qty > 0),
  PRIMARY KEY (order_id, sku)
);

Выборка и фильтры

sql
SELECT id, email, city
FROM customers
WHERE city = 'Казань'
  AND created_at >= '2026-01-01'
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;
УсловиеПример
сравнениеtotal > 1000, status <> 'cancelled'
списокstatus IN ('paid', 'shipped')
диапазон, границы включеныtotal BETWEEN 100 AND 500
шаблонemail LIKE '%@example.com' (% — любая строка, _ — один символ)
без учёта регистраemail ILIKE 'ivan%' (PostgreSQL), в MySQL LIKE и так обычно без учёта
пустое значениеcity IS NULL, city IS NOT NULL
подстановка по умолчаниюCOALESCE(city, 'не указан')
условие внутри выборкиCASE WHEN total > 5000 THEN 'крупный' ELSE 'обычный' END

Порядок выполнения запроса отличается от порядка записи. Поэтому псевдоним из SELECT нельзя использовать в WHERE, но можно в ORDER BY:

  1. FROM и JOIN — откуда берутся строки;
  2. WHERE — фильтр строк;
  3. GROUP BY — группировка;
  4. HAVING — фильтр групп;
  5. SELECT — вычисление столбцов;
  6. DISTINCT;
  7. ORDER BY;
  8. LIMIT и OFFSET.

Соединения

ВидЧто вернёт
INNER JOINтолько пары, у которых совпал ключ
LEFT JOINвсе строки слева, справа — совпавшие или NULL
RIGHT JOINнаоборот; обычно переписывают в LEFT JOIN
FULL JOINвсе строки обеих таблиц (в MySQL нет)
CROSS JOINвсе комбинации строк
sql
-- заказы с почтой покупателя
SELECT o.id, c.email, o.total
FROM orders o
JOIN customers c ON c.id = o.customer_id;

-- покупатели без единого заказа
SELECT c.*
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;

-- то же через NOT EXISTS, часто понятнее и не хуже по скорости
SELECT c.*
FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);

Группировка и агрегаты

sql
SELECT c.city,
       count(*)                                   AS orders,
       count(DISTINCT o.customer_id)              AS buyers,
       sum(o.total)                               AS revenue,
       round(avg(o.total), 2)                     AS avg_check,
       count(*) FILTER (WHERE o.status = 'cancelled') AS cancelled
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.created_at >= date_trunc('month', now())
GROUP BY c.city
HAVING sum(o.total) > 100000
ORDER BY revenue DESC;

count(*) считает строки, count(столбец) — только непустые значения. Конструкция FILTER (WHERE ...) есть в PostgreSQL и SQLite; в MySQL вместо неё пишут sum(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END).

Подзапросы и CTE

Общие табличные выражения (WITH) делают длинный запрос читаемым: каждый шаг получает имя.

sql
WITH monthly AS (
  SELECT customer_id, date_trunc('month', created_at) AS month, sum(total) AS spent
  FROM orders
  WHERE status <> 'cancelled'
  GROUP BY 1, 2
)
SELECT month, count(*) AS active_buyers, sum(spent) AS revenue
FROM monthly
GROUP BY month
ORDER BY month;

Рекурсивный CTE проходит по дереву, например по категориям, у которых есть родитель:

sql
WITH RECURSIVE tree AS (
  SELECT id, parent_id, name, 1 AS depth FROM categories WHERE parent_id IS NULL
  UNION ALL
  SELECT c.id, c.parent_id, c.name, t.depth + 1
  FROM categories c JOIN tree t ON c.parent_id = t.id
)
SELECT * FROM tree ORDER BY depth, name;

Оконные функции

Окно считает значение по группе строк, но не схлопывает их, как GROUP BY.

sql
SELECT id, customer_id, total,
       row_number() OVER (PARTITION BY customer_id ORDER BY created_at) AS nth_order,
       sum(total)   OVER (PARTITION BY customer_id ORDER BY created_at) AS running_total,
       lag(total)   OVER (PARTITION BY customer_id ORDER BY created_at) AS prev_total,
       rank()       OVER (ORDER BY total DESC)                          AS place
FROM orders;
ФункцияЧто даёт
row_number()номер строки в окне: 1, 2, 3, 4
rank()место с пропусками при равенстве: 1, 2, 2, 4
dense_rank()место без пропусков: 1, 2, 2, 3
lag(x), lead(x)значение из предыдущей или следующей строки
first_value(x)первое значение в окне
ntile(4)разбивка на 4 примерно равные части

Последний заказ каждого покупателя:

sql
SELECT * FROM (
  SELECT o.*, row_number() OVER (PARTITION BY customer_id ORDER BY created_at DESC) AS rn
  FROM orders o
) t
WHERE rn = 1;

Изменение данных

sql
INSERT INTO customers (email, city) VALUES ('anna@example.com', 'Пермь')
RETURNING id;

UPDATE orders SET status = 'shipped'
WHERE status = 'paid' AND created_at < now() - interval '2 days';

DELETE FROM orders WHERE status = 'cancelled' AND created_at < '2025-01-01';

-- вставить или обновить по уникальному ключу (PostgreSQL, SQLite)
INSERT INTO customers (email, city) VALUES ('anna@example.com', 'Самара')
ON CONFLICT (email) DO UPDATE SET city = EXCLUDED.city;

В MySQL вместо ON CONFLICT пишут ON DUPLICATE KEY UPDATE city = VALUES(city), а RETURNING поддерживает только MariaDB.

Индексы и план запроса

sql
CREATE INDEX orders_customer_created_idx ON orders (customer_id, created_at);
CREATE INDEX CONCURRENTLY orders_status_idx ON orders (status) WHERE status <> 'shipped';
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 10;
  • Составной индекс работает слева направо: индекс по (customer_id, created_at) помогает в запросе по customer_id, но не в запросе только по created_at.
  • Функция над столбцом мешает индексу: WHERE lower(email) = ... требует индекса по выражению lower(email).
  • LIKE 'abc%' может использовать индекс, LIKE '%abc' — нет.
  • В плане ищите Seq Scan по большим таблицам и большие расхождения между ожидаемым и реальным числом строк.
  • Глубокая пагинация через OFFSET 100000 медленная: база всё равно перебирает пропущенные строки. Быстрее пагинация по ключу: WHERE id < последний_id ORDER BY id DESC LIMIT 20.

Безопасность запросов

Никогда не склеивайте SQL из строк с пользовательским вводом. Используйте параметры, их поддерживает любой драйвер:

python
cur.execute("SELECT * FROM customers WHERE email = %s", (email,))

Значение уходит в базу отдельно от текста запроса, и строка вида ' OR 1=1 -- останется просто строкой.