new-lvl.pro · SQL · Практика
Задачи // 10 задач с решениями

10 SQL-задач
уровня собеседования
в Яндекс и Авито

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

Оконные функции
Когорты
Воронки
Sessionization
Агрегация

Таблицы для всех задач

Все задачи используют один датасет e-commerce маркетплейса. Вот основные таблицы:

Схема данных
-- Пользователи
users (user_id, registration_date, acquisition_channel, platform)

-- Заказы
orders (order_id, user_id, order_date, amount, status)

-- События (логи)
events (event_id, user_id, event_name, event_time, platform)

-- Значения status: 'completed', 'cancelled', 'refunded'
-- Значения event_name: 'page_view', 'search', 'view_item',
--   'add_to_cart', 'begin_checkout', 'purchase'
// Как использовать
Попробуйте решить задачу самостоятельно, прежде чем открывать решение. Засеките 5–7 минут на задачу — это примерно столько дают на собесе. Потом сравните свой подход с эталонным.
1
Последний заказ каждого клиента
EasyROW_NUMBER
// Условие
Для каждого пользователя выведите его последний завершённый заказ: user_id, order_id, order_date, amount.
Показать решение
Решение
WITH ranked AS (
  SELECT
    user_id, order_id, order_date, amount,
    ROW_NUMBER() OVER (
      PARTITION BY user_id
      ORDER BY order_date DESC
    ) AS rn
  FROM orders
  WHERE status = 'completed'
)
SELECT user_id, order_id, order_date, amount
FROM ranked
WHERE rn = 1;
Паттерн «Топ-1 по группам»: ROW_NUMBER с PARTITION BY user_id и ORDER BY date DESC. Фильтр rn = 1 оставляет только последний заказ. WHERE status = 'completed' — не забываем, что нужны только завершённые.
Типичная ошибка
Забыть PARTITION BY — получите один заказ на всю таблицу, а не по одному на клиента.
2
Month-over-month рост выручки
EasyLAG
// Условие
Посчитайте выручку по месяцам и процент изменения по сравнению с предыдущим месяцем (MoM growth).
Показать решение
Решение
WITH monthly AS (
  SELECT
    DATE_TRUNC('month', order_date) AS month,
    SUM(amount) AS revenue
  FROM orders
  WHERE status = 'completed'
  GROUP BY 1
)
SELECT
  month, revenue,
  LAG(revenue) OVER (ORDER BY month) AS prev_revenue,
  ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
    / LAG(revenue) OVER (ORDER BY month), 1) AS mom_pct
FROM monthly;
LAG(revenue) OVER (ORDER BY month) достаёт выручку предыдущего месяца. Первая строка будет NULL — это нормально. Формула роста: (текущий − предыдущий) / предыдущий × 100.
Типичная ошибка
Писать LAG(revenue, 1, 0) — при 0 в знаменателе будет деление на ноль. Лучше оставить NULL и обработать отдельно.
3
Day-7 Retention по когортам
MediumКогортыJOIN
// Условие
Для каждой когорты (месяц регистрации) посчитайте Day-7 Classic Retention: какая доля пользователей была активна ровно через 7 дней после регистрации.
Показать решение
Решение
SELECT
  DATE_TRUNC('month', u.registration_date) AS cohort,
  COUNT(DISTINCT u.user_id) AS cohort_size,
  ROUND(100.0 * COUNT(DISTINCT CASE
    WHEN e.event_time::DATE = u.registration_date + 7
    THEN u.user_id END)
    / COUNT(DISTINCT u.user_id), 1) AS day7_retention
FROM users u
LEFT JOIN events e ON u.user_id = e.user_id
GROUP BY 1
ORDER BY 1;
LEFT JOIN — чтобы не потерять пользователей без активности. COUNT DISTINCT CASE WHEN — условная агрегация: считаем только тех, у кого есть событие ровно на 7-й день. Не забудьте привести event_time к DATE.
Типичная ошибка
Использовать INNER JOIN вместо LEFT JOIN — потеряете пользователей без событий, и cohort_size будет занижен → retention завышен.
4
Воронка: конверсия по шагам
MediumВоронкаCASE
// Условие
Постройте воронку за последние 30 дней: page_view → view_item → add_to_cart → purchase. Выведите количество уникальных пользователей на каждом шаге и step-to-step конверсию.
Показать решение
Решение
WITH f AS (
  SELECT
    COUNT(DISTINCT CASE WHEN event_name='page_view'    THEN user_id END) AS s1,
    COUNT(DISTINCT CASE WHEN event_name='view_item'    THEN user_id END) AS s2,
    COUNT(DISTINCT CASE WHEN event_name='add_to_cart'  THEN user_id END) AS s3,
    COUNT(DISTINCT CASE WHEN event_name='purchase'     THEN user_id END) AS s4
  FROM events
  WHERE event_time >= CURRENT_DATE - 30
)
SELECT
  s1, s2, s3, s4,
  ROUND(100.0*s2/s1,1) AS cr_1_2,
  ROUND(100.0*s3/NULLIF(s2,0),1) AS cr_2_3,
  ROUND(100.0*s4/NULLIF(s3,0),1) AS cr_3_4
FROM f;
NULLIF(s2, 0) защищает от деления на ноль. Это простая воронка без проверки порядка — достаточно для большинства задач на собесе.
5
Децили пользователей по выручке
MediumNTILEПарето
// Условие
Разбейте пользователей на 10 групп (децилей) по сумме их покупок. Для каждого дециля выведите: количество пользователей, суммарную выручку и процент от общей выручки.
Показать решение
Решение
WITH user_rev AS (
  SELECT user_id, SUM(amount) AS total
  FROM orders WHERE status='completed'
  GROUP BY user_id
),
with_decile AS (
  SELECT *,
    NTILE(10) OVER (ORDER BY total DESC) AS decile
  FROM user_rev
)
SELECT
  decile,
  COUNT(*) AS users,
  SUM(total) AS revenue,
  ROUND(100.0*SUM(total)/SUM(SUM(total)) OVER(),1) AS pct
FROM with_decile
GROUP BY decile
ORDER BY decile;
NTILE(10) делит на 10 равных групп. SUM(SUM(total)) OVER() — общая выручка по всем децилям для расчёта процента. Обычно 1-й дециль даёт 50–70% выручки.
6
Sessionization: определить сессии
HardLAGSUM OVER
// Условие
Определите сессии для каждого пользователя. Новая сессия начинается, если между событиями прошло более 30 минут. Присвойте каждому событию session_id.
Показать решение
Решение
WITH with_prev AS (
  SELECT *,
    LAG(event_time) OVER (
      PARTITION BY user_id ORDER BY event_time
    ) AS prev_time
  FROM events
),
with_flag AS (
  SELECT *,
    CASE WHEN prev_time IS NULL
      OR event_time - prev_time > INTERVAL '30 min'
    THEN 1 ELSE 0 END AS is_new
  FROM with_prev
)
SELECT *,
  SUM(is_new) OVER (
    PARTITION BY user_id ORDER BY event_time
  ) AS session_id
FROM with_flag;
Три CTE: LAG достаёт предыдущее время → флаг new_session (1 если gap > 30 мин) → SUM OVER накопительно считает флаги = session_id. Классическая комбинация LAG + SUM OVER.
7
Когортный LTV за 3 месяца
HardКогортыSUM OVER
// Условие
Для каждой когорты (месяц регистрации) посчитайте кумулятивный LTV по месяцам: сколько в среднем принёс каждый пользователь за 0, 1, 2, 3 месяца.
Показать решение
Решение
WITH c AS (
  SELECT user_id,
    DATE_TRUNC('month', registration_date) AS cohort
  FROM users
),
rev AS (
  SELECT c.cohort,
    DATE_PART('month',
      AGE(DATE_TRUNC('month',o.order_date), c.cohort)
    )::INT AS period,
    SUM(o.amount) AS revenue,
    (SELECT COUNT(DISTINCT user_id) FROM c c2
     WHERE c2.cohort=c.cohort) AS size
  FROM c
  JOIN orders o ON c.user_id=o.user_id
    AND o.status='completed'
    AND o.order_date >= c.cohort
  GROUP BY c.cohort, period
)
SELECT cohort, period,
  ROUND(SUM(revenue) OVER (
    PARTITION BY cohort ORDER BY period
  ) / size, 0) AS cumulative_ltv
FROM rev
ORDER BY cohort, period;
SUM OVER(PARTITION BY cohort ORDER BY period) — накопительная сумма выручки. Делим на размер когорты → средний LTV. Ключевой момент: size считается от всех зарегистрированных, а не от купивших.
8
Running total выручки с нарастающим итогом
EasySUM OVER
// Условие
Выведите дневную выручку и нарастающий итог с начала месяца. Каждый новый месяц итог обнуляется.
Показать решение
Решение
SELECT
  order_date::DATE AS day,
  SUM(amount) AS daily_rev,
  SUM(SUM(amount)) OVER (
    PARTITION BY DATE_TRUNC('month', order_date)
    ORDER BY order_date::DATE
  ) AS mtd_total
FROM orders
WHERE status = 'completed'
GROUP BY order_date::DATE, DATE_TRUNC('month', order_date)
ORDER BY day;
PARTITION BY DATE_TRUNC('month') — сбрасывает итог каждый месяц. Без PARTITION BY итог бы накапливался с начала всей таблицы.
9
Пользователи, чей второй заказ больше первого
MediumROW_NUMBERLEAD
// Условие
Найдите пользователей, у которых сумма второго заказа строго больше суммы первого. Выведите user_id, суммы обоих заказов.
Показать решение
Решение
WITH numbered AS (
  SELECT
    user_id, amount,
    ROW_NUMBER() OVER (
      PARTITION BY user_id ORDER BY order_date
    ) AS rn
  FROM orders
  WHERE status = 'completed'
)
SELECT
  a.user_id,
  a.amount AS first_order,
  b.amount AS second_order
FROM numbered a
JOIN numbered b
  ON a.user_id = b.user_id
  AND a.rn = 1 AND b.rn = 2
WHERE b.amount > a.amount;
Нумеруем заказы по дате, потом self-join по user_id: rn=1 — первый заказ, rn=2 — второй. Альтернатива: использовать LEAD вместо self-join.
10
Дни без заказов (Gap analysis)
Hardgenerate_seriesLEFT JOIN
// Условие
Найдите все дни за последние 90 дней, когда не было ни одного заказа. Учтите, что в таблице orders таких дней просто нет — строку нужно «создать».
Показать решение
Решение
WITH calendar AS (
  SELECT d::DATE
  FROM generate_series(
    CURRENT_DATE - 90,
    CURRENT_DATE,
    '1 day'::INTERVAL
  ) d
),
daily_orders AS (
  SELECT order_date::DATE AS day,
    COUNT(*) AS cnt
  FROM orders
  GROUP BY 1
)
SELECT c.d AS missing_day
FROM calendar c
LEFT JOIN daily_orders o ON c.d = o.day
WHERE o.day IS NULL
ORDER BY c.d;
generate_series создаёт полный календарь. LEFT JOIN к реальным данным — дни без совпадений = дни без заказов. Этот паттерн (calendar LEFT JOIN) встречается на каждом третьем собесе.
Типичная ошибка
Искать «пропуски» в самой таблице orders — это сложно и ненадёжно. generate_series + LEFT JOIN — канонический подход.

Связанные материалы

Как готовиться к SQL-секции

На собеседовании в BigTech SQL-задача занимает 15–20 минут. Вас просят написать запрос на доске или в онлайн-редакторе. Оценивают: правильность, читаемость, умение объяснить логику и знание edge cases (NULL, деление на ноль, LEFT vs INNER JOIN).

Ключевые паттерны, которые закрывают 80% задач: ROW_NUMBER для топ-N по группам, LAG/LEAD для сравнения с предыдущим, SUM OVER для running total, COUNT DISTINCT CASE WHEN для условной агрегации, и generate_series + LEFT JOIN для заполнения пропусков.

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

АТ
Андрей Тарасенко
// Продуктовый аналитик · Авито · Ментор

Задачи собраны из реальных собеседований — моих и менти. Каждая проверена: формулировка, решение, edge cases. Если что-то непонятно — пишите в Telegram, разберём.

Написать в Telegram