SQL на собеседовании в 2026-м редко проваливается из-за «не назвал все виды JOIN из учебника». Чаще ломается другое: человек пишет SELECT * и надеется, что интервьюер догадается. Нанимающий аналитика, backend или QA хочет увидеть, что вы умеете собрать нужные строки, не удвоить их джойном и объяснить, почему запрос может тормозить. Это проверка мышления на данных, не олимпиада по стандарту SQL:2016.
Ниже — темы, которые реально ставят, полные скетчи запросов в цитатах и как практиковаться на живой схеме, а не на мемах про DELETE без WHERE. Карта обучения — SQL roadmap. Общий каркас интервью — вопросы на собеседовании. Роли, где SQL в явном виде: вакансии с SQL.
Коротко:
- INNER/LEFT, условие в ON vs WHERE, размножение строк — база, без которой окна рано.
- GROUP BY и HAVING — разные этажи фильтра. Окна — «оставить строки, добавить ранг/сумму».
- Индекс и план: гипотеза, EXPLAIN, селективность. Не «надо кэш» первым словом.
- Пишите читаемый запрос: явные JOIN, понятные алиасы, без SELECT * в финале.
- Готовьте 3–4 запроса по своей предметке: список, агрегат, «последняя запись», дедуп.
Какой уровень на какую роль
QA: найти заказы пользователя, посчитать, проверить, что UI совпал с таблицей. Аналитик: воронка, повторные покупки, окна по дате. Backend: тот же JOIN плюс транзакция и «почему seq scan». Data engineer — ещё инкремент и качество. Не готовьте аналитические окна на QA-слот, если в вакансии «написать SELECT». Не приходите на аналитика только с SELECT по одной таблице.
Диалект: Postgres чаще на CIS product, иногда MySQL/ClickHouse. Синтаксис окон почти везде похож. Если в вакансии ClickHouse — готовьте движок и ограничения JOIN, не притворяйтесь, что это тот же Postgres.
JOIN: задача, которую ставят всегда
Формулировка: «пользователи и заказы. Вывести всех пользователей и сумму оплаченных заказов за 90 дней. У кого заказов нет — ноль, не пропасть из списка».
SELECT u.id, u.email, COALESCE(SUM(o.amount), 0) AS paid_90d FROM users u LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid' AND o.created_at >= CURRENT_DATE - INTERVAL '90 days' GROUP BY u.id, u.email;
Почему LEFT: INNER выкинет пользователей без оплат. Почему фильтр дат и статуса в ON, не в WHERE: условие на o в WHERE после LEFT превращает его в INNER — строки с NULL-заказом отвалятся. COALESCE, чтобы в отчёте был 0, не NULL, если так просит заказчик.
Риск размножения: если у заказа несколько строк позиций и я по ошибке джойню items без агрегата — сумма раздуется. Тогда либо сначала агрегировать заказы в подзапросе, либо считать SUM по уникальному order_id аккуратно. На интервью я это проговариваю, не жду, пока найдут за меня.
Второй классический вопрос: «разница JOIN в ON и фильтр в WHERE». Ответ выше. Третий: «нарисуйте, сколько строк получится» на маленькой таблице — потренируйте на бумаге.
Группировка, HAVING, подзапрос
«Клиенты, у которых больше трёх оплаченных заказов за месяц».
SELECT user_id, COUNT(*) AS paid_cnt FROM orders WHERE status = 'paid' AND created_at >= DATE_TRUNC('month', CURRENT_DATE) GROUP BY user_id HAVING COUNT(*) > 3;
WHERE режет строки до группировки. HAVING — группы после. Писать HAVING на сырую дату вместо WHERE — ошибка: движок сначала тащит лишнее. Подзапрос уместен, если дальше джойнить к users за email: сначала ids, потом справочник, чтобы не тащить email в GROUP BY зря, если диалект требует все неагрегированные поля.
Окна: «последний статус» и дедуп
Формулировка: «по каждому заказу последняя строка из истории статусов».
WITH ranked AS ( SELECT s.order_id, s.status, s.changed_at, ROW_NUMBER() OVER (PARTITION BY s.order_id ORDER BY s.changed_at DESC, s.id DESC) AS rn FROM order_status_history s ) SELECT order_id, status, changed_at FROM ranked WHERE rn = 1;
ROW_NUMBER, не RANK, если коллизия по времени: RANK даст две «последние». Второй ключ id — стабильный тай-брейк. Альтернатива DISTINCT ON (order_id) в Postgres: SELECT DISTINCT ON (order_id) order_id, status, changed_at FROM order_status_history ORDER BY order_id, changed_at DESC, id DESC; — короче, менее переносимо. На интервью называю оба и спрашиваю, какой диалект у них.
SUM() OVER (PARTITION BY user_id ORDER BY created_at) — накопительно. Не путать с GROUP BY: строки не схлопываются. Если попросили «топ-3 заказа на пользователя» — тот же ROW_NUMBER, затем WHERE rn <= 3.
Это не трюк взлома и не «достать чужие данные». Это отчётный паттерн, который ждут аналитики и backend.
Индекс и «почему тормозит»
Вопрос: «список заказов пользователя по дате, таблица большая. Что смотрите?»
Сначала тот же запрос в EXPLAIN ANALYZE, не оптимизация вслепую. Смотрю seq scan vs index scan, сколько строк читаем vs сколько отдаём. Фильтр user_id + сортировка created_at — гипотеза: составной индекс (user_id, created_at). Отдельный индекс только по дате часто бесполезен для «моего пользователя».
SELECT o.id, o.created_at, o.status FROM orders o WHERE o.user_id = 1042 ORDER BY o.created_at DESC LIMIT 50;
Не тяну JOIN на историю статусов, если на списке три поля шапки. Кэш — после того, как запрос дешёвый. Иначе кэшируем медленный мусор. Честно: без селективности не обещаю «композитный всегда». Могу предложить гипотезу и проверку на копии, не создавать индекс на каждый столбец — запись тоже стоит.
Не предлагайте «выключить seq scan глобально» и не рассказывайте, как обходить права. План и индекс — достаточно.
NULL, дубли, аккуратность
COUNT(*) считает строки, COUNT(col) пропускает NULL. NOT IN с NULL в подзапросе — ловушка, лучше NOT EXISTS. Равенство NULL не через =, а IS NULL. На интервью это отделяют взрослых от тех, кто учил только INNER JOIN по id.
Пользователи без заказа через NOT EXISTS:
SELECT u.id FROM users u WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.status = 'paid' );
Эквивалент LEFT JOIN ... WHERE o.id IS NULL. NOT IN (SELECT user_id FROM orders) развалится, если user_id в подзапросе бывает NULL. Это я и скажу, если предложат NOT IN.