Все материалы
Продуктовая аналитикачеклистстарт

Шпаргалка по SQL для аналитика: синтаксис и типовые запросы

Шпаргалка по SQL на одной странице: порядок выполнения запроса, фильтры, GROUP BY, JOIN, CTE, оконные функции, даты, NULL и метрики. Каждый пример выполнен, результат указан.

КейсПрактика2 октября 2026 г.13 мин

Шпаргалка по SQL — одна страница с тем, что аналитик пишет каждый день: фильтр, группировка, JOIN, CTE, оконные функции, даты, NULL и метрики в виде «задача → конструкция → пример». Запрос пишут в порядке SELECT … FROM … WHERE … GROUP BY … HAVING … ORDER BY … LIMIT, а база выполняет его начиная с FROM, и SELECT оказывается пятым шагом. Каждый пример выполнен на учебной базе симулятора SQL-аналитика в PostgreSQL 14 и DuckDB: числа в таблицах и подписях — результат запуска.

В каком порядке писать запрос и в каком он выполняется

Логический порядок объясняет, какие имена где видны: WHERE не знает алиас из SELECT, а ORDER BY знает. Разбор — в статье SQL с нуля: SELECT, FROM и WHERE.

Схема: части SQL-запроса в порядке записи и в порядке выполнения, SELECT переезжает с первого места на пятое
В записи SELECT первый, при выполнении — пятый. Остальные части порядок сохраняют.
Логический порядок выполнения запроса
ШагЧасть запросаЧто делаетЧто из этого следует
1FROM, JOINсобирает строки из таблицстроки размножаются здесь, до всех фильтров
2WHEREотбирает строкиагрегатов и алиасов из SELECT ещё нет
3GROUP BYсворачивает строки в группыдальше доступны ключи группы и агрегаты
4HAVINGотбирает группыусловия на COUNT и SUM пишут сюда
5SELECTсчитает выражения и окна, затем DISTINCTокно видит уже отобранные и сгруппированные строки
6ORDER BYсортирует результаталиас из SELECT уже виден; в PostgreSQL — только вне выражений
7LIMITоставляет первые N строкбез ORDER BY набор строк не определён

Как выбрать, отфильтровать и отсортировать строки: SELECT, WHERE, ORDER BY

В примерах — таблицы учебной базы: users (4 613 регистраций), events (35 341 событие) и payments (1 251 оплата). Сравнения, AND, OR и скобки — в статье про операторы и логику: там выбор оператора по вопросу и что проверить, здесь — только запись и результат.

Разборы: ORDER BY и LIMIT, DISTINCT, BETWEEN, LIKE и ILIKE, строковые функции.

Выборка и фильтры
ЗадачаКонструкцияПример
Столбцы и алиасSELECT col AS aliasSELECT user_id, signup_date AS registered FROM users
Условие на строкиWHERE … AND …WHERE channel = 'organic' AND device = 'mobile' — 794 строки
Значение из спискаIN (…)WHERE country IN ('KZ', 'BY') — 1 326
Диапазон, обе границы входятBETWEEN a AND bWHERE amount BETWEEN 19 AND 29 — 1 109 оплат
Начало строкиLIKE 'pa%'WHERE channel LIKE 'pa%' — 1 829: paid_search и partner
Уникальные значенияSELECT DISTINCTSELECT DISTINCT channel FROM users — 4 строки
Первые N по порядкуORDER BY … LIMIT nORDER BY signup_date DESC, user_id LIMIT 5
Фильтр по четырём условиям, сортировка и первые пять строк
SELECT user_id, signup_date, country
FROM users
WHERE channel = 'organic'
  AND device = 'mobile'
  AND country IN ('KZ', 'BY')
  AND signup_date >= DATE '2026-08-01'
ORDER BY signup_date DESC, user_id
LIMIT 5;

-- условиям отвечает 81 строка; первые две:
-- 4577 | 2026-08-29 | BY
-- 4582 | 2026-08-29 | KZ

Как посчитать итог и разбить по группам: агрегаты, GROUP BY, HAVING

WHERE отбирает строки до группировки, HAVING — готовые группы. В первом запросе WHERE оставил только продления, а HAVING убрал тариф team, у которого их 40. Второй запрос считает первые оплаты и продления рядом: условие уходит из WHERE внутрь агрегата.

Разборы: агрегация и условные метрики, группировка по нескольким полям, HAVING, CASE WHEN, ROUND.

Агрегаты и группировка
ЗадачаКонструкцияПример
Сколько строкCOUNT(*)SELECT COUNT(*) FROM users — 4 613
Сколько непустых значенийCOUNT(col)COUNT(country) — 4 558
Сколько разных значенийCOUNT(DISTINCT col)COUNT(DISTINCT user_id) в payments — 912
Сумма и среднееSUM, AVG, MIN, MAXSUM(amount) — 30 639, AVG(amount) — 24,49
Условие на группуHAVINGHAVING COUNT(*) >= 100
Счётчик по условиюSUM(CASE …) или FILTERCOUNT(*) FILTER (WHERE payment_type = 'renewal') — 339
Доля по условиюAVG(CASE … THEN 1.0 ELSE 0 END)продления — 27,1% оплат
WHERE до группировки, HAVING после: продления по тарифам
SELECT
  plan,
  COUNT(*)                AS renewals,
  COUNT(DISTINCT user_id) AS payers,
  SUM(amount)             AS revenue
FROM payments
WHERE payment_type = 'renewal'   -- отбор строк
GROUP BY plan
HAVING COUNT(*) >= 100           -- отбор групп
ORDER BY revenue DESC;

-- basic | 192 | 165 | 3648.00
-- pro   | 107 |  92 | 3103.00
sqlУсловная агрегация: первые оплаты и продления в одной строке
SELECT
  plan,
  COUNT(*) AS payments,
  SUM(CASE WHEN payment_type = 'first' THEN 1 ELSE 0 END) AS first_payments,
  COUNT(*) FILTER (WHERE payment_type = 'renewal')        AS renewals,
  ROUND(100.0 * AVG(CASE WHEN payment_type = 'renewal' THEN 1.0 ELSE 0 END), 1) AS renewal_pct
FROM payments
GROUP BY plan
ORDER BY plan;

-- basic | 706 | 514 | 192 | 27.2
-- pro   | 403 | 296 | 107 | 26.6
-- team  | 142 | 102 |  40 | 28.2

Как соединить таблицы и не размножить строки: JOIN

Перед соединением назовите зерно каждой таблицы: что означает одна строка. users и payments связаны «один ко многим»: строк станет столько, сколько оплат. payments и events по user_id — «многие ко многим»: оплата повторяется на каждое событие пользователя, и 1 251 строка превращается в 11 667.

Лекарство — свести каждую таблицу фактов (много строк на пользователя: оплаты, события) к строке на пользователя и только потом соединять. Разборы: INNER и LEFT JOIN, JOIN без потерь и дублей, FULL и CROSS JOIN, UNION и UNION ALL.

Соединения
ЗадачаКонструкцияПример
Только строки с паройINNER JOINusers u JOIN payments p ON p.user_id = u.user_id — 1 251 строка
Все строки левой таблицыLEFT JOINто же с LEFT JOIN — 4 952 строки
Строки без парыLEFT JOIN … WHERE p.user_id IS NULL3 701 пользователь без оплат
Фильтр правой таблицыусловие в ONON p.user_id = u.user_id AND p.plan = 'pro' — слева все 4 613
Две таблицы фактовагрегат до соединенияJOIN (SELECT user_id, SUM(amount) AS revenue FROM payments GROUP BY user_id) AS p ON p.user_id = u.user_id — 912 строк
Склеить результаты по вертикалиUNION ALLSELECT user_id FROM payments UNION ALL SELECT user_id FROM subscriptions — 2 163 строки; UNION без ALL оставит 912
Ловушка: две таблицы фактов в одном JOIN раздувают выручку
SELECT
  COUNT(*)      AS joined_rows,
  SUM(p.amount) AS inflated_revenue
FROM payments p
JOIN events e ON e.user_id = p.user_id;

-- 11667 | 284913.00
-- настоящая выручка: SELECT SUM(amount) FROM payments — 30639.00
sqlПравильно: агрегат по каждой таблице, потом соединение
WITH pay AS (
  SELECT user_id, SUM(amount) AS revenue
  FROM payments
  GROUP BY user_id
),
act AS (
  SELECT user_id, COUNT(*) AS events
  FROM events
  GROUP BY user_id
)
SELECT
  COUNT(*)         AS users,
  SUM(pay.revenue) AS revenue,
  SUM(act.events)  AS events
FROM users u
LEFT JOIN pay ON pay.user_id = u.user_id
LEFT JOIN act ON act.user_id = u.user_id;

-- 4613 | 30639.00 | 35341

Когда хватит подзапроса, а когда нужен CTE

Подзапрос удобен для одного значения или списка. Когда шагов два и больше, их выносят в WITH: у шага появляется имя, и его можно выполнить отдельно. В примере первый шаг сводит оплаты к строке на пользователя, второй считает людей с одной, двумя и тремя оплатами.

Разборы: подзапросы: IN, EXISTS и подзапрос в FROM, EXISTS и IN, коррелированный подзапрос, CTE и WITH.

Подзапросы и CTE
ЗадачаКонструкцияПример
Сравнить с итогомскалярный подзапросWHERE amount > (SELECT AVG(amount) FROM payments) — 545 оплат
Фильтр по другой таблицеIN (SELECT …)WHERE user_id IN (SELECT user_id FROM payments) — 912
Есть связанная строкаEXISTSWHERE EXISTS (SELECT 1 FROM payments p WHERE p.user_id = u.user_id) — 912
Нет связанной строкиNOT EXISTS3 701; NOT IN вернёт пусто, если в списке есть NULL
Таблица из запросаподзапрос в FROMFROM (SELECT … GROUP BY user_id) AS t
Именованные шагиWITH name AS (…)WITH per_user AS (…) SELECT … FROM per_user
CTE в два шага: сколько пользователей платили один, два и три раза
WITH per_user AS (
  SELECT user_id, COUNT(*) AS payments, SUM(amount) AS revenue
  FROM payments
  GROUP BY user_id
)
SELECT
  payments,
  COUNT(*)     AS users,
  SUM(revenue) AS revenue
FROM per_user
GROUP BY payments
ORDER BY payments;

-- 1 | 622 | 15238.00
-- 2 | 241 | 11738.00
-- 3 |  49 |  3663.00

Как получить номер строки, прошлое значение и долю: оконные функции

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

Во втором запросе условие rn = 1 стоит во внешнем запросе: WHERE выполняется раньше окна. Разборы: оконные функции, PARTITION BY и OVER, LAG и LEAD, RANK и топ-N, строка с максимумом, дубликаты, рамка окна, процент и доля.

Оконные функции
ЗадачаКонструкцияПример
Номер строки в группеROW_NUMBER() OVER (PARTITION BY … ORDER BY …)первая оплата пользователя: rn = 1
Предыдущая строкаLAG(x) OVER (ORDER BY …)выручка прошлого месяца
Накопительный итогSUM(x) OVER (ORDER BY …)3 983 → 14 851 → 30 639
Доля от общего100.0 * x / SUM(x) OVER ()13,0% · 35,5% · 51,5%
Доля внутри группы100.0 * x / SUM(x) OVER (PARTITION BY g)mobile внутри organic — 45,4%
Скользящее среднее за 7 днейAVG(x) OVER (ORDER BY d ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)регистраций в день на 29 августа — 72,0
LAG, накопительный итог и доля от общего: выручка по месяцам
WITH monthly AS (
  SELECT
    CAST(date_trunc('month', paid_at) AS DATE) AS month,
    SUM(amount) AS revenue
  FROM payments
  GROUP BY 1
)
SELECT
  month,
  revenue,
  LAG(revenue) OVER (ORDER BY month) AS prev_month,
  SUM(revenue) OVER (ORDER BY month) AS running_total,
  ROUND(100.0 * revenue / SUM(revenue) OVER (), 1) AS share_pct
FROM monthly
ORDER BY month;

-- 2026-06-01 |  3983.00 |     NULL |  3983.00 | 13.0
-- 2026-07-01 | 10868.00 |  3983.00 | 14851.00 | 35.5
-- 2026-08-01 | 15788.00 | 10868.00 | 30639.00 | 51.5
sqlROW_NUMBER: первая оплата каждого пользователя, по тарифам
SELECT plan, COUNT(*) AS first_payments
FROM (
  SELECT
    user_id,
    plan,
    ROW_NUMBER() OVER (
      PARTITION BY user_id
      ORDER BY paid_at, payment_id
    ) AS rn
  FROM payments
) AS ranked
WHERE rn = 1
GROUP BY plan
ORDER BY first_payments DESC;

-- basic | 514
-- pro   | 296
-- team  | 102

Как работать с датами: неделя, месяц, разница и период

Период задавайте полуоткрытым интервалом: начало входит, конец нет. BETWEEN включает обе границы, но на метке времени вторая граница — это полночь, и события 29 августа в выборку не попадают: 588 из 16 345.

date_trunc от даты возвращает не дату: в PostgreSQL — метку времени с часовым поясом, в DuckDB с версии 1.5 — TIMESTAMP (до 1.4 включительно — DATE). Поэтому результат приводят к DATE. Разборы: периоды, недели и календарь, функции даты, INTERVAL, ряд дат без пропусков.

Даты и периоды
ЗадачаКонструкцияПример
Начало месяцаCAST(date_trunc('month', d) AS DATE)регистрации по месяцам: 1 042, 1 676, 1 895
Начало недели, понедельникCAST(date_trunc('week', d) AS DATE)29 августа 2026 → 24 августа
День из метки времениCAST(event_time AS DATE)в events 91 день
Разница в дняхd2 - d1 для двух датдо первой оплаты — от 3 до 20 дней
Сдвиг на неделюd + 7 или d + INTERVAL '7 days'первое даёт дату, второе — метку времени
День недели, понедельник = 1EXTRACT(isodow FROM d)30 августа 2026 — 7
Месяц целикомd >= DATE '2026-08-01' AND d < DATE '2026-09-01'1 895 регистраций
BETWEEN против полуоткрытого интервала: события 1–29 августа
SELECT
  COUNT(*) FILTER (
    WHERE event_time BETWEEN TIMESTAMP '2026-08-01' AND TIMESTAMP '2026-08-29'
  ) AS between_rows,
  COUNT(*) FILTER (
    WHERE event_time >= TIMESTAMP '2026-08-01'
      AND event_time <  TIMESTAMP '2026-08-30'
  ) AS half_open_rows
FROM events;

-- 15757 | 16345

Что делать с NULL: IS NULL, COALESCE, NULLIF

NULL означает «значение неизвестно», и сравнение с ним даёт NULL, а в WHERE проходят только строки с истиной. Поэтому country <> 'RU' молча теряет 55 пользователей без страны: 1 843 строки вместо 1 898.

Агрегаты пропуски обходят: COUNT(country) видит 4 558 строк из 4 613. Разборы: NULL и COALESCE, NOT IN и NULL.

Пропуски
ЗадачаКонструкцияПример
Найти пропускиIS NULLWHERE country IS NULL — 55; с = NULL строк 0
Сколько пропусковCOUNT(*) - COUNT(col)4 613 − 4 558 = 55
Подставить значениеCOALESCE(col, значение)COALESCE(country, 'unknown')
«Не равно» вместе с NULLIS DISTINCT FROMcountry IS DISTINCT FROM 'RU' — 1 898
Деление на возможный нольNULLIF(x, 0)100.0 * a / NULLIF(b, 0) — NULL вместо ошибки в PostgreSQL и Infinity в DuckDB
Пропуски в конце сортировкиNULLS LASTORDER BY country DESC NULLS LAST: без него при DESC PostgreSQL ставит NULL первым, DuckDB — последним
Пропуски в country: счётчики и два «не равно»
SELECT
  COUNT(*)                  AS users,
  COUNT(country)            AS with_country,
  COUNT(*) - COUNT(country) AS missing,
  COUNT(*) FILTER (WHERE country <> 'RU')               AS not_ru,
  COUNT(*) FILTER (WHERE country IS DISTINCT FROM 'RU') AS not_ru_with_null
FROM users;

-- 4613 | 4558 | 55 | 1843 | 1898

Как записать метрику одной строкой: DAU, конверсия, D7, ARPU, ARPPU

У каждой метрики назовите действие и знаменатель, а потом проверьте, закрыт ли период. События 30 августа обрываются в 12:59, поэтому DAU посчитан по 29 августа: с неполным днём среднее падает с 486,9 до 478,1. В D7 когорта ограничена 22 августа: у более поздних регистраций седьмой день не закрыт или не наступил, и с ними выходит 19,1% вместо 21,4%. Первая оплата приходит через 3–20 дней, так что 19,77% занижены поздними регистрациями: среди зарегистрированных до 9 августа включительно платили 23,57%.

Разборы: продуктовые метрики запросами, DAU, когорты и retention, воронка, ARPU и ARPPU.

Метрики одной строкой
МетрикаЗаписьНа учебной базе
DAUCOUNT(DISTINCT user_id) по CAST(event_time AS DATE)в среднем 486,9 за 1–29 августа
Конверсия в оплату100.0 * COUNT(DISTINCT p.user_id) / COUNT(DISTINCT u.user_id)912 из 4 613 — 19,77%
Удержание D7100.0 * COUNT(DISTINCT e.user_id) / COUNT(DISTINCT u.user_id), событие ровно в день signup_date + 7когорта по 22 августа: 881 из 4 109 — 21,4%
ARPUSUM(p.amount) / COUNT(DISTINCT u.user_id)30 639 / 4 613 = 6,64
ARPPUSUM(p.amount) / COUNT(DISTINCT p.user_id)30 639 / 912 = 33,60
Средний платёжAVG(amount)30 639 / 1 251 = 24,49
Конверсия в оплату, ARPU, ARPPU и средний платёж одним запросом
SELECT
  COUNT(DISTINCT u.user_id) AS users,
  COUNT(DISTINCT p.user_id) AS payers,
  SUM(p.amount)             AS revenue,
  ROUND(100.0 * COUNT(DISTINCT p.user_id) / COUNT(DISTINCT u.user_id), 2) AS conversion_pct,
  ROUND(SUM(p.amount) / COUNT(DISTINCT u.user_id), 2) AS arpu,
  ROUND(SUM(p.amount) / COUNT(DISTINCT p.user_id), 2) AS arppu,
  ROUND(AVG(p.amount), 2)                             AS avg_payment
FROM users u
LEFT JOIN payments p ON p.user_id = u.user_id;

-- 4613 | 912 | 30639.00 | 19.77 | 6.64 | 33.60 | 24.49
-- в DuckDB arppu выводится как 33.6: ROUND от double не дописывает ноль
sqlСредний DAU за полные дни августа и удержание D7
WITH daily AS (
  SELECT CAST(event_time AS DATE) AS day, COUNT(DISTINCT user_id) AS dau
  FROM events
  WHERE event_time >= TIMESTAMP '2026-08-01'
    AND event_time <  TIMESTAMP '2026-08-30'
  GROUP BY 1
),
d7 AS (
  SELECT
    COUNT(DISTINCT u.user_id) AS cohort,
    COUNT(DISTINCT e.user_id) AS returned
  FROM users u
  LEFT JOIN events e
    ON e.user_id = u.user_id
   AND CAST(e.event_time AS DATE) = u.signup_date + 7
  WHERE u.signup_date <= DATE '2026-08-22'
)
SELECT
  (SELECT ROUND(AVG(dau), 1) FROM daily) AS avg_dau,
  cohort,
  returned,
  ROUND(100.0 * returned / cohort, 1) AS d7_pct
FROM d7;

-- 486.9 | 4109 | 881 | 21.4

Запрос выполнился, а число неверное: что проверить

Сообщение об ошибке хотя бы видно. Опаснее запрос, который выполнился и вернул правдоподобное число. Каждое «неправильное» число в таблице получено запуском на учебной базе. Тексты сообщений PostgreSQL — в статье ошибки в SQL-запросах, деление целых и метки времени — в типах данных и CAST, проверки данных — в чеклисте качества данных.

Симптом, причина, исправление
СимптомПричинаИсправление
Сумма после JOIN — 284 913 вместо 30 639две таблицы фактов соединены по user_idагрегировать каждую до соединения
Доля равна 0в PostgreSQL 912 / 4613 — деление целых100.0 * 912 / 4613 — 19,77
После LEFT JOIN осталось 296 пользователей из 4 613условие p.plan = 'pro' стоит в WHEREперенести условие в ON
За период меньше строк: 15 757 вместо 16 345BETWEEN по метке времени обрезал последний деньполуоткрытый интервал: >= и <
Сегмент «не RU» меньше на 55<> не возвращает строки с NULLIS DISTINCT FROM или OR country IS NULL
Последняя точка ряда «упала»: DAU 223 при среднем 486,9день не закрыт: события до 12:59считать по последний полный день

Чем отличаются PostgreSQL, DuckDB, MySQL, SQL Server и ClickHouse

В таблице три расхождения с MySQL, SQL Server и ClickHouse по официальной документации: MySQL 8.4 Reference Manual, Microsoft Learn (Transact-SQL), ClickHouse Docs. Проверено 2 октября 2026 года, на живых серверах запросы не запускались.

Под таблицей — различия PostgreSQL 14 и DuckDB, каждое проверено запуском.

Три частых расхождения диалектов
ЗадачаPostgreSQL и DuckDBMySQL 8.4SQL ServerClickHouse
Первые 5 строкORDER BY … LIMIT 5ORDER BY … LIMIT 5SELECT TOP (5) … ORDER BY …ORDER BY … LIMIT 5
Склеить строкиa || b, CONCAT(a, b)CONCAT(a, b); || по умолчанию означает ORa + b, CONCAT(a, b); || — с версии 2025a || b, concat(a, b)
Начало месяцаCAST(date_trunc('month', d) AS DATE)DATE_FORMAT(d, '%Y-%m-01') — строка; DATE_TRUNC в списке функций нетDATETRUNC(month, d), с версии 2022toStartOfMonth(d), date_trunc('month', d)
  • 912 / 4613 в PostgreSQL — 0, в DuckDB — 0,1977. Перед делением целых ставьте 1.0 *.
  • Алиас из SELECT в WHERE и HAVING DuckDB принимает, PostgreSQL отвечает column "n" does not exist. Повторите выражение или вынесите шаг в CTE.
  • ROUND(x, 2) от double precision в PostgreSQL не существует: приведите аргумент к numeric.
  • SELECT 1 / 0 в PostgreSQL — ошибка division by zero, DuckDB возвращает Infinity, и он молча ломает средние.

Частые вопросы

Что повторить по SQL перед собеседованием? Порядок выполнения запроса, разницу WHERE и HAVING, размножение строк в JOIN, ROW_NUMBER для первой записи в группе и поведение NULL. Задачи — в подборке SQL на собеседовании аналитика.

Подойдёт ли шпаргалка для MySQL и SQL Server? Запросы проверены только в PostgreSQL и DuckDB. В MySQL и SQL Server нет FILTER и NULLS LAST, в MySQL вместо IS NOT DISTINCT FROM пишут <=>, в SQL Server IS DISTINCT FROM — с версии 2022, а AVG от целых возвращает целое. Остальное — в таблице диалектов.

Чем COUNT(*) отличается от COUNT(столбец)? Первый считает строки, второй — непустые значения: 4 613 и 4 558 для country. COUNT(DISTINCT столбец) считает разные значения.

Где выполнить эти запросы? В песочнице симулятора: там та же учебная база.

Продолжить чтение
Вся библиотека