Шпаргалка по SQL для аналитика

Проверь себя · 1/3разбор после ответа
Колонка created_at имеет тип timestamp. Какой тип данных вернёт DATE_TRUNC('day', created_at)?

Зачем нужна шпаргалка по SQL

SQL — рабочий язык аналитика, и на собеседовании вас почти наверняка попросят написать запрос вживую. Проблема в том, что синтаксис разбросан по десяткам гайдов: JOIN в одном месте, оконные функции в другом, обработка NULL в третьем. Эта шпаргалка по SQL собирает всё, что нужно аналитику данных, на одной странице — от простого SELECT до оконных функций и CTE. Открыли, нашли нужный паттерн, скопировали, поправили под свои таблицы.

Шпаргалка рассчитана на тех, кто уже понимает, что такое таблица и строка, но путается в порядке выполнения, забывает синтаксис HAVING или не помнит, чем RANK отличается от ROW_NUMBER. Если вы готовитесь к собесу, держите её рядом и параллельно решайте задачи — теория без практики выветривается за неделю. Тренажёр Карьерник как раз даёт живые SQL-задачи с проверкой ответа, чтобы синтаксис из этой шпаргалки закрепился руками, а не остался в закладках.

Все примеры написаны под PostgreSQL — самый частый диалект в аналитике. В MySQL и других базах синтаксис местами отличается, но логика одна и та же.

Выборка: SELECT, WHERE, ORDER BY, LIMIT

SELECT определяет, какие колонки вернуть, FROM — откуда. Звёздочка тянет все колонки, но в рабочих запросах лучше перечислять нужные явно — так читается понятнее и не ломается при изменении схемы.

SELECT user_id, country, created_at FROM users;
SELECT DISTINCT country FROM users;

WHERE фильтрует строки до агрегации. Условия комбинируются через AND, OR и NOT, а для диапазонов и списков есть удобные операторы BETWEEN, IN и LIKE.

SELECT * FROM users
WHERE age > 30
  AND country IN ('RU', 'KZ')
  AND email LIKE '%@gmail.com'
  AND created_at BETWEEN '2026-01-01' AND '2026-04-01'
  AND deleted_at IS NULL;

ORDER BY сортирует результат: ASC по возрастанию (по умолчанию), DESC по убыванию. Можно сортировать по нескольким колонкам сразу. LIMIT ограничивает число строк, OFFSET пропускает первые N — вместе они дают постраничную выборку.

SELECT * FROM users
ORDER BY country ASC, created_at DESC
LIMIT 20 OFFSET 40;

JOIN: типы соединений

JOIN склеивает строки двух таблиц по условию в ON. Тип соединения определяет, что делать со строками, которым не нашлась пара.

INNER JOIN оставляет только те строки, где совпадение нашлось в обеих таблицах. Если у пользователя нет заказов, он в результат не попадёт.

SELECT u.user_id, o.order_id, o.amount
FROM users u
INNER JOIN orders o ON o.user_id = u.id;

LEFT JOIN берёт все строки из левой таблицы и подтягивает совпадения из правой; где пары нет — NULL. Это рабочая лошадка аналитика: «все пользователи и их заказы, даже если заказов не было».

SELECT u.user_id, COUNT(o.order_id) AS orders_cnt
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.user_id;

RIGHT JOIN — зеркало LEFT, на практике почти не используется: проще поменять таблицы местами. FULL OUTER JOIN возвращает все строки обеих таблиц, заполняя NULL там, где пары нет. CROSS JOIN даёт декартово произведение — каждая строка с каждой; нужен редко и легко получается случайно, если забыть условие в ON.

GROUP BY, HAVING и агрегаты

Агрегатные функции сворачивают группу строк в одно число. COUNT(*) считает все строки, COUNT(col) — только не-NULL значения, COUNT(DISTINCT col) — уникальные. Остальные: SUM, AVG, MIN, MAX.

GROUP BY разбивает таблицу на группы по значениям колонки, и агрегаты считаются внутри каждой группы. HAVING фильтрует уже сами группы — в отличие от WHERE, который работает по строкам до группировки.

SELECT country, COUNT(*) AS users_cnt, AVG(age) AS avg_age
FROM users
WHERE deleted_at IS NULL
GROUP BY country
HAVING COUNT(*) > 100
ORDER BY users_cnt DESC;

Частая задача — посчитать долю в процентах. Тут важна ловушка: деление целого на целое в PostgreSQL даёт целое (усечение), поэтому умножайте на 100.0, чтобы результат стал дробным. Условную агрегацию удобно делать через FILTER.

SELECT
    country,
    COUNT(*) AS total,
    COUNT(*) FILTER (WHERE is_paid) AS paid,
    ROUND(100.0 * COUNT(*) FILTER (WHERE is_paid) / COUNT(*), 2) AS paid_pct
FROM users
GROUP BY country;

Полезно помнить логический порядок выполнения запроса: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT. Именно поэтому в WHERE нельзя сослаться на алиас из SELECT — на момент WHERE его ещё не существует.

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

Оконные функции считают значение по «окну» строк, но, в отличие от GROUP BY, не схлопывают строки — каждая строка остаётся, рядом добавляется результат. Окно задаётся через OVER (PARTITION BY ... ORDER BY ...).

ROW_NUMBER нумерует строки внутри группы, RANK и DENSE_RANK ранжируют с учётом одинаковых значений: RANK пропускает следующий номер после ничьей (1, 2, 2, 4), DENSE_RANK не пропускает (1, 2, 2, 3).

SELECT
    user_id, product, created_at,
    ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn,
    DENSE_RANK() OVER (PARTITION BY user_id ORDER BY amount DESC) AS rnk
FROM orders;

LAG и LEAD достают значение из предыдущей или следующей строки — удобно для расчёта прироста день к дню.

SELECT
    day, revenue,
    LAG(revenue) OVER (ORDER BY day) AS prev_revenue,
    revenue - LAG(revenue) OVER (ORDER BY day) AS delta
FROM daily_revenue;

Накопительный итог и скользящее среднее задаются рамкой окна. По умолчанию ORDER BY в окне суммирует от начала до текущей строки, а явная рамка ROWS BETWEEN N PRECEDING AND CURRENT ROW ограничивает диапазон.

SELECT
    day, revenue,
    SUM(revenue) OVER (ORDER BY day) AS running_total,
    AVG(revenue) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma7
FROM daily_revenue;

Важно: оконную функцию нельзя использовать в WHERE или HAVING — на момент их выполнения окна ещё не посчитаны. Если нужно отфильтровать по результату оконки, заверните её в CTE и фильтруйте снаружи (пример ниже, в задаче top-N).

Прокачай SQL для собеса
500+ задач по SQL: оконные функции, JOIN, CTE — с разбором каждой
Тренировать SQL в Telegram

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

Подзапрос — это запрос внутри запроса. В WHERE он часто стоит после IN или EXISTS, в FROM — как временная таблица.

-- подзапрос в WHERE
SELECT * FROM users
WHERE id IN (SELECT user_id FROM orders WHERE amount > 1000);

-- коррелированный подзапрос: ссылается на внешнюю таблицу
SELECT * FROM users u
WHERE EXISTS (
    SELECT 1 FROM orders o
    WHERE o.user_id = u.id AND o.amount > 100
);

CTE (Common Table Expression) — это именованный подзапрос через WITH. Читается сверху вниз, как шаги, поэтому длинные запросы из вложенных подзапросов почти всегда понятнее переписать на CTE.

WITH active_users AS (
    SELECT id FROM users
    WHERE last_login > CURRENT_DATE - INTERVAL '30 days'
),
their_orders AS (
    SELECT o.*
    FROM orders o
    JOIN active_users a ON a.id = o.user_id
)
SELECT user_id, SUM(amount) AS total
FROM their_orders
GROUP BY user_id;

Классическая задача «топ-N в каждой группе» решается оконной функцией внутри CTE с фильтром снаружи — потому что в WHERE оконку не вызвать.

WITH ranked AS (
    SELECT *,
           DENSE_RANK() OVER (PARTITION BY category ORDER BY price DESC) AS rnk
    FROM products
)
SELECT * FROM ranked WHERE rnk <= 3;

Рекурсивный CTE (WITH RECURSIVE) нужен для иерархий и генерации последовательностей — например, чтобы получить непрерывный календарь дат и заполнить пропуски нулями.

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

Работа с NULL

NULL — это не ноль и не пустая строка, а «неизвестно». Любое сравнение с NULL даёт не TRUE и не FALSE, а UNKNOWN, поэтому проверять надо через IS NULL и IS NOT NULL, а не через = NULL.

SELECT * FROM users WHERE deleted_at IS NULL;
SELECT COALESCE(phone, 'не указан') AS phone FROM users; -- первый не-NULL
SELECT NULLIF(status, 'unknown') AS status FROM users;   -- NULL, если значение равно 'unknown'

COALESCE возвращает первый не-NULL аргумент — удобно подставлять значение по умолчанию. NULLIF(a, b) возвращает NULL, если a = b. Главное применение NULLIF — безопасное деление: оно превращает делитель-ноль в NULL и спасает от ошибки «деление на ноль».

SELECT revenue / NULLIF(orders_cnt, 0) AS avg_check FROM daily;

Две частые ловушки. Первая: NOT IN с подзапросом, где встречается NULL, возвращает пустой результат целиком — потому что сравнение с NULL даёт UNKNOWN. Безопаснее использовать NOT EXISTS. Вторая: COUNT(col) не считает строки с NULL в этой колонке, тогда как COUNT(*) считает все, — это легко принять за расхождение в данных.

-- ловушка: вернёт пусто, если у кого-то user_id = NULL
SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM orders);

-- безопасно
SELECT * FROM users u
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);

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

С датами в аналитике работают постоянно. DATE_TRUNC округляет дату до начала периода (день, неделя, месяц) — основа любой группировки по времени. EXTRACT достаёт часть даты числом. Арифметика идёт через INTERVAL.

SELECT
    DATE_TRUNC('month', created_at) AS month,
    COUNT(*) AS signups
FROM users
WHERE created_at >= CURRENT_DATE - INTERVAL '6 months'
GROUP BY 1
ORDER BY 1;

Строковые функции пригодятся для очистки и парсинга. Конкатенация в PostgreSQL — через ||, обрезка пробелов — TRIM, поиск подстроки — POSITION, а вытащить часть по разделителю проще всего через SPLIT_PART.

SELECT
    TRIM(LOWER(name))                AS name_clean,
    SPLIT_PART(email, '@', 2)        AS domain,
    LEFT(phone, 4) || '****'          AS phone_masked
FROM users;

CASE WHEN — это ветвление прямо в SELECT: проверяет условия сверху вниз и возвращает значение первого сработавшего. Незаменим для сегментации и условной агрегации.

SELECT
    user_id,
    CASE
        WHEN age < 18 THEN 'до 18'
        WHEN age < 35 THEN '18-34'
        WHEN age < 55 THEN '35-54'
        ELSE '55+'
    END AS age_group
FROM users;

Частые ошибки

Самая частая ошибка новичков — SELECT * в продакшен-запросах и дашбордах. Тянуть все колонки медленнее, ломает читаемость и приводит к сюрпризам, когда в таблицу добавляют поле. Перечисляйте колонки явно.

Вторая — забытое условие в JOIN. Без ON (или с неверным ключом) база делает декартово произведение, и число строк взрывается. Если после JOIN строк стало подозрительно много, первым делом проверяйте условие соединения и уникальность ключа справа.

Третья — путаница WHERE и HAVING. WHERE фильтрует строки до группировки, HAVING — группы после. Условие на агрегат (COUNT(*) > 100) должно идти в HAVING, а на обычную колонку — в WHERE, иначе либо синтаксическая ошибка, либо лишняя нагрузка.

Четвёртая — деление целых чисел. 5 / 20 в PostgreSQL вернёт 0, а не 0.25. Приводите к дробному типу: 5::NUMERIC / 20 или умножайте на 100.0. И не забывайте про NULLIF(делитель, 0), чтобы не упасть на нуле.

Пятая — сравнение с NULL через = и NOT IN с подзапросом, в котором есть NULL. Проверяйте через IS NULL, а для исключения используйте NOT EXISTS.

Связанные темы

FAQ

С чего начать учить SQL аналитику?

С SELECT, WHERE и GROUP BY — этого хватает для 80% базовых задач. Дальше подключайте JOIN, потом подзапросы и CTE, и в конце оконные функции. Параллельно решайте задачи: SQL запоминается только через практику, а не чтением.

Чем GROUP BY отличается от оконных функций?

GROUP BY схлопывает группу строк в одну агрегированную, теряя детализацию. Оконная функция считает агрегат по окну, но оставляет все исходные строки и добавляет результат рядом. Если нужно сохранить строки и при этом видеть, например, накопительный итог, — это окно.

В чём разница между WHERE и HAVING?

WHERE фильтрует отдельные строки до группировки и не умеет работать с агрегатами. HAVING фильтрует уже готовые группы и применяется к агрегатам вроде COUNT(*) или SUM(amount). По логике выполнения WHERE идёт раньше GROUP BY, а HAVING — после.

Почему запрос возвращает пустой результат?

Частая причина — NOT IN с подзапросом, где есть NULL: тогда всё условие становится UNKNOWN и не проходит ни одна строка. Вторая — слишком жёсткие условия в WHERE или неверный JOIN, отрезающий все совпадения. Замените NOT IN на NOT EXISTS и проверяйте фильтры по очереди.

Какой диалект SQL учить аналитику?

PostgreSQL — самый частый в аналитике и хорошая база: синтаксис близок к стандарту. MySQL и SQL Server отличаются в мелочах (функции дат, оконки, лимиты), но 90% знаний переносятся напрямую. Начните с одного диалекта и не распыляйтесь.