SQL на собеседовании аналитика: 20 задач от SELECT до когорт
Практический разбор SQL-задач с собеседований: JOIN, GROUP BY, оконные функции, даты, retention, воронки и проверки, которые отличают рабочий запрос от случайного ответа.
SQL-задача на собеседовании проверяет не количество выученных конструкций. Важнее, понимаешь ли ты, сколько строк лежит в таблице, где появляется дубликат, какой знаменатель нужен и как убедиться, что результат не случайно размножился после JOIN. Ниже — набор задач, который удобно проходить как тренировочный маршрут: от простого фильтра до когорт и окон.
Как начать SQL-задачу
Первые тридцать секунд лучше потратить на уточнение условий. Спроси, что означает одна строка, какие статусы считать валидными, какой период нужен, что делать с NULL и должен ли результат содержать пользователей без события. Затем назови ожидаемое зерно: “одна строка на пользователя” или “одна строка на месяц и канал”. Это сразу задаёт форму решения.
Если условие неполное, зафиксируй допущение вслух и продолжи. Интервьюер обычно оценивает, заметил ли кандидат неоднозначность. Гораздо хуже молча выбрать случайный знаменатель и получить технически красивый, но бесполезный результат.
- Назвать зерно исходных таблиц и результата.
- Проверить ключи JOIN и ожидаемую кардинальность.
- Отделить фильтр периода от фильтра статуса.
- Решить, нужны ли пользователи без событий.
- После запроса проверить количество строк и дубли ключа.
Задачи 1–4: SELECT, фильтры и агрегация
На старте часто дают таблицу orders и просят найти оплаченные заказы за месяц, выручку по каналам, покупателей с двумя и более заказами или долю отмен. Эти вопросы кажутся простыми, но проверяют полуинтервал дат, COUNT DISTINCT, деление без целочисленного округления и правильный уровень агрегации.
Говори, почему считаешь заказы через `count(distinct order_id)`, если таблица может содержать позиции. Если статус относится к заказу, а строка — к товару, фильтрация после JOIN с products не должна менять бизнес-смысл. В идеале сначала приведи данные к нужному зерну, затем считай метрику.
select channel,
count(distinct order_id) filter (where status = 'paid') as paid_orders,
count(distinct order_id) filter (where status = 'cancelled') as cancelled_orders,
sum(amount) filter (where status = 'paid') as revenue,
round(
count(distinct order_id) filter (where status = 'cancelled')::numeric
/ nullif(count(distinct order_id), 0), 3
) as cancel_rate
from orders
where created_at >= date '2026-07-01'
and created_at < date '2026-08-01'
group by channel
order by revenue desc nulls last;Задачи 5–7: JOIN без размножения строк
Сценарий: есть users, orders и order_items. Нужно показать число пользователей, заказы и выручку по стране. Самая частая ошибка — соединить users с items и посчитать пользователей через `count(*)`, а затем удивиться завышенной цифре. До запроса скажи, что items имеет зерно “одна позиция”, orders — “один заказ”, а итог должен быть на стране.
Для денежных расчётов сначала полезно агрегировать позиции до заказа, а уже затем присоединять пользователей. Если это невозможно, используй `count(distinct)` и проверь, что сумма не дублируется. `SUM(DISTINCT amount)` почти никогда не является исправлением: два разных заказа могут иметь одинаковую сумму.
| Шаг | Ожидаемое зерно | Проверка |
|---|---|---|
| users | одна строка на user_id | unique user_id = rows |
| orders | одна строка на order_id | unique order_id = rows |
| order_items | одна строка на товар в заказе | order_id может повторяться |
| items_to_order | одна строка на order_id | group by order_id перед JOIN |
| итог | одна строка на страну | ключ страны уникален |
Задачи 8–10: топ-N и оконные функции
Топ-3 товара в каждой категории, первая покупка пользователя и накопительная выручка — классические задачи на `row_number`, `rank`, `lag` и `sum() over`. Не путай `row_number` и `rank`: первый всегда даёт уникальный порядок, второй сохраняет одинаковый ранг при равных значениях. Выбор зависит от того, должен ли ответ содержать ровно N строк или всех участников с одинаковым результатом.
Перед оконной функцией почти всегда нужен промежуточный слой. Сначала агрегируй до товара, пользователя или дня. Затем ранжируй. Если поставить окно над сырыми позициями, оно ответит на другой вопрос и будет сложнее проверяться.
with ranked_orders as (
select user_id, order_id, created_at, amount,
row_number() over (
partition by user_id
order by created_at, order_id
) as rn
from orders
where status = 'paid'
)
select user_id, order_id, created_at, amount
from ranked_orders
where rn = 1;Задачи 11–13: даты, NULL и границы периода
Вопросы про “прошлый месяц”, “активность на следующий день” и “пользователей без покупки” выявляют зрелость быстрее, чем редкий синтаксис. Используй явную таймзону, полуинтервалы и `coalesce` только там, где неизвестное действительно нужно превратить в ноль. NULL в цене, канале и дате означает разные вещи и не должен обрабатываться одной универсальной заменой.
Для активности на D7 сначала зафиксируй дату старта когорты, а затем присоедини событие ровно в нужное окно. Не вычитай даты без понимания часового пояса: событие в 23:30 UTC может попасть уже в следующий календарный день бизнеса.
- Правая граница периода обычно не включается.
- Таймзона должна быть одинаковой в cohort и activity.
- LEFT JOIN нужен, если отсутствие события — важный результат.
- NULL не равен нулю и не равен пустой строке.
- CURRENT_DATE удобен для примера, но контрольную дату лучше параметризовать.
Задачи 14–16: retention и воронка
Для retention создай cohorts с user_id и cohort_day, затем activity с уникальной парой user_id и active_day. После этого посчитай distinct пользователей на D1, D7 или D30 и раздели на размер исходной когорты. Главная проверка — D7 не должен случайно считаться от пользователей, которые уже вернулись на D1.
Для воронки лучше собрать user-level flags: был ли просмотр, регистрация, создание workspace и отчёт. Так один пользователь не размножается из-за нескольких событий, а шаги можно сравнить от одной стартовой базы или в последовательном режиме — в зависимости от условия.
select user_id,
bool_or(event_name = 'pricing_viewed') as saw_pricing,
bool_or(event_name = 'signup_completed') as signed_up,
bool_or(event_name = 'workspace_created') as created_workspace,
bool_or(event_name = 'report_viewed') as saw_report
from events
where occurred_at >= timestamp '2026-07-01'
and occurred_at < timestamp '2026-08-01'
group by user_id;Задачи 17–18: доля, медиана и контрольный ответ
Если просят долю пользователей с повторной покупкой или медианное время до активации, уточни знаменатель и способ обработки пользователей без результата. Для медианы не подставляй ноль тем, кто не активировался: это смешает время успешных пользователей с долей неуспеха. Сначала покажи activation rate, затем распределение time-to-value среди активированных.
После запроса назови контрольный ответ. Например, число строк должно равняться числу каналов, дата первой покупки не может быть раньше регистрации, а сумма выручки после JOIN должна совпадать с независимой агрегацией orders. Такая финальная проверка превращает решение из “код выполнился” в аналитический результат.
Задачи 19–20: качество и производительность
В продвинутом раунде могут спросить, как найти дубли, пропуски, нарушение уникальности или медленный запрос. Начни с бизнес-инварианта: order_id должен быть уникален, сумма платежей не должна быть отрицательной без refund, событие не должно лежать до signup_at. Затем выбери SQL-проверку и обсуди, где её запускать — в модели, тесте или мониторинге.
Производительность объясняй через план и объём, а не через магическое “добавлю индекс”. Сначала отфильтруй период, проверь кардинальность JOIN, убери ненужные колонки, посмотри EXPLAIN и только потом обсуждай индекс или предварительную агрегацию. В аналитической базе правильный partition pruning может дать больше, чем локальная перестановка CTE.
Не начинай с длинного запроса, не проверив условие. Не маскируй дубликаты DISTINCT без объяснения. Не говори “запрос правильный”, пока не сверил зерно, количество строк и крайние случаи.
Скрытые проверки в SQL-задаче
Интервьюер может не сказать, что в таблице есть повторные события, NULL или пользователи без заказов. Это не ловушка ради ловушки: в реальной работе именно такие детали меняют ответ. Поэтому после основного запроса добавь короткий аудит. Сколько строк в источнике? Уникален ли ключ? Какова доля пропусков? Сколько пользователей потерял INNER JOIN? Совпадает ли сумма после соединения с исходной таблицей?
Если времени мало, выбери одну критичную проверку и скажи, какую выполнишь после интервью. Важно показать приоритет. Для денежного отчёта это будет сверка суммы и возвратов. Для retention — зрелость когорты и таймзона. Для воронки — distinct user_id и порядок шагов.
| Задача | Главный риск | Быстрая проверка |
|---|---|---|
| Выручка | размножение позиций и возвраты | сверить сумму на уровне order_id |
| Retention | незрелые когорты и неверный return event | показать cohort size и max age |
| Воронка | события вместо пользователей | собрать user-level flags |
| Top-N | ранжирование до агрегации | проверить зерно перед окном |
Как объяснять сложность и компромиссы
На middle- и senior-уровне могут спросить, как ускорить запрос. Не обещай, что CTE, индекс или переписывание в подзапрос всегда решит проблему. Сначала оцени объём, селективность фильтра, план JOIN и размер промежуточных наборов. В warehouse важно не скачать лишние строки, не потерять partition pruning и не считать одну тяжёлую агрегацию заново в нескольких виджетах.
Если есть два корректных варианта, назови trade-off. Оконная функция может быть читаемее, а предварительная агрегация — дешевле на большом объёме. Временная таблица может упростить отладку, но добавить поддержку. Интервьюеру важно услышать, что выбор зависит от масштаба, SLA и стоимости сопровождения.
- Сначала измерить план, а не оптимизировать по интуиции.
- Фильтровать как можно ближе к источнику.
- Не читать колонки, которые не нужны результату.
- Проверить, не повторяется ли дорогой слой в нескольких отчётах.
- Сохранить понятность запроса для следующего аналитика.
Как тренировать SQL вслух
Возьми одну задачу и реши её в три прохода. В первом только проговори таблицы, зерно и план. Во втором напиши минимальный запрос. В третьем добавь проверку и расскажи, как изменится запрос при новом условии. Например, после топ-3 по категории попроси топ-3 по каждой неделе и обсуди, что меняется в PARTITION BY.
Для реальной скорости полезнее десять задач с разбором ошибок, чем пятьдесят задач, решённых молча. После каждого раунда записывай, где ошибся: в синтаксисе, зерне, периоде, знаменателе или трактовке. Эти категории дают понятный план подготовки.
Материалы по теме
SQL-проверки качества данных: дубли, пропуски и скачки метрик
Практический чеклист SQL-проверок перед дашбордом: найти дубли, пропущенные ключи, события без пользователей и неожиданные скачки дневного объёма.
Читать материал
SQL даты и время в аналитике: периоды, недели и возраст пользователя
Как работать с датами в SQL: строить полные периоды, группировать события по дням и неделям, считать возраст пользователя и не ошибаться на границах интервалов.
Читать материал
SQL CASE, COALESCE и NULL: как не сломать сегменты и метрики
Разбираем NULL, CASE и COALESCE на задачах аналитика: как группировать пустые значения, создавать сегменты и показывать ноль вместо пропущенных данных.
Читать материал