Справочник · Данные и форматы
SQL: шпаргалка по запросам
Выборка, фильтры, соединения, группировка, оконные функции, изменение данных и индексы. Примеры работают в PostgreSQL, большинство — в MySQL и SQLite.
Все примеры ниже построены на трёх таблицах небольшого магазина. Синтаксис — стандартный SQL в варианте PostgreSQL; где MySQL или SQLite отличаются, это отмечено.
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)
);Выборка и фильтры
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:
FROMиJOIN— откуда берутся строки;WHERE— фильтр строк;GROUP BY— группировка;HAVING— фильтр групп;SELECT— вычисление столбцов;DISTINCT;ORDER BY;LIMITиOFFSET.
Соединения
| Вид | Что вернёт |
|---|---|
INNER JOIN | только пары, у которых совпал ключ |
LEFT JOIN | все строки слева, справа — совпавшие или NULL |
RIGHT JOIN | наоборот; обычно переписывают в LEFT JOIN |
FULL JOIN | все строки обеих таблиц (в MySQL нет) |
CROSS JOIN | все комбинации строк |
-- заказы с почтой покупателя
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);Группировка и агрегаты
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) делают длинный запрос читаемым: каждый шаг получает имя.
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 проходит по дереву, например по категориям, у которых есть родитель:
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.
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 примерно равные части |
Последний заказ каждого покупателя:
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;Изменение данных
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.
Индексы и план запроса
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 из строк с пользовательским вводом. Используйте параметры, их поддерживает любой драйвер:
cur.execute("SELECT * FROM customers WHERE email = %s", (email,))Значение уходит в базу отдельно от текста запроса, и строка вида ' OR 1=1 -- останется просто строкой.