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

SQL-задачи с решениями: 30 задач для аналитика на данных продукта

Тридцать SQL-задач с ответами и решениями на одной учебной базе: WHERE, GROUP BY, JOIN, даты, когорты и оконные функции. Каждый ответ можно проверить в песочнице.

КПКейсПрактика23 сентября 2026 г.21 мин

Большинство SQL-задач в интернете решаются на таблице из десяти строк: сотрудники, отделы, заказы. Запрос сходится с образцом, но проверить его не на чем, и непонятно, что делать с полученным числом. Здесь 30 задач на одной учебной базе продукта: 4 613 пользователей, 35 341 событие, 1 251 платёж. У каждой задачи есть точный ответ, решение и типичная ошибка, которая даёт правдоподобное, но неверное число. Задачи идут от SELECT и WHERE до оконных функций и когорт, а последние три — продуктовые вопросы без подсказок. Решать их можно в песочнице SQL-курса: там та же база, и ваш ответ должен совпасть с нашим до единицы.

Коротко

Задачи разбиты на шесть уровней. Внутри уровня сначала идут условия с подсказками, потом отдельным разделом — ответы, решения и ошибки. Так ответ не попадается на глаза раньше времени.

  • Уровни: SELECT и WHERE, агрегаты, JOIN, даты и когорты, оконные функции, продуктовые вопросы.
  • Ответ — число или маленькая таблица. Если нужен процент, в условии сказано, как округлять.
  • Другой запрос с тем же результатом — тоже верное решение. Сравнивайте числа, а не текст.
  • Если ответ не совпал, в конце есть порядок проверки: зерно, NULL, JOIN, окно, неполный период.
  • Как решать SQL-задачу вслух на собеседовании, разобрано в отдельной статье. Здесь — тренировка на числах.

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

База описывает SaaS-сервис для отчётов. Люди регистрируются, открывают приложение, создают рабочие пространства и отчёты, приглашают коллег и платят за тариф basic ($19), pro ($29) или team ($39). Подписка продлевается раз в 30 дней. Регистрации идут с 1 июня по 29 августа 2026 года, канал paid_search запущен 1 июля.

У выгрузки есть граница, и она участвует в задачах. События записаны до 30 августа 13:00, поэтому последний день неполный, а последний полный — 29 августа. Платежи в базе есть по 30 августа. Окно считается закрытым, если дата начала плюс длина окна не позже этих дат. Там, где это важно, условие задачи напоминает о границе.

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

Таблицы учебной базы
ТаблицаОдна строкаСтрокГлавные колонки
usersпользователь4 613user_id, signup_date, channel, country, device
eventsсобытие в продукте35 341user_id, event_time, event_name
paymentsплатёж: первый или продление1 251user_id, paid_at, amount, plan, payment_type
subscriptionsподписка платящего пользователя912user_id, started_at, plan, status, monthly_price
experiment_exposuresпопадание в вариант эксперимента3 068user_id, variant, converted

Уровень 1. SELECT и WHERE: 6 задач

Первые задачи проверяют фильтры. Ошибки здесь почти никогда не дают сообщения об ошибке: запрос выполняется и возвращает другое число.

Задача 1. Сколько пользователей зарегистрировались в августе 2026 года? Подсказка: задайте месяц полуинтервалом — от первого числа включительно до первого числа следующего месяца, не включая его.

Задача 2. Сколько пользователей не указали страну? Подсказка: пустое значение ищут не знаком равенства.

Задача 3. Маркетинг просит число пользователей не из России. Посчитайте два варианта: только с известной страной и вместе с теми, у кого страна не указана. Подсказка: сравните <> и IS DISTINCT FROM.

Задача 4. Сколько пользователей пришли с мобильных устройств из каналов paid_search или partner? Подсказка: AND связывает сильнее, чем OR.

Задача 5. Выведите пять последних платежей по тарифу team: payment_id, user_id, paid_at, amount. Порядок строк должен быть одинаковым при каждом запуске. Подсказка: в один день бывает несколько платежей.

Задача 6. Сколько продлений прошло в августе и на какую сумму? Продление — это payment_type = 'renewal'. Подсказка: фильтр по типу и по дате стоят в одном WHERE.

Ответы и решения уровня 1

Задачи 2 и 3 — об одном и том же. Сравнение с NULL возвращает не «ложь», а «неизвестно», и WHERE такую строку отбрасывает. Поэтому country = NULL находит ноль строк, а country <> 'RU' молча теряет 55 человек без страны. Если пустые значения должны попасть в ответ, это нужно написать явно.

Задача 5 похожа на простую, но проверяет привычку, которая пригодится везде: сортировка должна однозначно задавать порядок. На 30 августа приходится три платежа team, на 29 августа — четыре. Если сортировать только по дате, пятая строка может меняться от запуска к запуску, и отчёт, собранный вчера, не совпадёт с сегодняшним.

Ответы уровня 1
ЗадачаОтветГде ошибаются
11 895BETWEEN по 31 августа работает для DATE, но с колонкой TIMESTAMP теряет почти весь последний день
255country = NULL возвращает 0 строк
31 843 с известной страной, 1 898 вместе с пустой<> 'RU' не видит 55 строк без страны
4998без скобок выходит 1 484: к мобильным из paid_search добавляются все пользователи partner
5payment_id 1241, 1239, 1223, 1221, 1209сортировка только по paid_at: порядок внутри дня не определён
6232 продления на $5 698нет верхней границы даты: на следующей выгрузке в «август» попадёт сентябрь
Решения задач 1–6
-- Задача 1
SELECT count(*) AS august_signups
FROM users
WHERE signup_date >= DATE '2026-08-01'
  AND signup_date < DATE '2026-09-01';
-- 1895

-- Задача 2
SELECT count(*) AS no_country
FROM users
WHERE country IS NULL;
-- 55

-- Задача 3
SELECT
  count(*) FILTER (WHERE country <> 'RU')               AS known_not_ru,
  count(*) FILTER (WHERE country IS DISTINCT FROM 'RU') AS not_ru_with_unknown
FROM users;
-- 1843 | 1898

-- Задача 4
SELECT count(*) AS mobile_paid_or_partner
FROM users
WHERE device = 'mobile'
  AND (channel = 'paid_search' OR channel = 'partner');
-- 998

-- Задача 5
SELECT payment_id, user_id, paid_at, amount
FROM payments
WHERE plan = 'team'
ORDER BY paid_at DESC, payment_id DESC
LIMIT 5;
-- 1241, 1239, 1223 (30 августа), 1221, 1209 (29 августа)

-- Задача 6
SELECT count(*) AS renewals, sum(amount) AS renewal_revenue
FROM payments
WHERE payment_type = 'renewal'
  AND paid_at >= DATE '2026-08-01'
  AND paid_at < DATE '2026-09-01';
-- 232 | 5698

Уровень 2. Агрегаты и GROUP BY: 6 задач

Здесь главный вопрос — что именно вы считаете: строки, людей или деньги. Во всех задачах уровня ответ проверяется контрольной суммой: регистрации по группам должны давать 4 613, выручка — $30 639.

Задача 7. Сколько регистраций принёс каждый канал? Отсортируйте по убыванию. Подсказка: одна строка результата — один канал.

Задача 8. Сколько всего выручки, сколько платежей и сколько платящих пользователей? Подсказка: платёж и платящий — разные сущности.

Задача 9. Сколько пользователей платили больше одного раза? Подсказка: условие на результат агрегата пишется не в WHERE.

Задача 10. Сколько регистраций было в каждом месяце? Подсказка: date_trunc.

Задача 11. Какая доля пользователей каждого канала пришла с мобильных? Округлите до десятых процента. Подсказка: посчитайте мобильных условным COUNT и следите за типом деления.

Задача 12. Сколько пользователей в каждой стране, включая тех, у кого страна не указана? Подсказка: важно, что стоит внутри COUNT.

Ответы и решения уровня 2

Задача 11 — первая, где у данных видна форма. У paid_search две трети пользователей приходят с телефона, у остальных каналов — от 40 до 45%. Запомните это: в задаче 28 разница между каналами объяснит то, что на первый взгляд выглядит проблемой мобильной версии.

В задаче 12 ошибка почти незаметна. count(country) считает непустые значения колонки, поэтому группа без страны получает ноль, хотя в ней 55 человек. count(*) считает строки и такой проблемы не имеет. Если нужно число людей, а не число заполненных значений, пишите count(*) или count(user_id).

Ответы уровня 2
ЗадачаОтветГде ошибаются
7organic 1 747, referral 1 037, paid_search 1 015, partner 814не сверяют сумму с 4 613 и не замечают потерянную группу
8$30 639, 1 251 платёж, 912 платящихcount(user_id) без DISTINCT даёт 1 251 «платящего»
9290считают строки с renewal: 339 — это продления, а не люди
10июнь 1 042, июль 1 676, август 1 895extract(month …) склеит одинаковые месяцы разных лет
11paid_search 66,0%, organic 45,4%, referral 42,1%, partner 40,3%в PostgreSQL целое делится на целое нацело, и доля становится нулём
12RU 2 715, KZ 912, AM 517, BY 414, без страны 55count(country) даёт группе без страны 0
Доля пользователей с мобильных по каналам, %

paid_search заметно мобильнее остальных каналов. Учебная база SQL-курса, 4 613 пользователей.

С мобильных, %
Решения задач 7–12
-- Задача 7
SELECT channel, count(*) AS signups
FROM users
GROUP BY channel
ORDER BY signups DESC;
-- organic 1747, referral 1037, paid_search 1015, partner 814

-- Задача 8
SELECT
  sum(amount)             AS revenue,
  count(*)                AS payments,
  count(DISTINCT user_id) AS payers
FROM payments;
-- 30639 | 1251 | 912

-- Задача 9
SELECT count(*) AS repeat_payers
FROM (
  SELECT user_id
  FROM payments
  GROUP BY user_id
  HAVING count(*) > 1
) AS t;
-- 290

-- Задача 10
SELECT date_trunc('month', signup_date) AS month, count(*) AS signups
FROM users
GROUP BY month
ORDER BY month;
-- 1042, 1676, 1895

-- Задача 11
SELECT
  channel,
  count(*) AS users,
  round(100.0 * count(*) FILTER (WHERE device = 'mobile') / count(*), 1) AS mobile_pct
FROM users
GROUP BY channel
ORDER BY mobile_pct DESC;
-- paid_search 66.0, organic 45.4, referral 42.1, partner 40.3

-- Задача 12
SELECT country, count(*) AS users
FROM users
GROUP BY country
ORDER BY users DESC;
-- RU 2715, KZ 912, AM 517, BY 414, NULL 55

Уровень 3. JOIN без размножения строк: 5 задач

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

Задача 13. Сколько выручки принёс каждый канал и какая это доля от общей выручки? Подсказка: у платежа один пользователь, поэтому соединение платежей с пользователями строки не размножает. Долю удобно получить оконной суммой поверх агрегата.

Задача 14. Какая доля пользователей каждого канала хоть раз платила? Округлите до сотых процента. Подсказка: сначала сверните платежи до списка плательщиков.

Задача 15. Сколько пользователей ни разу не платили? Подсказка: NOT EXISTS или LEFT JOIN с проверкой правой стороны на NULL.

Задача 16. Сколько выручки за всё время принесли пользователи, которые открывали приложение в августе? Подсказка: у одного человека в августе бывает больше десятка событий app_open.

Задача 17. Среди пользователей, создавших рабочее пространство, какая доля создала и отчёт? Посчитайте по каналам. Подсказка: соберите флаги на уровне пользователя, а потом считайте доли.

Ответы и решения уровня 3

Задачи 14 и 16 показывают одну и ту же ловушку с разной силой. Если соединить пользователей с платежами и посчитать строки, каждый продливший подписку учтётся несколько раз: в organic выйдет 566 «платящих» вместо 410. Если соединить платежи с августовскими событиями, каждый платёж умножится на число заходов, и выручка вырастет с $25 096 до $82 712 — больше, чем вся выручка продукта. Второе хотя бы видно по контрольной сумме. Первое выглядит правдоподобно.

Задача 13 полезна и как вывод. organic даёт 37,9% регистраций, но 46,1% выручки, а paid_search — 22,0% регистраций и только 8,1% выручки. Каналы с одинаковым числом людей приносят очень разные деньги, и именно такие сравнения аналитик делает каждую неделю.

В задаче 17 знаменатель — пользователи с рабочим пространством. Отчёт без рабочего пространства в базе не встречается, это проверяется отдельным запросом. Доли по каналам почти одинаковы, от 62,7 до 66,0%: кто дошёл до рабочего пространства, дальше ведёт себя похоже независимо от канала.

Ответы уровня 3
ЗадачаОтветГде ошибаются
13organic $14 114 (46,1%), referral $9 326 (30,4%), partner $4 732 (15,4%), paid_search $2 467 (8,1%)делят на сумму своей группы — в каждой строке 100%
14referral 26,81%, organic 23,47%, partner 16,95%, paid_search 8,47%count(*) после JOIN с payments: 566 «платящих» в organic
153 701INNER JOIN вместо LEFT JOIN: неплатившие исчезают до подсчёта
16$25 096 от 785 платящихJOIN с событиями умножает платёж на число заходов: $82 712
17organic 66,0%, partner 64,9%, paid_search 63,3%, referral 62,7%count(*) после LEFT JOIN с events: 15 053 строки в organic на 1 747 человек
Доля канала в регистрациях и в выручке, %

Регистрации — 4 613 пользователей, выручка — $30 639 за всё время. Учебная база SQL-курса.

Доля регистрацийДоля выручки
Решения задач 13–17
-- Задача 13
SELECT
  u.channel,
  sum(p.amount) AS revenue,
  round(100.0 * sum(p.amount) / sum(sum(p.amount)) OVER (), 1) AS revenue_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)

-- Задача 14
SELECT
  u.channel,
  count(*) AS signups,
  count(pp.user_id) AS payers,
  round(100.0 * count(pp.user_id) / count(*), 2) AS payer_pct
FROM users u
LEFT JOIN (SELECT DISTINCT user_id FROM payments) AS pp
  ON pp.user_id = u.user_id
GROUP BY u.channel
ORDER BY payer_pct DESC;
-- referral 278 (26.81), organic 410 (23.47), partner 138 (16.95), paid_search 86 (8.47)

-- Задача 15
SELECT count(*) AS never_paid
FROM users u
WHERE NOT EXISTS (
  SELECT 1 FROM payments p WHERE p.user_id = u.user_id
);
-- 3701

-- Задача 16
SELECT sum(amount) AS revenue, count(DISTINCT user_id) AS payers
FROM payments
WHERE user_id IN (
  SELECT user_id
  FROM events
  WHERE event_name = 'app_open'
    AND event_time >= TIMESTAMP '2026-08-01'
    AND event_time < TIMESTAMP '2026-09-01'
);
-- 25096 | 785

-- Задача 17
WITH flags AS (
  SELECT
    u.user_id,
    u.channel,
    max(CASE WHEN e.event_name = 'workspace_created' THEN 1 ELSE 0 END) AS has_workspace,
    max(CASE WHEN e.event_name = 'report_created' THEN 1 ELSE 0 END)    AS has_report
  FROM users u
  LEFT JOIN events e ON e.user_id = u.user_id
  GROUP BY u.user_id, u.channel
)
SELECT
  channel,
  sum(has_workspace) AS activated,
  sum(has_report)    AS with_report,
  round(100.0 * sum(has_report) / sum(has_workspace), 1) AS report_pct
FROM flags
GROUP BY channel
ORDER BY report_pct DESC;
-- organic 1166/770 (66.0), partner 433/281 (64.9), paid_search 376/238 (63.3), referral 775/486 (62.7)

Уровень 4. Даты и когорты: 5 задач

Задачи с датами ломаются на двух вещах: на границе периода и на незрелых пользователях. Незрелый — тот, у кого окно наблюдения ещё не закрылось. Если оставить таких людей в знаменателе, метрика получится ниже настоящей, и тем ниже, чем свежее данные.

Задача 18. Сколько дней проходит от регистрации до первой оплаты? Посчитайте медиану, минимум и максимум. Подсказка: первая оплата — минимальная дата платежа пользователя.

Задача 19. Какая доля пользователей заплатила в первые 7 дней после регистрации? Учитывайте только тех, у кого эти 7 дней целиком попали в выгрузку. Подсказка: платежи есть по 30 августа.

Задача 20. Какой D7 retention у каждого канала — доля пользователей, открывших приложение ровно на седьмой день после регистрации? Округлите до сотых процента. Подсказка: события заканчиваются 30 августа в 13:00, последний полный день — 29 августа.

Задача 21. Какой средний DAU по событию app_open в августе? Округлите до десятых. Подсказка: посмотрите, сколько часов покрывает последний день выгрузки.

Задача 22. Какая доля пользователей платит в первые 14 дней — по месяцам регистрации? Подсказка: у августовской когорты окно закрылось не у всех.

Ответы и решения уровня 4

Во всех задачах уровня есть отсечение по дате регистрации, и оно выведено из одного правила: к дате регистрации прибавляется длина окна, и результат не должен выходить за последний полный день данных. Для D7 по событиям это 22 августа, для оплаты в течение 7 дней — 23 августа, для 14 дней — 16 августа.

Без отсечения ответы ведут себя по-разному, и это стоит видеть. В задаче 19 разница небольшая: 4,6% против 4,9%. В задаче 20 заметнее: D7 у paid_search падает с 11,68% до 9,95%. В задаче 22 она создаёт тренд, которого нет: августовская когорта без отсечения показывает 10,1% вместо 14,3%, и кажется, что конверсия в оплату снижается.

Задача 21 — про неполный день. 30 августа данные есть только до 13:00, и DAU в этот день — 221 человек против 506 накануне. Среднее по 30 дням даёт 462,6, по 29 полным — 470,9. Разница небольшая, но если тот же неполный день попадёт на график, он будет похож на обвал.

Ответы уровня 4
ЗадачаОтветГде ошибаются
18медиана 11 дней, минимум 3, максимум 20берут все платежи вместо первого: продления дают медиану 14
19201 из 4 142, 4,9%в знаменателе люди, у которых неделя ещё не прошла: 4,6%
20referral 27,00%, organic 24,53%, partner 19,17%, paid_search 11,68%; всего 881 из 4 109, 21,44%без отсечения по 22 августа поздние регистрации тянут D7 вниз
21470,9 за 29 полных днейс неполным 30 августа — 462,6
22июнь 16,0% (167 из 1 042), июль 13,4% (225 из 1 676), август 14,3% (135 из 943)без отсечения август даёт 10,1% и ложный спад
D7 retention по каналам: зрелые когорты и все регистрации, %

Зрелые — регистрации по 22 августа включительно (4 109 человек). Учебная база SQL-курса.

Зрелые когортыВсе регистрации
Решения задач 18–22
-- Задача 18
WITH first_payment AS (
  SELECT user_id, min(paid_at) AS first_paid_at
  FROM payments
  GROUP BY user_id
)
SELECT
  percentile_cont(0.5) WITHIN GROUP (ORDER BY f.first_paid_at - u.signup_date) AS median_days,
  min(f.first_paid_at - u.signup_date) AS min_days,
  max(f.first_paid_at - u.signup_date) AS max_days
FROM first_payment f
JOIN users u ON u.user_id = f.user_id;
-- 11 | 3 | 20

-- Задача 19
WITH mature AS (
  SELECT
    u.user_id,
    CASE WHEN EXISTS (
      SELECT 1 FROM payments p
      WHERE p.user_id = u.user_id
        AND p.paid_at < u.signup_date + 7
    ) THEN 1 ELSE 0 END AS paid_7d
  FROM users u
  WHERE u.signup_date <= DATE '2026-08-23'
)
SELECT
  count(*) AS users,
  sum(paid_7d) AS paid_7d,
  round(100.0 * sum(paid_7d) / count(*), 1) AS paid_7d_pct
FROM mature;
-- 4142 | 201 | 4.9

-- Задача 20
WITH d7 AS (
  SELECT
    u.user_id,
    u.channel,
    max(CASE
      WHEN e.event_name = 'app_open'
       AND CAST(e.event_time AS DATE) = u.signup_date + 7
      THEN 1 ELSE 0
    END) AS returned
  FROM users u
  LEFT JOIN events e ON e.user_id = u.user_id
  WHERE u.signup_date <= DATE '2026-08-22'
  GROUP BY u.user_id, u.channel
)
SELECT
  channel,
  count(*) AS cohort,
  sum(returned) AS returned,
  round(100.0 * sum(returned) / count(*), 2) AS d7_pct
FROM d7
GROUP BY channel
ORDER BY d7_pct DESC;
-- referral 926/250 (27.00), organic 1598/392 (24.53), partner 720/138 (19.17), paid_search 865/101 (11.68)

-- Задача 21
WITH daily AS (
  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-01'
    AND event_time < TIMESTAMP '2026-08-30'
  GROUP BY day
)
SELECT count(*) AS days, round(avg(dau), 1) AS avg_dau
FROM daily;
-- 29 | 470.9

-- Задача 22
WITH mature AS (
  SELECT
    u.user_id,
    date_trunc('month', u.signup_date) AS cohort_month,
    CASE WHEN EXISTS (
      SELECT 1 FROM payments p
      WHERE p.user_id = u.user_id
        AND p.paid_at < u.signup_date + 14
    ) THEN 1 ELSE 0 END AS paid_14d
  FROM users u
  WHERE u.signup_date <= DATE '2026-08-16'
)
SELECT
  cohort_month,
  count(*) AS users,
  sum(paid_14d) AS paid_14d,
  round(100.0 * sum(paid_14d) / count(*), 1) AS paid_14d_pct
FROM mature
GROUP BY cohort_month
ORDER BY cohort_month;
-- июнь 1042/167 (16.0), июль 1676/225 (13.4), август 943/135 (14.3)

Уровень 5. Оконные функции: 5 задач

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

Задача 23. Какие четыре дня дали больше всего регистраций? Если дни делят место, покажите все. Подсказка: RANK, а не ROW_NUMBER.

Задача 24. На сколько процентов регистрации каждого месяца выросли к предыдущему месяцу? Подсказка: LAG.

Задача 25. В какой день выручка, накопленная с начала периода, превысила половину всей выручки? Подсказка: сначала выручка по дням, потом оконная сумма.

Задача 26. Какая самая длинная серия дней подряд, в которые пользователь заходил в продукт? Постройте распределение: у скольких пользователей самая длинная серия — 1 день, 2 дня и так далее. Подсказка: внутри серии разность «дата минус номер дня по порядку» не меняется.

Задача 27. Какую долю регистраций месяца дал каждый канал? Подсказка: оконная сумма с PARTITION BY поверх GROUP BY.

Ответы и решения уровня 5

В задаче 23 ничья настоящая: 26 и 27 августа пришло по 87 человек. RANK даёт обоим дням четвёртое место, и в ответе пять строк. ROW_NUMBER с условием «не больше 4» оставит один из двух дней, и какой именно, база решает сама. Первые два места — всплеск 15 и 16 июля: 99 и 100 регистраций при 59–61 в соседние будни.

В задаче 24 рост выглядит так: +60,8% в июле и +13,1% в августе. Но август в данных короче — регистрации идут только по 29-е. В пересчёте на день рост заметнее: 54,1 регистрации в день в июле против 65,3 в августе. Сравнивать месяцы разной длины суммами можно, только если это оговорено.

Задача 26 — классический приём gaps and islands. Если пронумеровать активные дни пользователя по порядку и вычесть номер из даты, у дней одной серии получится одна и та же дата-метка. Дальше это обычная группировка. Чаще всего самая длинная серия — два дня подряд (1 852 человека), восемь дней не пропускал только один пользователь — с 19 по 26 июля.

Ответы уровня 5
ЗадачаОтветГде ошибаются
2316 июля — 100, 15 июля — 99, 28 августа — 88, 26 и 27 августа — по 87ROW_NUMBER теряет один из дней с 87 регистрациями
24июль +60,8% (1 042 → 1 676), август +13,1% (1 676 → 1 895)сравнивают суммы месяцев разной длины без оговорки
251 августа: накоплено $15 331 из $30 639sum() OVER () без ORDER BY даёт всю сумму в каждой строке
261 день — 940, 2 — 1 852, 3 — 1 246, 4 — 401, 5 — 122, 6 — 42, 7 — 9, 8 — 1нумеруют события, а не дни: в дни с несколькими событиями номер убегает вперёд, и серии считаются неверно
27июль: organic 35,6%, paid_search 28,8%, referral 21,0%, partner 14,6%окно без PARTITION BY делит на все регистрации квартала
Самая длинная серия активных дней подряд: сколько пользователей

Одна точка — пользователь, его самая длинная серия дней с событиями. 4 613 пользователей учебной базы SQL-курса.

Пользователей
Решения задач 23–27
-- Задача 23
WITH daily AS (
  SELECT signup_date, count(*) AS signups
  FROM users
  GROUP BY signup_date
),
ranked AS (
  SELECT signup_date, signups,
    rank() OVER (ORDER BY signups DESC) AS place
  FROM daily
)
SELECT signup_date, signups, place
FROM ranked
WHERE place <= 4
ORDER BY place, signup_date;
-- 07-16 100 (1), 07-15 99 (2), 08-28 88 (3), 08-26 87 (4), 08-27 87 (4)

-- Задача 24
WITH monthly AS (
  SELECT date_trunc('month', signup_date) AS month, count(*) AS signups
  FROM users
  GROUP BY month
)
SELECT
  month,
  signups,
  lag(signups) OVER (ORDER BY month) AS prev_signups,
  round(100.0 * (signups - lag(signups) OVER (ORDER BY month))
    / lag(signups) OVER (ORDER BY month), 1) AS growth_pct
FROM monthly
ORDER BY month;
-- июнь NULL, июль 60.8, август 13.1

-- Задача 25
WITH daily AS (
  SELECT paid_at, sum(amount) AS revenue
  FROM payments
  GROUP BY paid_at
),
running AS (
  SELECT paid_at, revenue,
    sum(revenue) OVER (ORDER BY paid_at) AS cumulative
  FROM daily
)
SELECT paid_at, cumulative
FROM running
WHERE cumulative >= (SELECT sum(amount) / 2 FROM payments)
ORDER BY paid_at
LIMIT 1;
-- 2026-08-01 | 15331

-- Задача 26
WITH active_days AS (
  SELECT DISTINCT user_id, CAST(event_time AS DATE) AS day
  FROM events
),
islands AS (
  SELECT user_id, day,
    day - CAST(row_number() OVER (PARTITION BY user_id ORDER BY day) AS INTEGER) AS island
  FROM active_days
),
streaks AS (
  SELECT user_id, island, count(*) AS streak_days
  FROM islands
  GROUP BY user_id, island
),
best AS (
  SELECT user_id, max(streak_days) AS longest
  FROM streaks
  GROUP BY user_id
)
SELECT longest, count(*) AS users
FROM best
GROUP BY longest
ORDER BY longest;
-- 1:940, 2:1852, 3:1246, 4:401, 5:122, 6:42, 7:9, 8:1

-- Задача 27
WITH monthly AS (
  SELECT date_trunc('month', signup_date) AS month, channel, count(*) AS signups
  FROM users
  GROUP BY month, channel
)
SELECT
  month,
  channel,
  signups,
  round(100.0 * signups / sum(signups) OVER (PARTITION BY month), 1) AS pct_of_month
FROM monthly
ORDER BY month, signups DESC;
-- июнь: organic 45.9, referral 28.0, partner 26.1
-- июль: organic 35.6, paid_search 28.8, referral 21.0, partner 14.6
-- август: organic 35.5, paid_search 28.1, referral 20.7, partner 15.7

Уровень 6. Продуктовые вопросы: 3 задачи без подсказок

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

Задача 28. «Мобильные пользователи платят реже, чем десктопные. Нужно чинить оплату на телефоне?»

Задача 29. «Какой тариф продлевают чаще — basic, pro или team?»

Задача 30. «Выручка августа выросла к июлю. Это новые клиенты или продления?»

Разбор продуктовых задач

Задача 28. Первый запрос подтверждает наблюдение: с мобильных платят 17,9%, с десктопа — 21,5%. Но из задачи 11 известно, что paid_search на две трети мобильный, а из задачи 14 — что в paid_search платят реже всех. Среди мобильных пользователей 30,1% пришли из paid_search, среди десктопных — 14,5%. Если сравнить устройства внутри каждого канала, разрыв сжимается до 1,7–2,8 процентного пункта, а в partner даже меняет знак. Ответ в чат: «Разница в основном объясняется каналом: в paid_search мобильных вдвое больше, чем десктопных, и платят там реже всех. Внутри каналов мобильные и десктопные платят почти одинаково, так что поводов чинить оплату эти данные не дают».

Задача 29. Если посчитать долю продливших среди всех платящих, у всех тарифов выйдет около 32%. Но в эту долю попали люди, чей первый платёж был в августе: их продление ещё не наступило. Продление приходит через 30 дней, поэтому честный знаменатель — те, кто впервые заплатил не позже 31 июля. Среди них продлили 290 из 502, 57,8%, а по тарифам — 57,3, 58,2 и 58,9%. Ответ: «Тарифы продлевают одинаково, около 58%. Разница в 1,6 пункта на группах от 56 до 288 человек слишком мала, чтобы делать выводы».

Задача 30. Выручка выросла с $10 868 в июле до $15 788 в августе, на $4 920. Первые платежи дали из этого $1 835 (с $8 255 до $10 090), продления — $3 085 (с $2 613 до $5 698). Ответ: «Почти две трети прироста — продления. Это ожидаемо: продления начались только в июле и накапливаются с каждым месяцем. Новых платящих тоже больше — 410 первых платежей против 335». Отдельно стоит сказать, что в августе платежи учтены по 30-е число, а в июле — за все 31 день.

Задача 28: доля платящих по каналу и устройству, %
КаналДесктопМобильные
organic24,622,2
referral28,025,2
partner15,818,6
paid_search9,67,9
все каналы21,517,9
Решения задач 28–30
-- Задача 28: общий разрыв и доля paid_search по устройствам
SELECT
  device,
  count(*) AS users,
  round(100.0 * count(*) FILTER (WHERE user_id IN (SELECT user_id FROM payments)) / count(*), 1) AS payer_pct,
  round(100.0 * count(*) FILTER (WHERE channel = 'paid_search') / count(*), 1) AS paid_search_pct
FROM users
GROUP BY device
ORDER BY device;
-- desktop 2384 | 21.5 | 14.5
-- mobile   2229 | 17.9 | 30.1

-- Задача 28: то же внутри каждого канала
SELECT
  channel,
  device,
  count(*) AS users,
  round(100.0 * count(*) FILTER (
    WHERE user_id IN (SELECT user_id FROM payments)
  ) / count(*), 1) AS payer_pct
FROM users
GROUP BY channel, device
ORDER BY channel, device;

-- Задача 29
WITH payers AS (
  SELECT
    user_id,
    min(plan) AS plan,
    min(paid_at) AS first_paid_at,
    max(CASE WHEN payment_type = 'renewal' THEN 1 ELSE 0 END) AS renewed
  FROM payments
  GROUP BY user_id
)
SELECT
  plan,
  count(*) AS payers,
  sum(renewed) AS renewed,
  round(100.0 * sum(renewed) / count(*), 1) AS renewal_pct
FROM payers
WHERE first_paid_at <= DATE '2026-07-31'
GROUP BY plan
ORDER BY plan;
-- basic 288/165 (57.3), pro 158/92 (58.2), team 56/33 (58.9)

-- Задача 30
SELECT
  date_trunc('month', paid_at) AS month,
  sum(amount) FILTER (WHERE payment_type = 'first')   AS new_revenue,
  sum(amount) FILTER (WHERE payment_type = 'renewal') AS renewal_revenue,
  sum(amount) AS revenue
FROM payments
GROUP BY month
ORDER BY month;
-- июнь 3983 | NULL | 3983
-- июль 8255 | 2613 | 10868
-- август 10090 | 5698 | 15788
Почему в задаче 29 можно брать min(plan)

Тариф у пользователя в базе не меняется: запрос, который ищет людей с двумя разными тарифами, возвращает ноль. Если бы переходы между тарифами были, нужно было бы взять тариф первого платежа явно.

Как проверять себя, если ответ не совпал

Несовпадение с ответом почти всегда объясняется одной из шести причин. Проверяйте их по порядку: первые три встречаются чаще остальных.

  • Зерно. Назовите одной фразой, что описывает строка результата. Если «платёж», а вопрос про людей, нужен DISTINCT или предварительная группировка.
  • NULL. Посчитайте count(*) - count(колонка) для каждой колонки в условии. Сравнения с NULL тихо выбрасывают строки.
  • JOIN. Посчитайте строки до и после соединения. Если их стало больше, чем ожидалось, вы считаете повторы.
  • Окно и зрелость. Для любой метрики «за N дней» проверьте, что у всех в знаменателе эти N дней уже прошли.
  • Граница периода. Используйте полуинтервал >= начало AND < конец и помните о неполном последнем дне.
  • Контрольная сумма. Регистрации по группам должны давать 4 613, выручка — $30 639, платящие — 912. Если сумма не сходится, строки потерялись или размножились.

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

Где решать эти задачи онлайн? В песочнице SQL-курса: она открывается без регистрации, в ней та же учебная база и тот же DuckDB. Ответ должен совпасть с таблицами выше до единицы.

Подойдут ли задачи для подготовки к собеседованию? Да, особенно уровни 3–6: соединения, окна, когорты и продуктовые вопросы. Теоретические вопросы с короткими ответами собраны в отдельной статье, а формат «решите вслух» разобран в статье о SQL на собеседовании аналитика.

Работают ли решения в PostgreSQL? Мы прогнали их на PostgreSQL 16 с теми же данными: совпало всё, кроме трёх мест. Функции median в PostgreSQL нет — вместо неё percentile_cont(0.5) WITHIN GROUP (ORDER BY …), как в задаче 18. round(x, 1) не принимает double precision — если сумма хранится в этом типе, приведите её к numeric. И псевдоним нельзя использовать в том же SELECT, где он объявлен, — DuckDB это разрешает, PostgreSQL нет.

Можно ли решить задачу другим запросом? Конечно. Подзапрос вместо CTE, EXISTS вместо IN, CASE вместо FILTER — всё это даёт тот же результат. Курс тоже проверяет результат запроса, а не его текст.

Задача не получается даже с подсказкой. Что делать? Откройте статью, на которую ссылается уровень, и найдите в ней тот же приём. Затем решите задачу на маленьком срезе, например на одном дне, и сверьте результат вручную. Если и это не помогает, задача пока преждевременна: вернитесь к ней после соседних.

Что дальше

Тридцать задач покрывают то, что аналитик пишет каждую неделю: фильтры, сводки, соединения, окна и когорты. Если какой-то уровень дался тяжело, лучше вернуться к опорной статье по теме, чем решать больше задач того же типа.

В SQL-курсе те же темы разобраны по главам, на каждое задание есть автоматическая проверка, в главе 13 — самостоятельный кейс про бюджет paid_search, в главе 17 — A/B-тест, где среднее скрывает сегмент. Первые три главы открыты бесплатно.

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