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

SQL-запросы: 40 примеров для аналитика с результатами

Сорок SQL-запросов на одной учебной базе: SELECT и WHERE, COUNT и GROUP BY, JOIN, даты, оконные функции, DAU, retention и A/B-тест. У каждого запроса показан результат.

КПКейсПрактика23 сентября 2026 г.20 мин
Содержание статьи

Примеры SQL-запросов обычно показывают на таблице employees из десяти строк, где у сотрудника Иванова зарплата 50 000. Запрос понятен, но проверить его негде, а результат ничего не говорит о работе аналитика. Здесь сорок запросов на одной учебной базе SaaS-продукта: пять таблиц, 45 185 строк, пользователи, события, платежи, подписки и A/B-эксперимент. Под каждым запросом стоит результат, который он возвращает на этой базе. Запрос можно скопировать в песочницу SQL-курса и получить то же число до последней цифры. Каталог разбит по задачам: выбрать и отфильтровать, посчитать, соединить, работать с датами, оконные функции и продуктовые метрики. Подробные объяснения живут в отдельных статьях, ссылки на них стоят в конце каждого раздела.

Коротко: что в каталоге

Каталог устроен как справочник: найдите задачу в оглавлении, скопируйте запрос и сравните свой результат с показанным. Если число не совпало, разница почти всегда объясняется одной из трёх причин: фильтр по дате, NULL или соединение, которое размножило строки. Все три разобраны в примерах ниже.

  • Примеры 1–8: выбрать колонки, отсортировать, отфильтровать по условию, списку, диапазону, шаблону и NULL.
  • Примеры 9–16: COUNT, COUNT DISTINCT, SUM, AVG, MIN, MAX, GROUP BY и HAVING.
  • Примеры 17–22: INNER JOIN, LEFT JOIN, поиск строк без пары, три таблицы и агрегация до соединения.
  • Примеры 23–28: месяц, неделя, день недели, разница дат, полуоткрытый интервал и возраст пользователя.
  • Примеры 29–34: ROW_NUMBER, RANK, накопительный итог, LAG, скользящее среднее и доля внутри группы.
  • Примеры 35–40: DAU, MAU, активация, D1 retention, ARPU и конверсия A/B-теста.

На какой базе выполняются примеры

База описывает SaaS-сервис для отчётов. Люди регистрируются с 1 июня по 29 августа 2026 года, открывают приложение, создают рабочее пространство и отчёты, приглашают коллег и платят за тариф basic ($19), pro ($29) или team ($39). Выгрузка закрыта 30 августа в 13:00, поэтому последний день в событиях неполный. Канал paid_search запущен 1 июля: до этой даты из него никто не приходил.

Прежде чем писать запрос, полезно знать зерно таблицы — что описывает одна строка. От этого зависит, что считает count(*). В users одна строка — человек, в payments — платёж, и 1 251 платёж принадлежит 912 людям. Половина ошибок в примерах ниже появляется именно там, где зерно меняется незаметно.

Запросы выполнены в DuckDB, но написаны так, чтобы почти все без изменений работали в PostgreSQL. Где синтаксис расходится, это сказано рядом, а сводка различий стоит в конце статьи. Подробное описание таблиц, колонок и связей — в статье об учебной базе данных.

Пять таблиц учебной базы SQL-курса
ТаблицаОдна строкаКолонкиСтрок
usersпользовательuser_id, signup_date, channel, country, device4 613
eventsсобытие в продуктеevent_id, user_id, event_time, event_name35 341
paymentsплатёж: первый или продлениеpayment_id, user_id, paid_at, amount, plan, payment_type1 251
subscriptionsподпискаsubscription_id, user_id, started_at, plan, status, monthly_price912
experiment_exposuresпопадание в вариант экспериментаexposure_id, user_id, experiment_name, variant, exposed_at, converted3 068

Как выбрать колонки, отсортировать и посмотреть уникальные значения

Пример 1 — первый запрос к незнакомой таблице: пять строк показывают колонки и формат значений. ORDER BY здесь не украшение. Без него база вправе вернуть любые пять строк, и в двух запусках они могут различаться.

Пример 2 сортирует по двум колонкам. В последний день зарегистрировались несколько человек, поэтому второй ключ user_id DESC решает, кто из них попадёт в пятёрку. Без второго ключа порядок внутри одной даты не определён.

Пример 3 — DISTINCT: какие значения вообще встречаются в колонке. Так проверяют справочник перед фильтром. Если бы в данных жили и organic, и Organic, условие channel = 'organic' молча потеряло бы часть людей.

Пример 4 фильтрует строки по одному условию. Строковое значение пишется в одинарных кавычках. Двойные кавычки в PostgreSQL означают имя колонки, и country = "KZ" упадёт с ошибкой о несуществующей колонке.

Примеры 1–4: первые строки, сортировка, DISTINCT и простой WHERE
-- 1. Первые пять пользователей
SELECT *
FROM users
ORDER BY user_id
LIMIT 5;
-- 1 | 2026-06-01 | partner  | RU | desktop
-- 2 | 2026-06-01 | referral | RU | mobile
-- 3 | 2026-06-01 | partner  | BY | mobile
-- 4 | 2026-06-01 | organic  | RU | desktop
-- 5 | 2026-06-01 | organic  | AM | desktop

-- 2. Пять последних регистраций
SELECT user_id, signup_date, channel
FROM users
ORDER BY signup_date DESC, user_id DESC
LIMIT 5;
-- 4613 organic, 4612 referral, 4611 paid_search, 4610 organic, 4609 paid_search
-- у всех signup_date = 2026-08-29

-- 3. Какие каналы есть в данных
SELECT DISTINCT channel
FROM users
ORDER BY channel;
-- organic, paid_search, partner, referral

-- 4. Пользователи из Казахстана
SELECT user_id, signup_date, device
FROM users
WHERE country = 'KZ'
ORDER BY user_id
LIMIT 5;
-- user_id 13, 14, 22, 25, 31 (все 2026-06-01); всего таких 912

Как отфильтровать по нескольким условиям, диапазону, шаблону и NULL

Пример 5 показывает ловушку, из-за которой отчёт по «organic из России и Казахстана» оказывается на 41% больше правды. AND связывает сильнее, чем OR. Без скобок условие читается как «organic из России — или кто угодно из Казахстана»: 1 927 человек вместо 1 365. IN снимает вопрос о скобках, потому что список проверяется как одно условие.

Пример 6 — диапазон дат. BETWEEN включает обе границы, поэтому для колонки типа DATE он безопасен. Результат показывает кампанию 15–16 июля: 99 и 100 регистраций против 59–61 в соседние дни. Для колонки с временем, как event_time, BETWEEN с датами уже опасен: вечер последнего дня выпадет. Там нужен полуоткрытый интервал из примера 26.

Пример 7 — поиск по шаблону. % означает любое число символов. Подчёркивание _ в LIKE тоже служебное и означает ровно один любой символ, поэтому шаблон '%_created' работает не потому, что в нём есть подчёркивание, а вопреки этому.

Пример 8 — пропуски. Сравнение country = NULL возвращает ноль строк: NULL не равен ничему, даже другому NULL, и результат сравнения — «неизвестно». Проверка пишется только через IS NULL. В базе 55 пользователей без страны.

Примеры 5–8: AND и OR, BETWEEN, LIKE и IS NULL
-- 5. organic из России или Казахстана
SELECT count(*) AS users
FROM users
WHERE channel = 'organic'
  AND country IN ('RU', 'KZ');
-- 1365
-- без скобок: WHERE channel = 'organic' AND country = 'RU' OR country = 'KZ' → 1927

-- 6. Регистрации вокруг кампании в июле
SELECT signup_date, count(*) AS signups
FROM users
WHERE signup_date BETWEEN DATE '2026-07-13' AND DATE '2026-07-17'
GROUP BY signup_date
ORDER BY signup_date;
-- 07-13: 59, 07-14: 59, 07-15: 99, 07-16: 100, 07-17: 61

-- 7. События, название которых кончается на created
SELECT DISTINCT event_name
FROM events
WHERE event_name LIKE '%created'
ORDER BY event_name;
-- report_created, workspace_created

-- 8. Пользователи без страны
SELECT count(*) AS no_country
FROM users
WHERE country IS NULL;
-- 55   (а WHERE country = NULL вернёт 0)

Как посчитать количество строк, уникальных значений и сумму

Пример 9 — три разных «количества» из одной таблицы. count(*) считает строки, count(country) — строки, где страна заполнена, count(DISTINCT country) — сколько разных стран. 4 613, 4 558 и 4. Разница между первыми двумя — те самые 55 NULL из примера 8.

Пример 10 — вопрос, на котором путаются чаще всего: «сколько платящих». Платежей 1 251, а платящих людей 912, потому что 290 человек продлевали тариф. Если в отчёте стоит слово «пользователи», в запросе должен стоять count(DISTINCT user_id).

Пример 11 — минимум и максимум. Кроме ответа на вопрос он работает как проверка данных: даты регистраций с 1 июня по 29 августа, платежи от $19 до $39. Платёж в $0 или дата из 1970 года здесь сразу бы бросились в глаза.

Пример 12 — условная сумма через CASE внутри sum. Одна строка результата отвечает сразу на два вопроса: $22 328 принесли первые платежи и $8 311 — продления. В PostgreSQL и DuckDB то же самое короче пишется через sum(amount) FILTER (WHERE payment_type = 'first'), но CASE работает в любой базе.

Примеры 9–12: COUNT, COUNT DISTINCT, MIN, MAX и условная сумма
-- 9. Строки, заполненные значения и уникальные значения
SELECT
  count(*)                AS all_rows,
  count(country)          AS with_country,
  count(DISTINCT country) AS countries
FROM users;
-- 4613 | 4558 | 4

-- 10. Платежи и платящие
SELECT count(*) AS payments, count(DISTINCT user_id) AS payers
FROM payments;
-- 1251 | 912

-- 11. Границы данных
SELECT
  (SELECT min(signup_date) FROM users) AS first_signup,
  (SELECT max(signup_date) FROM users) AS last_signup,
  min(amount) AS min_payment,
  max(amount) AS max_payment
FROM payments;
-- 2026-06-01 | 2026-08-29 | 19 | 39

-- 12. Выручка от первых платежей и продлений одной строкой
SELECT
  sum(CASE WHEN payment_type = 'first'   THEN amount ELSE 0 END) AS first_revenue,
  sum(CASE WHEN payment_type = 'renewal' THEN amount ELSE 0 END) AS renewal_revenue,
  sum(amount) AS total
FROM payments;
-- 22328 | 8311 | 30639

Как сгруппировать строки и отфильтровать группы: GROUP BY и HAVING

Пример 13 — самая частая группировка в аналитике: число пользователей по каналу. organic даёт 1 747 регистраций из 4 613, paid_search — 1 015, хотя работает только с июля.

Пример 14 — сумма и среднее по группе. Средний платёж по тарифу равен цене тарифа, потому что внутри тарифа все платежи одинаковые. Такой результат полезен как проверка: если бы среднее по basic вышло $18,40, значит, в данных есть скидки или возвраты, о которых вы не знали.

Пример 15 группирует по двум колонкам и даёт по строке на каждую пару значений. Здесь видна деталь, которую не показывает ни одна группировка по одной колонке: у paid_search две трети регистраций с мобильных (670 из 1 015), а у остальных каналов больше десктопа. Для продукта, где отчёты строят на компьютере, это важно, и это же объясняет часть разрыва в активации из примера 37.

Пример 16 — HAVING, фильтр по результату агрегата. WHERE работает до группировки и про count(*) ещё ничего не знает, поэтому условие на размер группы пишется только в HAVING. Пустая страна (NULL) образует свою группу и в выборку не попала, потому что в ней 55 человек.

Регистрации по каналам и устройствам (пример 15)

paid_search — единственный канал, где мобильных регистраций больше, чем с компьютера. Учебная база SQL-курса.

desktopmobile
Примеры 13–16: GROUP BY по одной и двум колонкам, SUM, AVG и HAVING
-- 13. Регистрации по каналам
SELECT channel, count(*) AS users
FROM users
GROUP BY channel
ORDER BY users DESC;
-- organic 1747, referral 1037, paid_search 1015, partner 814

-- 14. Платежи, выручка и средний платёж по тарифу
SELECT plan, count(*) AS payments, sum(amount) AS revenue,
       round(avg(amount), 2) AS avg_payment
FROM payments
GROUP BY plan
ORDER BY revenue DESC;
-- basic 706 | 13414 | 19
-- pro   403 | 11687 | 29
-- team  142 |  5538 | 39

-- 15. Каналы и устройства
SELECT channel, device, count(*) AS users
FROM users
GROUP BY channel, device
ORDER BY channel, device;
-- organic 953/794, paid_search 345/670, partner 486/328, referral 600/437 (desktop/mobile)

-- 16. Страны, где больше 500 пользователей
SELECT country, count(*) AS users
FROM users
GROUP BY country
HAVING count(*) > 500
ORDER BY users DESC;
-- RU 2715, KZ 912, AM 517

Как соединить две таблицы: INNER JOIN и LEFT JOIN

Пример 17 — выручка по каналу привлечения. Канал живёт в users, деньги — в payments, и без соединения вопрос не решается. Долю от общей выручки даёт скалярный подзапрос в знаменателе. organic приносит 46,1% денег, referral — 30,4%, partner — 15,4%, paid_search — 8,1%. У paid_search 22% регистраций и 8% выручки: ровно тот разрыв, ради которого такой запрос и пишут.

Пример 18 — LEFT JOIN сохраняет пользователей без платежей. У них сумма после соединения равна NULL, и coalesce(..., 0) превращает её в ноль. Без coalesce среднее по пользователям посчиталось бы только по платившим, и ARPU вырос бы в пять раз.

Пример 19 ищет строки без пары: LEFT JOIN и условие IS NULL на ключ правой таблицы. Никогда не платили 3 701 человек. Условие на правую таблицу стоит в WHERE, и здесь это правильно: нужны именно строки, где пары не нашлось. Если в WHERE поставить p.plan = 'pro', LEFT JOIN тихо превратится в INNER, и пользователи без платежей исчезнут.

Доля канала в регистрациях и в выручке

Регистрации — пример 13, выручка — пример 17. Учебная база SQL-курса.

Доля регистраций, %Доля выручки, %
Примеры 17–19: выручка по каналам, LEFT JOIN с COALESCE и поиск строк без пары
-- 17. Выручка и доля выручки по каналу
SELECT u.channel,
       sum(p.amount) AS revenue,
       round(100.0 * sum(p.amount) / (SELECT sum(amount) FROM payments), 1) AS share_pct
FROM payments p
JOIN users u ON u.user_id = p.user_id
GROUP BY u.channel
ORDER BY revenue DESC;
-- organic 14114 | 46.1
-- referral 9326 | 30.4
-- partner  4732 | 15.4
-- paid_search 2467 | 8.1

-- 18. Выручка каждого пользователя, включая ноль
SELECT u.user_id, u.channel, coalesce(sum(p.amount), 0) AS revenue
FROM users u
LEFT JOIN payments p ON p.user_id = u.user_id
GROUP BY u.user_id, u.channel
ORDER BY u.user_id
LIMIT 5;
-- 1 partner 0 | 2 referral 0 | 3 partner 29 | 4 organic 0 | 5 organic 0

-- 19. Сколько пользователей никогда не платили
SELECT count(*) AS never_paid
FROM users u
LEFT JOIN payments p ON p.user_id = u.user_id
WHERE p.user_id IS NULL;
-- 3701

Как соединить три таблицы и не размножить выручку

Пример 20 отвечает на вопрос воронки «создал пространство, но не сделал ни одного отчёта» без соединений вовсе. EXISTS проверяет, есть ли у человека событие, а NOT EXISTS — что его нет. Строки не размножаются, дубли не нужно склеивать DISTINCT. Таких пользователей 975: это главный провал продуктовой воронки учебной базы.

Пример 21 соединяет три таблицы: попадание в эксперимент, пользователя (ради устройства) и платежи. Платежей у человека может быть несколько, поэтому и люди, и плательщики считаются через count(DISTINCT ...). Результат полезен как guardrail к A/B-тесту из примера 40: на десктопе вариант с чек-листом поднял конверсию, но плательщиков в нём 169 против 189 в контроле.

Пример 22 — ошибка, которую не видно по форме результата. Если соединить платежи с событиями по user_id, каждый платёж повторится столько раз, сколько раз человек открывал приложение. Выручка получается $235 833 вместо $30 639 — почти в восемь раз больше. Лечение — свернуть каждую таблицу до одной строки на пользователя и только потом соединять.

Примеры 20–22: NOT EXISTS, три таблицы и агрегация до соединения
-- 20. Создали пространство, но не создали отчёт
SELECT count(*) AS workspace_no_report
FROM users u
WHERE EXISTS (SELECT 1 FROM events e
              WHERE e.user_id = u.user_id AND e.event_name = 'workspace_created')
  AND NOT EXISTS (SELECT 1 FROM events e
              WHERE e.user_id = u.user_id AND e.event_name = 'report_created');
-- 975

-- 21. Участники эксперимента и плательщики по устройству и варианту
SELECT u.device, x.variant,
       count(DISTINCT x.user_id) AS users,
       count(DISTINCT p.user_id) AS payers
FROM experiment_exposures x
JOIN users u ON u.user_id = x.user_id
LEFT JOIN payments p ON p.user_id = x.user_id
GROUP BY u.device, x.variant
ORDER BY u.device, x.variant;
-- desktop checklist 807 | 169
-- desktop control   789 | 189
-- mobile  checklist 734 | 119
-- mobile  control   738 | 137

-- 22. Выручка плативших, которые открывали приложение: сначала свернуть, потом соединить
SELECT sum(r.revenue) AS revenue, count(*) AS payers
FROM (SELECT user_id, sum(amount) AS revenue
      FROM payments GROUP BY user_id) r
JOIN (SELECT user_id, count(*) AS opens
      FROM events WHERE event_name = 'app_open' GROUP BY user_id) o
  ON o.user_id = r.user_id;
-- 30639 | 912
-- «в лоб»: SELECT sum(p.amount) FROM payments p JOIN events e ON e.user_id = p.user_id AND e.event_name = 'app_open' → 235833

Как сгруппировать по месяцам, неделям и дням недели

Пример 23 — регистрации по месяцам через date_trunc. Функция обрезает дату до начала месяца, и все дни июня превращаются в 2026-06-01. В DuckDB и PostgreSQL date_trunc от даты возвращает отметку времени (в PostgreSQL — timestamp with time zone), поэтому результат приведён обратно к DATE, чтобы в отчёте не висело «00:00:00». Июнь — 1 042 регистрации, июль — 1 676, август — 1 895.

Пример 24 раскладывает регистрации по дню недели. extract(dow ...) в PostgreSQL и DuckDB возвращает 0 для воскресенья и 6 для субботы. В воскресенье регистрируется 283 человека за весь период, в будни — от 763 до 828. У B2B-инструмента такая форма ожидаема, и она же предупреждает: сравнивать «понедельник с воскресеньем» при анализе дневной метрики нельзя.

Пример 25 — выручка по неделям. date_trunc('week', ...) начинает неделю с понедельника. Фильтр начинается с понедельника 3 августа. Если начать с 1 августа, неделя с 27 июля попала бы в результат только субботой и воскресеньем и выглядела бы провалом.

Регистрации по дням недели за три месяца (пример 24)

Выходные дают втрое меньше регистраций, чем будни. Учебная база SQL-курса.

Регистрации
Примеры 23–25: месяц, день недели и неделя
-- 23. Регистрации по месяцам
SELECT CAST(date_trunc('month', signup_date) AS DATE) AS month,
       count(*) AS signups
FROM users
GROUP BY 1
ORDER BY 1;
-- 2026-06-01 1042 | 2026-07-01 1676 | 2026-08-01 1895

-- 24. Регистрации по дню недели (0 — воскресенье)
SELECT extract(dow FROM signup_date) AS dow, count(*) AS signups
FROM users
GROUP BY 1
ORDER BY 1;
-- 0: 283 | 1: 763 | 2: 773 | 3: 819 | 4: 828 | 5: 797 | 6: 350

-- 25. Выручка по неделям августа (неделя начинается с понедельника)
SELECT CAST(date_trunc('week', paid_at) AS DATE) AS week,
       sum(amount) AS revenue
FROM payments
WHERE paid_at >= DATE '2026-08-03'
GROUP BY 1
ORDER BY 1;
-- 08-03: 3623 | 08-10: 3112 | 08-17: 3893 | 08-24: 4286

Как посчитать разницу между датами и выбрать период

Пример 26 — сколько дней проходит от регистрации до первой оплаты. В PostgreSQL и DuckDB разность двух дат — целое число дней. Сначала подзапрос сворачивает платежи до первой даты на человека, иначе продления попали бы в среднее. В среднем 11,1 дня, медиана — 11. Медиана через percentile_cont работает в обеих базах; короткая функция median() есть в DuckDB, но не в PostgreSQL.

Пример 27 — последние семь полных дней выгрузки. Для колонки с временем правильный фильтр — полуоткрытый интервал: >= начала первого дня и < начала дня после последнего. Так в период попадают события до 23:59:59 последнего дня и не попадает ничего лишнего. 30 августа исключено сознательно: выгрузка закрыта в 13:00, и неполный день выглядел бы падением.

Пример 28 — «возраст» пользователя. В рабочей базе здесь стоял бы CURRENT_DATE, но у учебной базы фиксированная дата выгрузки, и запрос с CURRENT_DATE через месяц вернул бы другое число. Поэтому дата среза записана явно. 952 человека зарегистрировались меньше двух недель назад: для них ещё рано считать конверсию в оплату за 14 дней.

Примеры 26–28: разница дат, полуоткрытый интервал и возраст
-- 26. Дней от регистрации до первой оплаты
SELECT round(avg(first_paid - signup_date), 1) AS avg_days,
       percentile_cont(0.5) WITHIN GROUP (ORDER BY first_paid - signup_date) AS median_days
FROM (
  SELECT u.user_id, u.signup_date, min(p.paid_at) AS first_paid
  FROM users u
  JOIN payments p ON p.user_id = u.user_id
  GROUP BY u.user_id, u.signup_date
) t;
-- 11.1 | 11

-- 27. События за 23–29 августа
SELECT count(*) AS events, count(DISTINCT user_id) AS users
FROM events
WHERE event_time >= TIMESTAMP '2026-08-23 00:00:00'
  AND event_time <  TIMESTAMP '2026-08-30 00:00:00';
-- 4331 | 2244

-- 28. Сколько пользователей моложе 14 дней на дату выгрузки
SELECT count(*) FILTER (WHERE DATE '2026-08-30' - signup_date < 14) AS younger_14d,
       count(*) AS users
FROM users;
-- 952 | 4613

Оконные функции: номер строки, ранг и доля внутри группы

Пример 29 находит первый платёж каждого пользователя через row_number(). Окно PARTITION BY user_id ORDER BY paid_at нумерует платежи человека по времени, и rn = 1 оставляет первый. Второй ключ сортировки payment_id нужен на случай двух платежей в один день: без него номер 1 достался бы случайному. Первые платежи распределены так: basic 514, pro 296, team 102.

Пример 30 — лучший день месяца по регистрациям через rank(). Окно считается поверх GROUP BY: сначала строки сворачиваются в дни, потом дни ранжируются внутри месяца. Если бы два дня набрали одинаковый максимум, rank() оставил бы оба, а row_number() — один случайный. В июле лучший день — 16-е, второй день кампании.

Пример 31 — доля тарифа внутри канала. sum(count(*)) OVER (PARTITION BY channel) возвращает итог канала в каждую его строку, не схлопывая их. У partner basic занимает 62,1% платежей, у organic — 52,3%: в органике чаще берут дорогие тарифы.

Примеры 29–31: ROW_NUMBER, RANK и доля внутри группы
-- 29. Тариф первого платежа
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
) t
WHERE rn = 1
GROUP BY plan
ORDER BY first_payments DESC;
-- basic 514 | pro 296 | team 102

-- 30. День с максимумом регистраций в каждом месяце
SELECT month, signup_date, signups
FROM (
  SELECT CAST(date_trunc('month', signup_date) AS DATE) AS month,
         signup_date, count(*) AS signups,
         rank() OVER (PARTITION BY date_trunc('month', signup_date)
                      ORDER BY count(*) DESC) AS rnk
  FROM users
  GROUP BY signup_date
) t
WHERE rnk = 1
ORDER BY month;
-- 2026-06-30 51 | 2026-07-16 100 | 2026-08-28 88

-- 31. Доля тарифа в платежах канала
SELECT u.channel, p.plan, count(*) AS payments,
       round(100.0 * count(*) / sum(count(*)) OVER (PARTITION BY u.channel), 1) AS share_in_channel
FROM payments p
JOIN users u ON u.user_id = p.user_id
GROUP BY u.channel, p.plan
ORDER BY u.channel, p.plan;
-- organic: basic 52.3 | pro 36.0 | team 11.7
-- partner: basic 62.1 | pro 26.8 | team 11.1   (всего 12 строк)

Оконные функции: накопительный итог, LAG и скользящее среднее

Пример 32 — выручка нарастающим итогом. Внутренний sum(amount) — агрегат по месяцу, внешний sum(...) OVER (ORDER BY ...) складывает месяцы по порядку. За август выручка $15 788, к концу периода накоплено $30 639 — вся выручка базы, это удобная сверка.

Пример 33 сравнивает месяц с предыдущим через lag(). У первого месяца предыдущего нет, поэтому в его строке NULL, а не ноль. Рост июля — 60,8%, августа — 13,1%. Абсолютный прирост и процент лучше показывать рядом: 13% от большой базы — это всё ещё 219 человек.

Пример 34 — среднее за семь дней. Рамка ROWS BETWEEN 6 PRECEDING AND CURRENT ROW берёт текущий день и шесть предыдущих. Результат показывает, зачем сглаживать: дневные значения скачут от 33 в воскресенье до 88 в пятницу, а среднее за неделю сдвигается меньше чем на человека в день. Последний день, 29 августа, — суббота, поэтому 38 регистраций там не провал.

Примеры 32–34: накопительный итог, LAG и скользящее среднее
-- 32. Выручка по месяцам и нарастающим итогом
SELECT CAST(date_trunc('month', paid_at) AS DATE) AS month,
       sum(amount) AS revenue,
       sum(sum(amount)) OVER (ORDER BY date_trunc('month', paid_at)) AS running_revenue
FROM payments
GROUP BY date_trunc('month', paid_at)
ORDER BY 1;
-- 2026-06-01  3983 |  3983
-- 2026-07-01 10868 | 14851
-- 2026-08-01 15788 | 30639

-- 33. Рост регистраций месяц к месяцу
SELECT month, signups,
       signups - lag(signups) OVER (ORDER BY month) AS diff,
       round(100.0 * signups / lag(signups) OVER (ORDER BY month) - 100, 1) AS growth_pct
FROM (
  SELECT CAST(date_trunc('month', signup_date) AS DATE) AS month, count(*) AS signups
  FROM users
  GROUP BY 1
) m
ORDER BY month;
-- 2026-06-01 1042 | NULL | NULL
-- 2026-07-01 1676 |  634 | 60.8
-- 2026-08-01 1895 |  219 | 13.1

-- 34. Регистрации и среднее за 7 дней, последняя неделя
SELECT signup_date, signups,
       round(avg(signups) OVER (ORDER BY signup_date
             ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 1) AS avg_7d
FROM (SELECT signup_date, count(*) AS signups FROM users GROUP BY signup_date) d
ORDER BY signup_date DESC
LIMIT 7;
-- 08-29: 38 | 72.0    08-28: 88 | 71.9    08-27: 87 | 71.1
-- 08-26: 87 | 70.6    08-25: 86 | 69.9    08-24: 85 | 69.3
-- 08-23: 33 | 68.7

Продуктовые метрики одним запросом: DAU, MAU и активация

Пример 35 — DAU за последнюю неделю. Активным считается человек, который открыл приложение (app_open), и считается он один раз в день через count(DISTINCT user_id). 30 августа — 221 человек против 478–567 в предыдущие дни. Это не падение продукта, а выгрузка, закрытая в 13:00. Средний DAU за полные дни августа, с 1 по 29 число, — 470,9.

Пример 36 — MAU по календарным месяцам: 1 042, 2 688 и 4 109. Рост почти вчетверо складывается из двух вещей: продукт набирает регистрации, и в базе копятся люди, у которых ещё идут два месяца активности. Июньский MAU равен числу июньских регистраций, потому что каждый зарегистрированный хотя бы раз открыл приложение.

Пример 37 — активация по каналам: доля пользователей, создавших рабочее пространство. Подзапрос SELECT DISTINCT user_id сворачивает события до одного человека, поэтому LEFT JOIN не размножает строки, а count(a.user_id) не считает тех, у кого пары не нашлось. referral активируется в 74,7% случаев, organic — в 66,7%, paid_search — в 37,0%.

Примеры 35–37: DAU, MAU и activation rate по каналам
-- 35. DAU за 24–30 августа
SELECT CAST(event_time AS DATE) AS day, count(DISTINCT user_id) AS dau
FROM events
WHERE event_name = 'app_open'
  AND event_time >= TIMESTAMP '2026-08-24'
  AND event_time <  TIMESTAMP '2026-08-31'
GROUP BY 1
ORDER BY 1;
-- 08-24: 478 | 08-25: 511 | 08-26: 549 | 08-27: 541
-- 08-28: 567 | 08-29: 506 | 08-30: 221 (день обрезан выгрузкой в 13:00)

-- 36. MAU по календарным месяцам
SELECT CAST(date_trunc('month', event_time) AS DATE) AS month,
       count(DISTINCT user_id) AS mau
FROM events
WHERE event_name = 'app_open'
GROUP BY 1
ORDER BY 1;
-- 2026-06-01 1042 | 2026-07-01 2688 | 2026-08-01 4109

-- 37. Activation rate по каналам
SELECT u.channel, count(*) AS users, count(a.user_id) AS activated,
       round(100.0 * count(a.user_id) / count(*), 1) AS activation_pct
FROM users u
LEFT JOIN (SELECT DISTINCT user_id FROM events
           WHERE event_name = 'workspace_created') a ON a.user_id = u.user_id
GROUP BY u.channel
ORDER BY activation_pct DESC;
-- referral 1037 | 775 | 74.7
-- organic  1747 | 1166 | 66.7
-- partner   814 | 433 | 53.2
-- paid_search 1015 | 376 | 37.0

Продуктовые метрики одним запросом: D1 retention, ARPU и A/B-тест

Пример 38 — D1 retention: доля людей, которые открыли приложение на следующий календарный день после регистрации. В знаменателе только те, у кого этот день уже закончился. Последний полный день выгрузки — 29 августа, значит, берём регистрации по 28 августа включительно: 4 575 человек, из них вернулись 2 444, то есть 53,42%. Так же считает девятая глава SQL-курса.

Пример 39 — ARPU и ARPPU по каналу. ARPU делит выручку на всех пользователей канала, ARPPU — только на платящих. Разница между ними огромная: у paid_search $2,43 против $28,69. Выручка на платящего по каналам почти одинаковая ($28,69–34,42), а на пользователя различается почти вчетверо. Значит, каналы различаются тем, какая доля людей вообще платит, а не суммой чека.

Пример 40 — конверсия в эксперименте с чек-листом онбординга. В среднем вариант выигрывает: 30,3% против 20,0%. Разбивка по устройству, добавленная через JOIN users, меняет вывод: на десктопе 39,8% против 20,0%, на мобильных 19,9% против 20,1%. Весь прирост дал десктоп, и катить вариант «на всех» по среднему нельзя.

Пример 40 с разбивкой по устройству: GROUP BY u.device, x.variant после JOIN users
УстройствоВариантУчастниковКонверсия
desktopchecklist80739,8%
desktopcontrol78920,0%
mobilechecklist73419,9%
mobilecontrol73820,1%
Примеры 38–40: D1 retention, ARPU и ARPPU, конверсия A/B-теста
-- 38. D1 retention по зрелым регистрациям
SELECT count(*) AS eligible,
       count(*) FILTER (WHERE EXISTS (
         SELECT 1 FROM events e
         WHERE e.user_id = u.user_id
           AND e.event_name = 'app_open'
           AND CAST(e.event_time AS DATE) = u.signup_date + 1
       )) AS returned
FROM users u
WHERE u.signup_date <= DATE '2026-08-28';
-- 4575 | 2444  → 53,42%

-- 39. ARPU и ARPPU по каналам
SELECT u.channel,
       count(DISTINCT u.user_id) AS users,
       count(DISTINCT p.user_id) AS payers,
       round(coalesce(sum(p.amount), 0) / count(DISTINCT u.user_id), 2) AS arpu,
       round(sum(p.amount) / count(DISTINCT p.user_id), 2) AS arppu
FROM users u
LEFT JOIN payments p ON p.user_id = u.user_id
GROUP BY u.channel
ORDER BY arpu DESC;
-- referral    1037 | 278 | 8.99 | 33.55
-- organic     1747 | 410 | 8.08 | 34.42
-- partner      814 | 138 | 5.81 | 34.29
-- paid_search 1015 |  86 | 2.43 | 28.69

-- 40. Конверсия по варианту эксперимента
SELECT variant, count(*) AS users,
       sum(CASE WHEN converted THEN 1 ELSE 0 END) AS converted,
       round(100.0 * avg(CASE WHEN converted THEN 1 ELSE 0 END), 1) AS conversion_pct
FROM experiment_exposures
GROUP BY variant
ORDER BY variant;
-- checklist 1541 | 467 | 30.3
-- control   1527 | 306 | 20.0

Как написать свой SQL-запрос: пять шагов от вопроса к ответу

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

Первый шаг — назвать результат: одна строка на канал, в ней выручка, делённая на число пользователей канала. Второй — найти таблицы и зерно: канал в users (строка — человек), деньги в payments (строка — платёж). Третий — решить, как их связать: пользователи без платежей должны остаться в знаменателе, значит, LEFT JOIN и coalesce. Четвёртый — написать запрос по частям и проверить каждую: число строк после соединения, сумму выручки против общей $30 639. Пятый — прочитать результат глазами менеджера и спросить, нет ли у числа другого объяснения. Так получается пример 39.

Контрольные числа учебной базы

4 613 пользователей, 912 платящих, 1 251 платёж, выручка $30 639. Если ваш запрос по всей базе вернул другую выручку или больше 912 платящих, ищите соединение, которое размножило строки.

  • Назовите результат: что в одной строке и какие колонки.
  • Найдите таблицы и зерно каждой: что описывает одна строка.
  • Решите, какие строки должны остаться без пары, и выберите тип JOIN.
  • Проверьте промежуточный результат: число строк и контрольную сумму.
  • Прочитайте ответ как вывод для человека и проверьте период: не попал ли неполный день или незрелая когорта.

Как перенести примеры в PostgreSQL

Почти все сорок запросов выполняются в PostgreSQL без правок: FILTER, percentile_cont, date_trunc, extract(dow ...), EXISTS, оконные функции и разность дат работают там так же. Различия, которые встречаются в этой статье, собраны в таблице. Каждое проверено на PostgreSQL 16.

Самое неприятное из них — round от дробного числа. В учебной базе amount хранится как DOUBLE, и в PostgreSQL round(avg(amount), 2) для такой колонки падает с ошибкой function round(double precision, integer) does not exist. Если перенесёте данные с типом numeric, ошибки не будет; если с double precision, добавьте приведение ::numeric.

Что ведёт себя по-разному в DuckDB и PostgreSQL
КонструкцияDuckDBPostgreSQL 16
7 / 23.5 — дробное деление3 — целочисленное деление
median(x)естьнет, нужен percentile_cont(0.5) WITHIN GROUP (ORDER BY x)
date_trunc('month', дата)возвращает TIMESTAMPвозвращает timestamp with time zone
round(double, 2)работаетошибка, нужен ::numeric
дата − датацелое число днейцелое число дней
дата + 1следующий деньследующий день

Частые вопросы про SQL-запросы

Что такое SQL-запрос? Это текст на языке SQL, который просит базу данных вернуть или изменить данные. Аналитик почти всегда пишет запросы на чтение: SELECT с условиями, группировкой и соединениями. Результат запроса — таблица.

В каком порядке писать части запроса? SELECTFROMJOINWHEREGROUP BYHAVINGORDER BYLIMIT. Выполняются они в другом порядке: сначала FROM и JOIN, потом WHERE, группировка, HAVING, затем SELECT и сортировка. Поэтому псевдоним из SELECT нельзя использовать в WHERE.

Как посчитать количество строк в SQL? SELECT count(*) FROM таблица. Количество уникальных значений — count(DISTINCT колонка), количество заполненных — count(колонка). Все три варианта — в примере 9.

Как выбрать данные за дату или период? Для колонки типа DATE подходит BETWEEN с двумя датами (пример 6). Для колонки с временем используйте полуоткрытый интервал >= начало AND < конец (пример 27), иначе потеряете события последнего дня.

Как проверить запрос, если нет своей базы? Скопируйте любой пример в песочницу SQL-курса: там та же учебная база, и результат должен совпасть с показанным здесь. Регистрация не нужна.

Что дальше

Следующий шаг после каталога — задачи, где запрос не показан: вопрос сформулирован словами, и таблицы, зерно и период нужно выбрать самому. Для этого есть задачи с решениями на той же базе и главы SQL-курса, где запрос сверяется с эталонным результатом. Первые три главы открыты без регистрации.

Продолжить чтение
Вся библиотека
Продуктовая аналитика23 сентября 2026 г.17 мин

Как выучить SQL с нуля: маршрут на шесть недель для аналитика

План изучения SQL с нуля для будущего аналитика: что учить по неделям, какой запрос вы должны уметь написать к концу каждой, где практиковаться и когда идти на собеседование.

Читать материал