На главную

Таблицы и SQL

Шпаргалка SQL

Типы соединений на живом примере, оконные функции, CTE, индексы и различия PostgreSQL, MySQL, SQLite и SQL Server.

На трёх пользователях и четырёх заказах

Типы соединений

Кругами Эйлера не объяснить, почему LEFT JOIN с условием в WHERE превращается в INNER. Двумя крошечными таблицами — объяснить: смотрите, какие строки исчезают.

users

idname
1Аня
2Борис
3Вера

orders

user_idtotal
11200
1300
2900
4150
SELECT u.name, o.total
  FROM users u
  JOIN orders o ON o.user_id = u.id;

Строка попадает в результат, только если пара нашлась с обеих сторон. Вера без заказов и заказ несуществующего пользователя исчезают.

Результат — 3 строки

users.nameorders.total
Аня1200
Аня300
Борис900

Одно и то же разными словами

Чем отличаются СУБД

ЗадачаPostgreSQLMySQL / MariaDBSQLiteSQL Server
Ограничить число строкLIMIT 10 OFFSET 20LIMIT 20, 10LIMIT 10 OFFSET 20OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY
Склеить строкиa || bCONCAT(a, b)a || ba + b
Текущее времяnow()NOW()datetime('now')GETDATE()
АвтоинкрементGENERATED ALWAYS AS IDENTITYAUTO_INCREMENTINTEGER PRIMARY KEYIDENTITY(1,1)
UpsertON CONFLICT DO UPDATEON DUPLICATE KEY UPDATEON CONFLICT DO UPDATEMERGE
Регистронезависимый LIKEILIKELIKE (по сортировке)LIKE (только ASCII)LIKE (по сортировке)
Логические значенияbooleanTINYINT(1)0 и 1BIT
Вернуть изменённые строкиRETURNINGRETURNING (3.35+)OUTPUT
FULL OUTER JOINестьнет, собирают из UNIONесть (3.39+)есть
Кавычки для имени колонки"name"`name`"name"[name]
Тип для денегnumeric(12,2)DECIMAL(12,2)INTEGER в копейкахDECIMAL(12,2)
Регулярное выражение~ и regexp_matchREGEXPREGEXP (нужно расширение)
Список таблиц\dtSHOW TABLES.tablessys.tables
План запросаEXPLAIN ANALYZEEXPLAIN ANALYZE (8.0.18+)EXPLAIN QUERY PLANSET SHOWPLAN_ALL ON

Показано 61 из 61 строк.

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

Оконная функция не схлопывает строки: она добавляет к каждой строке значение, посчитанное по её «окну». Этим она и отличается от GROUP BY.

  • ROW_NUMBER() OVER (ORDER BY created_at)

    Сквозная нумерация без пропусков и без повторов.

  • RANK() OVER (ORDER BY total DESC)

    Место в рейтинге: одинаковые значения получают один ранг, следующий номер пропускается.

  • DENSE_RANK() OVER (ORDER BY total DESC)

    То же, но без пропусков в нумерации.

  • ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC)

    Нумерация внутри каждого пользователя — основа выборки «последний заказ каждого клиента».

  • SUM(total) OVER (PARTITION BY user_id)

    Сумма по клиенту рядом с каждой его строкой.

  • SUM(total) OVER (ORDER BY day ROWS UNBOUNDED PRECEDING)

    Нарастающий итог.

  • AVG(total) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)

    Скользящее среднее за семь дней.

  • LAG(total) OVER (ORDER BY day)

    Значение предыдущей строки — считать прирост день к дню.

  • LEAD(total, 1, 0) OVER (ORDER BY day)

    Значение следующей строки, третий аргумент — чем заменить отсутствующее.

  • FIRST_VALUE(total) OVER (PARTITION BY user_id ORDER BY day)

    Первое значение в группе.

  • NTILE(4) OVER (ORDER BY total)

    Разложить строки по четырём равным корзинам — квартили.

  • COUNT(*) OVER ()

    Общее число строк выборки рядом с каждой строкой — удобно для пагинации одним запросом.

  • WINDOW w AS (PARTITION BY user_id ORDER BY day)

    Именованное окно: описать один раз и ссылаться из нескольких функций.

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

  • WITH recent AS (SELECT … ) SELECT * FROM recent;

    Общее табличное выражение: длинный запрос читается сверху вниз, а не изнутри наружу.

  • WITH a AS (…), b AS (SELECT … FROM a) SELECT …;

    Несколько CTE подряд, второй видит первый.

  • WITH RECURSIVE tree AS (SELECT … UNION ALL SELECT … JOIN tree …) SELECT * FROM tree;

    Рекурсивный обход иерархии: категории, комментарии, оргструктура.

  • WITH RECURSIVE days AS (SELECT DATE '2026-01-01' d UNION ALL SELECT d + 1 FROM days WHERE d < DATE '2026-12-31') SELECT * FROM days;

    Календарь без отдельной таблицы дат.

  • WITH moved AS (DELETE FROM a RETURNING *) INSERT INTO b SELECT * FROM moved;

    Перенос строк одной командой (PostgreSQL).

  • SELECT * FROM t WHERE EXISTS (SELECT 1 FROM o WHERE o.t_id = t.id);

    EXISTS останавливается на первой найденной строке — обычно быстрее, чем IN с подзапросом.

  • SELECT * FROM t WHERE NOT EXISTS (…);

    Надёжнее NOT IN: NOT IN со списком, где есть NULL, не вернёт ничего.

  • SELECT … FROM t JOIN LATERAL (SELECT … WHERE o.t_id = t.id LIMIT 3) x ON true;

    LATERAL: подзапрос видит текущую строку — «три последних заказа каждого клиента».

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

  • GROUP BY user_id HAVING COUNT(*) > 3

    WHERE фильтрует строки до группировки, HAVING — группы после.

  • COUNT(*) vs COUNT(col)

    Первое считает строки, второе — только те, где значение не NULL.

  • COUNT(DISTINCT user_id)

    Число уникальных значений.

  • SUM(CASE WHEN status = 'paid' THEN total ELSE 0 END)

    Условная сумма — сводная таблица без разворота.

  • FILTER (WHERE status = 'paid')

    То же короче и понятнее: COUNT(*) FILTER (WHERE …) в PostgreSQL и SQLite.

  • GROUP BY GROUPING SETS ((a), (b), ())

    Несколько разрезов одним запросом; ROLLUP и CUBE — частные случаи.

  • STRING_AGG(name, ', ' ORDER BY name)

    Склеить значения группы в строку (в MySQL — GROUP_CONCAT).

  • COALESCE(value, 0)

    Заменить NULL. Помните: NULL + 1 = NULL, и SUM по пустой группе тоже NULL.

  • NULLIF(a, 0)

    Превратить значение в NULL — защита от деления на ноль: a / NULLIF(b, 0).

  • DISTINCT ON (user_id) … ORDER BY user_id, created_at DESC

    Первая строка каждой группы одной строчкой (PostgreSQL).

Индексы и планы

Индекс ускоряет чтение и замедляет запись. Каждый лишний индекс — это дополнительная работа при каждом INSERT и UPDATE.

  • CREATE INDEX idx_orders_user ON orders (user_id);

    Обычный B-tree: равенство, диапазоны, сортировка.

  • CREATE INDEX idx_orders_user_day ON orders (user_id, created_at);

    Составной индекс работает слева направо: по одному user_id — да, по одному created_at — нет.

  • CREATE UNIQUE INDEX ON users (lower(email));

    Уникальность без учёта регистра — функциональный индекс.

  • CREATE INDEX ON orders (user_id) WHERE status = 'active';

    Частичный индекс: меньше и быстрее, если запросы всегда с этим условием.

  • CREATE INDEX CONCURRENTLY …

    Строить индекс, не блокируя запись в таблицу (PostgreSQL). Дольше, но без простоя.

  • EXPLAIN ANALYZE SELECT …

    Не только план, но и реальное время. Сравнивайте rows= ожидаемые с actual rows= — расхождение означает устаревшую статистику.

  • Seq Scan вместо Index Scan

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

  • WHERE date(created_at) = '2026-01-01'

    Функция над колонкой отключает индекс. Пишите диапазон: created_at >= … AND created_at < ….

  • WHERE name LIKE '%текст%'

    Ведущий процент индексом не покрывается. Нужен полнотекстовый поиск или триграммный индекс.

  • ANALYZE table_name;

    Обновить статистику, по которой планировщик выбирает план.

  • CREATE INDEX ON t USING gin (data jsonb_path_ops);

    Индекс по JSONB в PostgreSQL.

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

  • INSERT INTO t (a, b) VALUES (1, 2), (3, 4);

    Многострочная вставка — на порядок быстрее, чем по одной.

  • INSERT … ON CONFLICT (id) DO UPDATE SET a = EXCLUDED.a;

    Upsert в PostgreSQL и SQLite.

  • INSERT … ON DUPLICATE KEY UPDATE a = VALUES(a);

    Upsert в MySQL.

  • INSERT … ON CONFLICT DO NOTHING;

    Пропустить дубликат молча.

  • UPDATE t SET a = 1 WHERE id = 2 RETURNING *;

    Вернуть изменённые строки, не делая второй запрос (PostgreSQL, SQLite 3.35+).

  • UPDATE t SET a = s.a FROM s WHERE s.id = t.id;

    Обновление по данным другой таблицы.

  • DELETE FROM t WHERE id IN (SELECT id FROM t ORDER BY id LIMIT 1000);

    Удалять большими партиями по частям — иначе одна транзакция забьёт журнал и заблокирует таблицу.

  • TRUNCATE t;

    Быстрая очистка таблицы целиком, но без триггеров и без возможности отфильтровать.

  • BEGIN; … COMMIT;

    Явная транзакция; SAVEPOINT позволяет откатить только часть.

  • SELECT … FOR UPDATE SKIP LOCKED LIMIT 1;

    Очередь задач без гонок: каждый воркер берёт свою строку.

Даты, строки, JSON

  • date_trunc('month', created_at)

    Округлить дату вниз до месяца (PostgreSQL). В MySQL — DATE_FORMAT, в SQLite — strftime.

  • created_at >= now() - interval '7 days'

    Последняя неделя. Условие на колонку без функции — индекс работает.

  • AGE(a, b) / EXTRACT(epoch FROM a - b)

    Разница дат по-человечески и в секундах.

  • generate_series(date1, date2, '1 day')

    Ряд дат, чтобы в отчёте не пропадали дни без событий (PostgreSQL).

  • data->>'name'

    Значение из JSONB как текст; -> вернёт JSON.

  • data @> '{"active": true}'

    Содержит ли JSON заданный фрагмент — покрывается GIN-индексом.

  • ILIKE / LOWER(col) = LOWER(?)

    Регистронезависимое сравнение: первое в PostgreSQL, второе везде.

  • split_part(email, '@', 2)

    Домен из адреса.

  • CAST(value AS integer) / value::integer

    Приведение типа; вторая форма — сокращение PostgreSQL.