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

Вопросы по SQL на собеседовании: 35 вопросов с короткими ответами

Теоретические вопросы по SQL для аналитика с ответами в два-пять предложений: ключи, NULL, JOIN, GROUP BY, окна, индексы, CTE, DAU и retention — с примерами на данных.

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

Перед SQL-задачей или прямо по ходу её решения на собеседовании задают короткие вопросы: чем WHERE отличается от HAVING, почему NOT IN вернул пустоту, что делает RANK при ничьей. На них отвечают за полминуты, и по ответу видно, понимаете ли вы, как база считает, или помните синтаксис. Здесь 35 таких вопросов для аналитика данных и продуктового аналитика. Ответы — по два-пять предложений, а там, где это помогает, с числом из учебной базы SQL-курса, которое можно повторить в песочнице. Практику на задачах мы разобрали отдельно: 30 задач с решениями и разбор того, как решать SQL-задачу вслух.

Что проверяют теоретическими вопросами

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

Хороший ответ устроен одинаково: одно предложение по сути, одно — про последствие для расчёта, и пример, если попросят. «WHERE фильтрует строки до группировки, HAVING — группы после. Поэтому агрегат в WHERE написать нельзя. Например, людей, плативших больше одного раза, ищут через HAVING count(*) > 1». Этого достаточно.

Все примеры ниже посчитаны на учебной базе SQL-курса. Это SaaS-продукт: 4 613 пользователей в users, 35 341 событие в events, 1 251 платёж в payments, 912 подписок в subscriptions и 3 068 попаданий в A/B-тест в experiment_exposures. Где поведение PostgreSQL отличается от DuckDB, на котором работает песочница, сказано отдельно — оба варианта проверены.

Основы: ключи, NULL, порядок выполнения, WHERE и HAVING

1. Что такое первичный ключ? Колонка или набор колонок, которые однозначно определяют строку: значения уникальны и не бывают NULL. В users эту роль играет user_id — 4 613 строк и 4 613 различных значений. По первичному ключу на строку ссылаются другие таблицы, и по нему же проверяют дубли. В PostgreSQL объявление первичного ключа автоматически создаёт уникальный индекс.

2. Что такое внешний ключ? Колонка, значения которой должны существовать в первичном ключе другой таблицы. payments.user_id по смыслу ссылается на users.user_id. Ограничение FOREIGN KEY запрещает платёж без пользователя; в учебной базе оно не объявлено, но запрос показывает, что таких платежей ноль. Важная деталь для производительности: в PostgreSQL внешний ключ не создаёт индекс на ссылающейся колонке, его добавляют отдельно.

3. Чем NULL отличается от нуля и пустой строки? NULL означает «значение неизвестно». Любое сравнение с NULL, включая NULL = NULL, даёт не истину и не ложь, а NULL, и WHERE такую строку отбрасывает. Поэтому country = NULL находит 0 строк, а country IS NULL — 55. Для сравнения с учётом NULL есть IS DISTINCT FROM.

4. Как агрегатные функции обрабатывают NULL? count(*) считает строки, count(колонка) — непустые значения, sum и avg пропускают NULL. Отсюда частая ошибка: avg(CASE WHEN device = 'mobile' THEN 1 END) без ELSE возвращает 1, потому что усредняет только единицы. С ELSE 0 получается доля мобильных — 0,483.

5. В каком порядке выполняется запрос? Логический порядок: FROM и JOIN, WHERE, GROUP BY, HAVING, SELECT, DISTINCT, ORDER BY, LIMIT. Из этого следует, что псевдоним из SELECT ещё не существует в WHERE: PostgreSQL ответит column "…" does not exist. DuckDB такие ссылки разрешает, поэтому запрос из песочницы может не перенестись в PostgreSQL как есть.

6. Чем WHERE отличается от HAVING? WHERE фильтрует строки до группировки, HAVING — группы после неё. Агрегат в WHERE написать нельзя: на этом шаге групп ещё нет. Пользователей, плативших больше одного раза, находят через GROUP BY user_id HAVING count(*) > 1 — их 290. Условие, которое не зависит от агрегата, лучше ставить в WHERE: база отбросит строки раньше.

7. Чем DISTINCT отличается от GROUP BY? Для удаления дублей они дают одинаковый результат. GROUP BY нужен, когда к уникальным значениям надо посчитать агрегат. count(DISTINCT user_id) по платежам даёт 912 платящих при 1 251 платеже. Если DISTINCT стоит в запросе «чтобы число сошлось», стоит поискать, где строки размножились.

8. Что значит ошибка «column must appear in the GROUP BY clause»? Каждая колонка в SELECT должна быть либо в GROUP BY, либо внутри агрегата: иначе непонятно, какое из значений группы показать. PostgreSQL делает одно исключение — колонки, которые однозначно зависят от первичного ключа, указанного в GROUP BY. DuckDB в тексте ошибки предлагает any_value(), но это решение подходит, только если значение внутри группы действительно одно.

Логический порядок выполнения SELECT
ШагЧто происходитЧто из этого следует
FROM, JOINсобираются строки из таблицпосле JOIN строк может стать больше
WHEREотбрасываются строкиагрегатов и псевдонимов ещё нет
GROUP BYстроки сворачиваются в группыодна строка — одна группа
HAVINGотбрасываются группыздесь можно фильтровать по count(*)
SELECTсчитаются выражения и окнаоконные функции видят только прошедшие WHERE строки
ORDER BY, LIMITсортировка и обрезкапсевдонимы из SELECT уже доступны

Соединения: виды JOIN, дубли, IN и EXISTS, NOT IN и NULL

9. Какие бывают JOIN? INNER оставляет пары, у которых нашлось совпадение. LEFT сохраняет все строки левой таблицы, а у строк без пары справа ставит NULL. RIGHT делает то же зеркально, FULL сохраняет строки обеих сторон, CROSS даёт все сочетания. users LEFT JOIN payments возвращает 4 952 строки: 3 701 пользователь без платежей и 1 251 строка платежей. INNER JOIN — только 1 251.

10. Почему после JOIN строк стало больше, чем было? Потому что связь «один ко многим»: на одного пользователя приходится несколько платежей, и строка пользователя повторяется для каждого. Если после этого считать людей через count(*), получится число платежей: в канале organic 566 вместо 410 платящих. Перед соединением стоит спросить себя, сколько строк справа приходится на одну строку слева.

11. Чем условие в ON отличается от условия в WHERE при LEFT JOIN? Условие в ON решает, какие строки справа подходят в пару, и не удаляет строки слева. Условие на правую таблицу в WHERE отбрасывает строки, где справа NULL, и LEFT JOIN превращается в INNER. LEFT JOIN payments p ON … WHERE p.plan = 'team' возвращает 142 строки, и пользователи без тарифа team исчезают. С тем же условием в ON остаются все 4 613 пользователей.

12. Чем IN отличается от EXISTS? Оба проверяют наличие и не размножают строки. EXISTS связан с внешней строкой, и в него можно добавить условия на пару, например «платил в первые 14 дней после регистрации». По скорости в PostgreSQL разницы обычно нет: для запроса «пользователи с платежами» оба варианта дают один и тот же план — Hash Semi Join.

13. Почему NOT IN вернул ноль строк? Потому что в списке есть NULL. x NOT IN (a, NULL) раскрывается в x <> a AND x <> NULL, второе сравнение даёт NULL, и условие не бывает истинным ни для одной строки. На учебной базе список активных подписчиков, собранный через LEFT JOIN, содержит NULL, и NOT IN даёт 0 вместо 4 098. NOT EXISTS от NULL не зависит, поэтому для исключения берут его.

14. Чем UNION отличается от UNION ALL? UNION убирает повторяющиеся строки, UNION ALL оставляет всё как есть. user_id из платежей и подписок через UNION дают 912 строк, через UNION ALL — 2 163. Если дубли не нужны по смыслу, UNION честнее; если дублей быть не может, UNION ALL дешевле, потому что базе не нужно их искать.

15. Зачем аналитику CROSS JOIN? Чтобы построить сетку, в которой есть и пустые ячейки. 90 дней на 4 канала — это 360 пар, а GROUP BY по регистрациям вернёт только 330: у paid_search нет строк за июнь, потому что канал запущен 1 июля. В отчёте по дням такие пропуски должны стать нулями, а не исчезнуть, иначе среднее и график будут врать.

Сколько строк даёт соединение users и payments
ЗапросСтрокПочему
users4 613одна строка — пользователь
users INNER JOIN payments1 251одна строка — платёж, неплатившие выпали
users LEFT JOIN payments4 9521 251 платёж + 3 701 пользователь без платежей
LEFT JOIN, условие plan = team в WHERE142LEFT JOIN превратился в INNER
LEFT JOIN, условие plan = team в ON4 653все пользователи сохранились, у 4 511 справа NULL
NOT IN и NOT EXISTS на одном списке
-- список «активных», в котором есть NULL
WITH active_list AS (
  SELECT s.user_id
  FROM users u
  LEFT JOIN subscriptions s
    ON s.user_id = u.user_id AND s.status = 'active'
)
SELECT
  (SELECT count(*) FROM users
    WHERE user_id NOT IN (SELECT user_id FROM active_list)) AS not_in,
  (SELECT count(*) FROM users u
    WHERE NOT EXISTS (
      SELECT 1 FROM subscriptions s
      WHERE s.user_id = u.user_id AND s.status = 'active'
    )) AS not_exists;

-- not_in 0, not_exists 4098

Агрегация и окна: COUNT, ROW_NUMBER, RANK и рамка окна

16. Чем COUNT(*), COUNT(колонка) и COUNT(DISTINCT колонка) отличаются? Первый считает строки, второй — непустые значения, третий — различные непустые значения. На users.country: 4 613, 4 558 и 4. Пятидесяти пяти людям без страны не хватило значения, а в DISTINCT NULL не считается отдельной страной.

17. Чем оконная функция отличается от GROUP BY? GROUP BY сворачивает строки в группы, окно считает агрегат по группе и оставляет каждую строку на месте. Поэтому окном удобно получать долю строки в итоге группы: регистрации канала за месяц, делённые на sum(signups) OVER (PARTITION BY month).

18. Чем ROW_NUMBER отличается от RANK и DENSE_RANK? Различие видно только на ничьих. 26 и 27 августа пришло по 87 регистраций: ROW_NUMBER даст им номера 4 и 5 в произвольном порядке, RANK — оба четвёртых места и следующему дню шестое, DENSE_RANK — оба четвёртых и следующему пятое. Для «топ-3 без повторов» нужен ROW_NUMBER с однозначной сортировкой, для «все, кто делит место» — RANK.

19. Как найти топ-N в каждой группе? Пронумеровать строки внутри группы через row_number() OVER (PARTITION BY … ORDER BY …) и отфильтровать номер во внешнем запросе или CTE. Прямо в WHERE того же запроса нельзя: окна считаются после WHERE. В DuckDB для этого есть QUALIFY, в PostgreSQL его нет.

20. Что такое рамка окна и какая она по умолчанию? Рамка — какие строки группы попадают в расчёт для текущей строки. Если в окне есть ORDER BY, по умолчанию берутся строки от начала до текущей, включая все строки с тем же значением сортировки. Поэтому накопительная сумма по дате даёт одинаковое значение двум платежам 6 июня — 48, а с ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — сначала 29, потом 48.

21. Почему LAST_VALUE возвращает не последнее значение? Из-за той же рамки по умолчанию: она заканчивается на текущей строке и её ровесниках, поэтому «последнее» — это последнее из уже просмотренных. Чтобы получить последнее значение всей группы, рамку расширяют до ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING или берут FIRST_VALUE с обратной сортировкой.

22. Почему скользящее среднее изменилось после добавления WHERE? Потому что WHERE отработал раньше окна, и окну не хватило предыдущих дней. Среднее регистраций за 7 дней на 16 июля — 60,3. Если отфильтровать дни с 13 по 19 июля в том же запросе, окно для 16 июля видит только четыре дня, и получается 79,3. Фильтр по периоду показа нужно ставить снаружи, после расчёта окна.

23. Что делают LAG и LEAD? Возвращают значение из предыдущей или следующей строки в порядке окна. Так считают прирост к прошлому периоду: регистрации выросли с 1 042 в июне до 1 676 в июле (+60,8%) и до 1 895 в августе (+13,1%). У первой строки предыдущей нет, и LAG возвращает NULL — прирост для июня не определён, а не равен нулю.

Ничья 26 и 27 августа в трёх функциях ранжирования
ДеньРегистрацийROW_NUMBERRANKDENSE_RANK
16 июля100111
15 июля99222
28 августа88333
26 августа874 или 544
27 августа875 или 444
25 августа86665
Окно считает после WHERE: сначала сглаживание, потом фильтр периода
WITH daily AS (
  SELECT signup_date, count(*) AS signups
  FROM users
  GROUP BY signup_date
),
smoothed AS (
  SELECT signup_date, signups,
    round(avg(signups) OVER (
      ORDER BY signup_date
      ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
    ), 1) AS ma7
  FROM daily
)
SELECT signup_date, signups, ma7
FROM smoothed
WHERE signup_date BETWEEN DATE '2026-07-13' AND DATE '2026-07-19'
ORDER BY signup_date;

-- 16 июля: 100 регистраций, ma7 = 60.3
-- если поставить WHERE внутрь daily, для 16 июля получится 79.3

Производительность: индексы, EXPLAIN и CTE

24. Что такое индекс и когда он не помогает? Индекс — отдельная упорядоченная структура, по которой база находит строки, не читая всю таблицу. Он не помогает, если колонка обёрнута в функцию: условие date_trunc('day', signup_date) = … заставит PostgreSQL прочитать таблицу целиком, а диапазон signup_date >= … AND signup_date < … использует индекс. То же с LIKE '%…': поиск по окончанию строки обычный B-tree не ускоряет.

25. Чем EXPLAIN отличается от EXPLAIN ANALYZE? EXPLAIN показывает план и оценки: сколько строк база ожидает на каждом шаге. EXPLAIN ANALYZE выполняет запрос и добавляет фактическое время и число строк. Большой разрыв между оценкой и фактом — частая причина плохого плана. С UPDATE и DELETE осторожнее: EXPLAIN ANALYZE их действительно выполнит.

26. Материализуется ли CTE? В PostgreSQL с 12-й версии CTE, который используется один раз и не меняет данные, встраивается в запрос, как подзапрос. Если на него ссылаются дважды, PostgreSQL по умолчанию вычисляет его один раз и хранит результат. Поведение можно задать явно: WITH x AS MATERIALIZED (…) или AS NOT MATERIALIZED. До 12-й версии CTE всегда материализовался, и старые советы «CTE медленнее подзапроса» пришли оттуда.

27. Что быстрее: IN, EXISTS или JOIN? Чаще всего одинаково: планировщик сводит их к одному плану. Для «пользователей с платежами» PostgreSQL строит Hash Semi Join и для IN, и для EXISTS. Исключение — отрицание: NOT EXISTS становится Hash Anti Join, а NOT IN остаётся фильтром с хешированным подзапросом: база обязана отдельно учитывать NULL и не может свести его к anti join. Спорить о скорости лучше с планом на экране.

28. Как ускорить медленный аналитический запрос? Отфильтровать период как можно раньше, агрегировать большую таблицу до соединения, а не после, выбирать только нужные колонки и не оборачивать фильтруемые колонки в функции. Потом сравнить EXPLAIN ANALYZE до и после. Индекс — последний шаг, а не первый: он ускоряет чтение, но замедляет запись и занимает место.

Какой план PostgreSQL 16 строит для проверки наличия
УсловиеПланЧто это значит
IN (SELECT …)Hash Semi Joinнайти хотя бы одну пару и остановиться
EXISTS (SELECT …)Hash Semi Joinтот же план, что у IN
NOT EXISTS (SELECT …)Hash Anti Joinоставить строки без пары
NOT IN (SELECT …)hashed SubPlanпроверка на каждую строку с учётом NULL

Аналитические вопросы: DAU, retention, конверсия и проверка числа

29. Как посчитать DAU? Число различных пользователей, которые в этот день сделали целевое действие, — count(DISTINCT user_id) по дню. Сначала договариваются, что считается активностью: в учебной базе это открытие приложения. Средний DAU за 29 полных дней августа — 470,9. 30 августа выгрузка обрывается в 13:00, и DAU этого дня — 221 при 506 накануне. Неполный день в отчёт не берут.

30. Как посчитать retention D1 и зачем нужна зрелая когорта? D1 — доля пользователей, вернувшихся на следующий календарный день после регистрации. В знаменатель берут только тех, у кого этот день уже закончился: регистрации по 28 августа. Получается 2 444 из 4 575, 53,42%. Если оставить незрелых, у свежих когорт возврат ещё не мог случиться, и метрика смещается вниз — сильнее всего для D7 и D30.

31. Как посчитать конверсию и не ошибиться в знаменателе? Числитель должен быть подмножеством знаменателя, а знаменатель — названным. Рабочее пространство создали 2 750 из 4 613 пользователей, 59,61%. Отчёт создали 1 775: это 64,55% от создавших пространство и 38,48% от всех. Все три числа верны, но отвечают на разные вопросы, и в отчёте должно быть написано, какой шаг к какому.

32. Чем ARPU отличается от ARPPU и среднего чека? ARPU — выручка на всех пользователей: $30 639 на 4 613 человек дают $6,64. ARPPU — выручка на платящих: $33,60 на каждого из 912. Средний платёж — $24,49 на каждый из 1 251 платежа. Это три разных зерна, и путать их — самая частая ошибка в отчётах о деньгах.

33. Когда показывать медиану, а не среднее? Когда распределение скошено и несколько больших значений тянут среднее вверх. Средняя выручка на платящего — $33,60, медиана — $29: продления добавляют части людей второй и третий платёж. Хороший ответ — показать оба числа и объяснить разрыв, а не выбрать одно.

34. Как проверить результат A/B-теста в SQL? Сначала сплит: в контроле 1 527 человек, в варианте 1 541, это 49,77% и 50,23%, перекоса нет. Затем метрика по группам: 20,0% против 30,3%. И обязательно разрез по главному сегменту: на десктопе конверсия выросла с 20,0% до 39,8%, на мобильных осталась на месте — 20,1% и 19,9%. Общий прирост целиком сделан десктопом, и решение «раскатываем всем» он не поддерживает.

35. Как проверить число перед отправкой? Сверить контрольную сумму: части должны давать целое, регистрации по каналам — 4 613, выручка — $30 639. Назвать зерно результата и проверить, не изменилось ли оно после JOIN. Посмотреть на NULL в колонках условий и на границы периода, включая неполный последний день. И посчитать то же число вторым способом — например, подзапросом вместо JOIN.

A/B-тест чек-листа онбординга: конверсия по устройствам
УстройствоКонтрольЧек-листРазница
десктоп20,0% из 78939,8% из 807+19,8 п.п.
мобильные20,1% из 73819,9% из 734−0,2 п.п.
все20,0% из 1 52730,3% из 1 541+10,3 п.п.
Конверсия A/B-теста по устройствам
SELECT
  u.device,
  x.variant,
  count(*) AS users,
  round(100.0 * avg(CASE WHEN x.converted THEN 1 ELSE 0 END), 1) AS conv_pct
FROM experiment_exposures x
JOIN users u ON u.user_id = x.user_id
GROUP BY u.device, x.variant
ORDER BY u.device, x.variant;

-- desktop: checklist 807 / 39.8, control 789 / 20.0
-- mobile:  checklist 734 / 19.9, control 738 / 20.1

Чем PostgreSQL и DuckDB отвечают по-разному

На собеседовании обычно подразумевают PostgreSQL, а тренироваться удобно в DuckDB: ему не нужен сервер, и на нём работает песочница курса. Большая часть запросов переносится без изменений, но несколько различий всплывают именно в теоретических вопросах. Их стоит знать, чтобы не спорить с интервьюером о том, что «у меня работало».

Различия, проверенные на PostgreSQL 16 и DuckDB песочницы
ЧтоPostgreSQLDuckDB
псевдоним из SELECT в WHERE и HAVINGошибка column does not existразрешено
7 / 2 для целых33.5; целочисленное деление — //
NULL при ORDER BY … ASCв концев конце
NULL при ORDER BY … DESCв началев конце
QUALIFY для фильтра по окнунет, нужен внешний запросесть
median()нет, есть percentile_cont(0.5) WITHIN GROUP (…)есть
скалярный подзапрос вернул две строкиошибкаошибка

Как отвечать, если не знаете ответа

Честное «не помню точно» с рассуждением ценится выше уверенной ошибки. Скажите, что знаете наверняка, и как бы проверили остальное: «Не помню, создаёт ли внешний ключ индекс в PostgreSQL. Проверил бы в pg_indexes после создания таблицы. Если индекса нет, добавил бы его — по этой колонке часто соединяют таблицы».

Если вопрос про поведение на граничном случае, предложите маленький пример. «Что вернёт NOT IN, если в списке NULL?» можно разобрать вслух на двух значениях, не вспоминая правило. Интервьюер увидит, что вы умеете получить ответ, а не только его помнить.

И не уходите в синтаксис конкретной базы, если вопрос о смысле. На вопрос о разнице WHERE и HAVING не нужно рассказывать про QUALIFY. Короткий ответ, одно следствие и пример — достаточно. Если интервьюеру нужно больше, он спросит.

  • Сначала суть одним предложением, потом следствие для расчёта.
  • Пример — на числах или на двух-трёх строках, а не абстрактный.
  • Если не уверены — скажите, как проверите, и проверьте, если есть доступ к базе.
  • Разницу между базами называйте, только если она относится к вопросу.

Как готовиться дальше

Теоретические вопросы проверяют модель, задачи — умение ею пользоваться. Лучшая подготовка — решить задачи на реальной базе и для каждой ошибки найти вопрос из этого списка, который её объясняет. Все числа из ответов можно повторить в песочнице SQL-курса: база та же, запросы из статьи выполняются без изменений.

В самом курсе эти темы разобраны по главам с автоматической проверкой: соединения, подзапросы, окна, когорты и A/B-тест. Первые главы открыты бесплатно.

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