SQL-запросы: 40 примеров для аналитика с результатами
Сорок SQL-запросов на одной учебной базе: SELECT и WHERE, COUNT и GROUP BY, JOIN, даты, оконные функции, DAU, retention и A/B-тест. У каждого запроса показан результат.
Содержание статьи
Примеры 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. Где синтаксис расходится, это сказано рядом, а сводка различий стоит в конце статьи. Подробное описание таблиц, колонок и связей — в статье об учебной базе данных.
| Таблица | Одна строка | Колонки | Строк |
|---|---|---|---|
| users | пользователь | user_id, signup_date, channel, country, device | 4 613 |
| events | событие в продукте | event_id, user_id, event_time, event_name | 35 341 |
| payments | платёж: первый или продление | payment_id, user_id, paid_at, amount, plan, payment_type | 1 251 |
| subscriptions | подписка | subscription_id, user_id, started_at, plan, status, monthly_price | 912 |
| experiment_exposures | попадание в вариант эксперимента | exposure_id, user_id, experiment_name, variant, exposed_at, converted | 3 068 |
Как выбрать колонки, отсортировать и посмотреть уникальные значения
Пример 1 — первый запрос к незнакомой таблице: пять строк показывают колонки и формат значений. ORDER BY здесь не украшение. Без него база вправе вернуть любые пять строк, и в двух запусках они могут различаться.
Пример 2 сортирует по двум колонкам. В последний день зарегистрировались несколько человек, поэтому второй ключ user_id DESC решает, кто из них попадёт в пятёрку. Без второго ключа порядок внутри одной даты не определён.
Пример 3 — DISTINCT: какие значения вообще встречаются в колонке. Так проверяют справочник перед фильтром. Если бы в данных жили и organic, и Organic, условие channel = 'organic' молча потеряло бы часть людей.
Пример 4 фильтрует строки по одному условию. Строковое значение пишется в одинарных кавычках. Двойные кавычки в PostgreSQL означают имя колонки, и country = "KZ" упадёт с ошибкой о несуществующей колонке.
-- 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. 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. Строки, заполненные значения и уникальные значения
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 человек.
paid_search — единственный канал, где мобильных регистраций больше, чем с компьютера. Учебная база SQL-курса.
-- 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. Выручка и доля выручки по каналу
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. Создали пространство, но не создали отчёт
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 июля попала бы в результат только субботой и воскресеньем и выглядела бы провалом.
Выходные дают втрое меньше регистраций, чем будни. Учебная база SQL-курса.
-- 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. Дней от регистрации до первой оплаты
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. Тариф первого платежа
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. Выручка по месяцам и нарастающим итогом
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. 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%. Весь прирост дал десктоп, и катить вариант «на всех» по среднему нельзя.
| Устройство | Вариант | Участников | Конверсия |
|---|---|---|---|
| desktop | checklist | 807 | 39,8% |
| desktop | control | 789 | 20,0% |
| mobile | checklist | 734 | 19,9% |
| mobile | control | 738 | 20,1% |
-- 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 16 |
|---|---|---|
| 7 / 2 | 3.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 с условиями, группировкой и соединениями. Результат запроса — таблица.
В каком порядке писать части запроса? SELECT → FROM → JOIN → WHERE → GROUP BY → HAVING → ORDER BY → LIMIT. Выполняются они в другом порядке: сначала FROM и JOIN, потом WHERE, группировка, HAVING, затем SELECT и сортировка. Поэтому псевдоним из SELECT нельзя использовать в WHERE.
Как посчитать количество строк в SQL? SELECT count(*) FROM таблица. Количество уникальных значений — count(DISTINCT колонка), количество заполненных — count(колонка). Все три варианта — в примере 9.
Как выбрать данные за дату или период? Для колонки типа DATE подходит BETWEEN с двумя датами (пример 6). Для колонки с временем используйте полуоткрытый интервал >= начало AND < конец (пример 27), иначе потеряете события последнего дня.
Как проверить запрос, если нет своей базы? Скопируйте любой пример в песочницу SQL-курса: там та же учебная база, и результат должен совпасть с показанным здесь. Регистрация не нужна.
Что дальше
Следующий шаг после каталога — задачи, где запрос не показан: вопрос сформулирован словами, и таблицы, зерно и период нужно выбрать самому. Для этого есть задачи с решениями на той же базе и главы SQL-курса, где запрос сверяется с эталонным результатом. Первые три главы открыты без регистрации.
Материалы по теме
SQL-задачи с решениями: 30 задач для аналитика на данных продукта
Тридцать SQL-задач с ответами и решениями на одной учебной базе: WHERE, GROUP BY, JOIN, даты, когорты и оконные функции. Каждый ответ можно проверить в песочнице.

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