Вопросы по SQL на собеседовании: 35 вопросов с короткими ответами
Теоретические вопросы по SQL для аналитика с ответами в два-пять предложений: ключи, NULL, JOIN, GROUP BY, окна, индексы, CTE, DAU и retention — с примерами на данных.
Содержание статьи
Перед 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(), но это решение подходит, только если значение внутри группы действительно одно.
| Шаг | Что происходит | Что из этого следует |
|---|---|---|
| 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 | 4 613 | одна строка — пользователь |
| users INNER JOIN payments | 1 251 | одна строка — платёж, неплатившие выпали |
| users LEFT JOIN payments | 4 952 | 1 251 платёж + 3 701 пользователь без платежей |
| LEFT JOIN, условие plan = team в WHERE | 142 | LEFT JOIN превратился в INNER |
| LEFT JOIN, условие plan = team в ON | 4 653 | все пользователи сохранились, у 4 511 справа NULL |
-- список «активных», в котором есть 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 — прирост для июня не определён, а не равен нулю.
| День | Регистраций | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|---|
| 16 июля | 100 | 1 | 1 | 1 |
| 15 июля | 99 | 2 | 2 | 2 |
| 28 августа | 88 | 3 | 3 | 3 |
| 26 августа | 87 | 4 или 5 | 4 | 4 |
| 27 августа | 87 | 5 или 4 | 4 | 4 |
| 25 августа | 86 | 6 | 6 | 5 |
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 до и после. Индекс — последний шаг, а не первый: он ускоряет чтение, но замедляет запись и занимает место.
| Условие | План | Что это значит |
|---|---|---|
| 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.
| Устройство | Контроль | Чек-лист | Разница |
|---|---|---|---|
| десктоп | 20,0% из 789 | 39,8% из 807 | +19,8 п.п. |
| мобильные | 20,1% из 738 | 19,9% из 734 | −0,2 п.п. |
| все | 20,0% из 1 527 | 30,3% из 1 541 | +10,3 п.п. |
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 | DuckDB |
|---|---|---|
| псевдоним из SELECT в WHERE и HAVING | ошибка column does not exist | разрешено |
7 / 2 для целых | 3 | 3.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-тест. Первые главы открыты бесплатно.
Материалы по теме
SQL-задачи с решениями: 30 задач для аналитика на данных продукта
Тридцать SQL-задач с ответами и решениями на одной учебной базе: WHERE, GROUP BY, JOIN, даты, когорты и оконные функции. Каждый ответ можно проверить в песочнице.
SQL-запросы: 40 примеров для аналитика с результатами
Сорок SQL-запросов на одной учебной базе: SELECT и WHERE, COUNT и GROUP BY, JOIN, даты, оконные функции, DAU, retention и A/B-тест. У каждого запроса показан результат.
SQL на собеседовании аналитика: 20 задач от SELECT до когорт
Практический разбор SQL-задач с собеседований: JOIN, GROUP BY, оконные функции, даты, retention, воронки и проверки, которые отличают рабочий запрос от случайного ответа.