Собеседование по SQL- это почти всегда теория + практика. Сначала спрашивают про JOIN, индексы и оконные функции, потом дают задачу и смотрят как вы думаете. Аналитик или дата-инженер - не важно, SQL будет в 90% случаев.
Я прошел больше десятка таких собесов сам и проводил их для кандидатов. Собрал здесь реальные задачи, теоретические вопросы на которых чаще всего валятся и типичные ошибки.
В этой статье мы пройдёмся по всему от самой базы до не самых простых задач. Помните: лучше знать базу досконально, но не знать что-то сложное, чем плавать в простых вопросах, но уметь писать сложные рекурсивные CTE. Пробелы в базовых знаниях всегда бьют по вам больнее всего.
Что спрашивают и как устроено SQL-собеседование
Формат зависит от компании, но обычно SQL-часть выглядит так:
- Теоретический блок (10-15 минут). JOIN, WHERE vs HAVING, NULL, индексы. Проверяют не заучил ли ты синтаксис, а понимаешь ли как SQL работает под капотом.
- Практическая задача (20-40 минут). Дают схему таблиц и просят написать запрос. Иногда на бумаге, иногда в онлайн-редакторе. Смотрят не только на результат, но и на ход мыслей.
- Разбор решения. Могут попросить оптимизировать запрос или предложить альтернативный подход.
Теория: вопросы, которые задают всегда
Эти вопросы настолько частые, что не знать ответы - просто стыдно.
Типы JOIN
Самый популярный вопрос на SQL-собеседовании. Без преувеличений - его задают на каждом втором собесе.
| JOIN | Что возвращает | Когда использовать |
|---|---|---|
| INNER JOIN | Только совпавшие строки из обеих таблиц | Нужны только связанные данные |
| LEFT JOIN | Все из левой + совпавшие из правой (NULL если нет) | Нужны все записи из основной таблицы |
| RIGHT JOIN | Все из правой + совпавшие из левой | Редко, обычно можно переписать через LEFT JOIN |
| FULL JOIN | Все из обеих таблиц | Сравнение двух наборов данных |
| CROSS JOIN | Каждая строка с каждой (декартово произведение) | Генерация комбинаций, календарных сеток |
Частая ловушка: "Чем INNER JOIN отличается от WHERE при соединении?" Для INNER - функционально одинаково. Но для OUTER JOIN - принципиальная разница. Условие в ON фильтрует при соединении, условие в WHERE - после. На LEFT JOIN это меняет результат кардинально.
Еще пример с реальных собесов: "Как JOIN работает с NULL?". NULL не учитывается при соединении - потому что NULL = NULL дает не TRUE, а NULL. Если нужно соединить по столбцу с пропусками - оборачивайте в COALESCE.
WHERE vs HAVING
WHERE фильтрует строки до группировки, HAVING - после. Звучит элементарно, но на практике путают постоянно.
-- WHERE: фильтруем строки ДО группировки
SELECT department, COUNT(*) as cnt
FROM employees
WHERE salary > 50000
GROUP BY department;
-- HAVING: фильтруем группы ПОСЛЕ группировки
SELECT department, COUNT(*) as cnt
FROM employees
GROUP BY department
HAVING COUNT(*) > 5;
В HAVING можно использовать агрегатные функции, в WHERE - нельзя. Если интервьюер спросит "можно ли использовать алиас в HAVING?" - ответ зависит от СУБД. В MySQL можно, в PostgreSQL - нет.
NULL и его подводные камни
NULL - это не значение, а отсутствие значения. Отсюда куча неочевидного поведения:
NULL = NULLвозвращает не TRUE, а NULLNULL <> 1тоже возвращает NULL, а не TRUECOUNT(*)считает строки с NULL,COUNT(column)- нетSUM()игнорирует NULL, но если все значения NULL - вернет NULL, а не 0- При сортировке NULL может оказаться в начале или конце - зависит от СУБД
Проверяется через IS NULL / IS NOT NULL, не через = NULL.
Классическая задачка-ловушка: "Есть таблица с id: 1, 2, 3, 4, 5, NULL, NULL. Что вернут эти запросы?"
COUNT(*) = 7 -- считает строки
COUNT(id) = 5 -- считает не-NULL значения
COUNT(1) = 7 -- считает строки (1 - константа)
SUM(id) = 15 -- игнорирует NULL
COUNT(NULL) = 0 -- NULL не считается
Если не знаете разницу между COUNT(*) и COUNT(column) - это сразу минус.
Индексы
"Что такое индекс и зачем он нужен" - базовый уровень. На Middle+ копнут глубже:
- B-tree - стандартный индекс для операций сравнения (=, <, >, BETWEEN, LIKE 'abc%'). Подходит для большинства задач.
- Hash - только для точного сравнения (=), быстрее B-tree для этой операции.
- BRIN - легковесный индекс для больших таблиц с естественной сортировкой (например, по дате). Занимает в разы меньше места чем B-tree, но работает только когда данные физически упорядочены на диске. Идеален для логов, событий, временных рядов.
- GIN/GiST - для полнотекстового поиска, JSONB, массивов (PostgreSQL).
- Составной индекс работает по принципу "слева направо" - индекс по (a, b, c) поможет в запросах по a, по a+b, по a+b+c, но не поможет по b или c отдельно.
- Индексы ускоряют чтение, но замедляют запись - каждый INSERT/UPDATE обновляет индекс.
- В OLTP индексы критичны для быстрого поиска, но тормозят запись. В OLAP (аналитика) они менее полезны - там партиционирование и колоночное хранение решают задачу лучше.
Вопрос с реальных собесов: "Может ли индекс навредить?" Да. Применили функцию к столбцу (WHERE YEAR(date) = 2025) - индекс по date не сработает, СУБД не может заглянуть "внутрь" функции. То же самое с CAST - неявное приведение типов убивает индекс. Я сам на это наступал когда типы ключей в JOIN не совпадали - запрос тормозил на ровном месте.
Порядок выполнения SQL-запроса
Многие пишут SQL годами и не знают в каком порядке движок выполняет запрос. А это объясняет кучу "магических" ошибок:
FROM -> JOIN -> WHERE -> GROUP BY -> HAVING -> SELECT -> DISTINCT -> ORDER BY -> LIMIT
SELECT выполняется почти последним. Поэтому алиас из SELECT недоступен в WHERE или GROUP BY (в стандартном SQL). Когда это понимаешь - сразу отпадает половина вопросов "почему не работает".
Что еще любят спрашивать
Темы, которые всплывают реже, но регулярно:
- COALESCE - возвращает первый не-NULL аргумент. Кажется простым, но на практике забывают использовать при делении (
value / COALESCE(divisor, 1)) и при конкатенации строк. - Типы SQL-команд - DDL (CREATE, DROP, ALTER), DML (INSERT, UPDATE, DELETE), DCL (GRANT, REVOKE), TCL (COMMIT, ROLLBACK). Спрашивают нечасто, но если спросят и не знаете - неловко.
- INTERSECT и EXCEPT - находят общие и различающиеся строки между двумя запросами. Мало кто помнит, а на собесе иногда выручают.
- Деление целых чисел - домножайте числитель на 1.0, иначе получите 0 вместо дробного результата. Простейшая ловушка, а тормозят на ней даже опытные люди.
Практические задачи
Задачи идут от простых к сложным. Начинаем с базовых - фильтрация, группировка, JOIN. Дальше оконные функции, CTE и рекурсия. Последние - про оптимизацию и архитектурные решения.
Задача 1. Найти дубликаты
Таблица
users(id, email, name). Найдите email-адреса, которые встречаются более одного раза.
SELECT email, COUNT(*) as cnt
FROM users
GROUP BY email
HAVING COUNT(*) > 1;
Если попросят вывести все строки с дублирующимися email:
SELECT *
FROM users
WHERE email IN (
SELECT email
FROM users
GROUP BY email
HAVING COUNT(*) > 1
);
Задача 2. Второй по величине элемент
Найдите вторую по величине зарплату из таблицы
employees.
Наивный подход:
SELECT MAX(salary)
FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);
Через OFFSET:
SELECT DISTINCT salary
FROM employees
ORDER BY salary DESC
LIMIT 1 OFFSET 1;
Лучший ответ - через DENSE_RANK (если спросят "а если нужен N-й элемент?"):
SELECT salary
FROM (
SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) as rnk
FROM employees
) t
WHERE rnk = 2;
Задача 3. Записи без связей
Таблицы
employees(id, name) иorders(id, employee_id, amount). Найдите сотрудников без заказов.
Три способа - интервьюер ждет минимум два:
-- LEFT JOIN + IS NULL (самый частый)
SELECT e.*
FROM employees e
LEFT JOIN orders o ON e.id = o.employee_id
WHERE o.id IS NULL;
-- NOT EXISTS (часто быстрее на больших таблицах)
SELECT *
FROM employees e
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.employee_id = e.id
);
-- NOT IN (осторожно с NULL!)
SELECT *
FROM employees
WHERE id NOT IN (
SELECT employee_id FROM orders WHERE employee_id IS NOT NULL
);
Про NOT IN - отдельная боль. Если подзапрос вернет хоть один NULL - весь NOT IN вернет пустой результат. Я видел как на этом сыпались кандидаты с трехлетним опытом. Используйте NOT EXISTS - оно надежнее.
Задача 4. Накопительный итог
Таблица
sales(date, amount). Посчитайте накопительный итог продаж по дням.
SELECT
date,
amount,
SUM(amount) OVER (ORDER BY date) as running_total
FROM sales
ORDER BY date;
Могут усложнить: "А накопительный итог с начала каждого месяца?"
SELECT
date,
amount,
SUM(amount) OVER (
PARTITION BY DATE_TRUNC('month', date)
ORDER BY date
) as monthly_running_total
FROM sales;
Кстати, на собесах иногда просят сделать скользящее среднее за 7 дней. Тут пригодится RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW в оконной функции.
Задача 5. Топ-N внутри групп
Таблица
employees(id, name, department, salary). Найдите топ-3 по зарплате в каждом отделе.
WITH ranked AS (
SELECT
*,
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) as rn
FROM employees
)
SELECT *
FROM ranked
WHERE rn <= 3;
Здесь обязательно спросят: "Чем ROW_NUMBER отличается от RANK и DENSE_RANK?" ROW_NUMBER - уникальные номера (1,2,3). RANK пропускает позиции при одинаковых значениях (1,2,2,4). DENSE_RANK не пропускает (1,2,2,3). Какой выбрать зависит от задачи - уточняйте у интервьюера.
Задача 6. Пользователи с активностью N дней подряд
Таблица
logins(user_id, login_date). Найдите тех, кто заходил 3 дня подряд.
Прямолинейный подход через self-join:
SELECT DISTINCT a.user_id
FROM logins a
JOIN logins b ON a.user_id = b.user_id AND b.login_date = a.login_date + INTERVAL '1 day'
JOIN logins c ON a.user_id = c.user_id AND c.login_date = a.login_date + INTERVAL '2 days';
Продвинутый вариант через gaps and islands:
WITH numbered AS (
SELECT
user_id,
login_date,
login_date - ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) * INTERVAL '1 day' as grp
FROM logins
)
SELECT user_id
FROM numbered
GROUP BY user_id, grp
HAVING COUNT(*) >= 3;
Эта задачка - классика. Встречается на собесах в Яндекс и Тинькофф, только в разных формулировках: "поездки два дня подряд", "покупки три дня подряд" - суть одна и та же.
Задача 7. Сравнение с предыдущим значением
Таблица
metrics(date, value). Найдите дни, когда показатель вырос более чем на 20%.
Тут главная ловушка - оконные функции нельзя фильтровать в WHERE напрямую. Нужен подзапрос или CTE:
WITH with_prev AS (
SELECT
date,
value,
LAG(value) OVER (ORDER BY date) as prev_value
FROM metrics
)
SELECT *
FROM with_prev
WHERE prev_value > 0
AND (value - prev_value) * 1.0 / prev_value > 0.2;
Обратите внимание на * 1.0 - без этого при целочисленном делении получите 0. Мелочь, а на собесе потеряете 5 минут на отладку.
Задача 8. Рекурсивный CTE
Таблица
employees(id, name, manager_id). Постройте иерархию от CEO до каждого сотрудника.
WITH RECURSIVE hierarchy AS (
SELECT id, name, manager_id, 1 as level, name as path
FROM employees
WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.name, e.manager_id, h.level + 1,
h.path || ' > ' || e.name
FROM employees e
JOIN hierarchy h ON e.manager_id = h.id
)
SELECT * FROM hierarchy ORDER BY path;
"Как защититься от бесконечной рекурсии?" - MAXRECURSION в MS SQL, ограничение по level (WHERE h.level < 10), или PostgreSQL ставит лимит по умолчанию.
Задача 9. Оптимизация медленного запроса
Запрос работает 30 секунд на 10 млн строк. Как ускорить?
SELECT *
FROM orders
WHERE YEAR(created_at) = 2025
AND status = 'completed'
ORDER BY amount DESC
LIMIT 10;
Что не так:
YEAR(created_at)- функция убивает индекс. Переписать:created_at >= '2025-01-01' AND created_at < '2026-01-01'SELECT *- тянет все столбцы, covering index не сработает- Нет подходящего составного индекса
После оптимизации:
SELECT id, created_at, amount
FROM orders
WHERE created_at >= '2025-01-01'
AND created_at < '2026-01-01'
AND status = 'completed'
ORDER BY amount DESC
LIMIT 10;
Плюс индекс: CREATE INDEX idx_orders_opt ON orders(status, created_at, amount);
Если интервьюер попросит объяснить план - говорите про EXPLAIN ANALYZE. Seq Scan на большой таблице - первый красный флаг. Nested Loop на миллионах строк - второй. На реальных собесах часто просят прочитать план и найти узкое место.
Задача 10. MERGE / UPSERT
Staging-таблица с новыми данными, целевая таблица. Обновить существующие, вставить новые.
-- PostgreSQL
INSERT INTO target (id, name, value, updated_at)
SELECT id, name, value, NOW()
FROM staging
ON CONFLICT (id)
DO UPDATE SET
name = EXCLUDED.name,
value = EXCLUDED.value,
updated_at = NOW();
-- Стандартный SQL (Snowflake, BigQuery, Delta Lake)
MERGE INTO target t
USING staging s ON t.id = s.id
WHEN MATCHED THEN
UPDATE SET t.name = s.name, t.value = s.value
WHEN NOT MATCHED THEN
INSERT (id, name, value) VALUES (s.id, s.name, s.value);
Для дата-инженеров это хлеб насущный. ETL без MERGE не обходится, и если вы не знаете разницу между MERGE, OVERWRITE и UPSERT - к DE-собесу вы не готовы.
SQL для дата-инженеров: что спросят дополнительно
Если идете на позицию дата-инженера, помимо стандартных задач копнут глубже. Вот темы, которые мне задавали на реальных собесах:
- Партиционирование: Чем RANGE отличается от LIST и HASH? Когда что использовать? Как partition pruning ускоряет запросы? На одном собесе меня полчаса гоняли по партициям в Greenplum - спрашивали про стратегии распределения данных и дистрибуцию по столбцам
- Материализованные представления: Чем отличаются от VIEW? Когда обновлять? На практике я использовал их для ускорения тяжелых агрегаций - пересчет раз в час вместо каждого запроса
- EXPLAIN / EXPLAIN ANALYZE: Попросят прочитать план и найти узкое место. Seq Scan на большой таблице, Nested Loop на миллионах строк, file cuts (сброс на диск из-за нехватки памяти) - все это надо уметь видеть
- Транзакции и уровни изоляции: READ COMMITTED vs REPEATABLE READ vs SERIALIZABLE. Dirty read, phantom read - звучит как теория, но спрашивают регулярно
- Массовая загрузка: COPY vs INSERT. Отключение индексов перед bulk insert. Как добиться идемпотентности при повторной загрузке
- Различия диалектов: Если в вакансии ClickHouse или Greenplum - будьте готовы. Вот самые частые вопросы:
| Фича | PostgreSQL | ClickHouse |
|---|---|---|
| UPDATE/DELETE | Стандартные | Только ALTER TABLE ... DELETE WHERE |
| BOOLEAN | Полноценный тип | Эмулируется через UInt8 |
| LIMIT с OFFSET | LIMIT 10 OFFSET 5 | LIMIT 5, 10 (сначала offset) |
| Округление даты | date_trunc('month', ts) | toStartOfMonth(ts) |
| GROUP BY | GROUP BY 1 (по позиции) | GROUP BY name (только по имени) |
Не нужно знать все наизусть. Но если в вакансии указана конкретная СУБД - базовые отличия знать обязательно.
Частые ошибки на SQL-собеседованиях
Одни и те же грабли, раз за разом:
- Забывают про NULL в NOT IN. Подзапрос вернул NULL - весь NOT IN возвращает пустоту. Используйте NOT EXISTS.
- Путают WHERE и HAVING. Пишут агрегатную функцию в WHERE, ловят ошибку, теряют время и нервы.
- Не знают порядок выполнения. Пытаются использовать алиас из SELECT в WHERE - не работает и непонятно почему.
- Игнорируют дубликаты при JOIN. Два LEFT JOIN подряд к таблицам "один ко многим" - данные размножаются.
- Не думают о производительности. Коррелированный подзапрос в SELECT на миллионе строк - это миллион подзапросов. Переписывайте на JOIN или оконку.
- Молчат. Думают молча 10 минут. Интервьюер не видит ход мыслей и не может подтолкнуть в нужную сторону. Проговаривайте рассуждения вслух - это реально часть оценки.
- Зубрят вместо того чтобы думать. 100 задач наизусть не помогут, если на собесе дадут 101-ю. Лучше разобрать 30 задач до полного понимания паттерна.
Где тренироваться
Теорию прочитали - нужна практика. Вот что реально помогает:
| Ресурс | Что дает | Уровень |
|---|---|---|
| LeetCode | Задачи из реальных собеседований FAANG | Junior - Senior |
| HackerRank | Пошаговые задачи от простого к сложному | Junior - Middle |
| sql-ex.ru | Классика для русскоязычных, жесткие задачи | Middle - Senior |
| StrataScratch | Задачи из реальных собесов + SQL-песочница | Middle - Senior |
Не гонитесь за количеством. 30-50 задач, но каждую разберите до конца. Поймите паттерн, а не запомните решение. Оконные функции, GROUP BY + HAVING, self-join, CTE - это покрывает 80% того, что спрашивают.
Шпаргалка: что повторить за день до собеса
Если собеседование завтра - вот минимум на освежить:
Синтаксис, который нужно писать с закрытыми глазами:
- Типы JOIN и чем они отличаются
- WHERE vs HAVING
- Оконные функции: ROW_NUMBER, RANK, DENSE_RANK, LAG, LEAD, SUM OVER
- CTE (WITH)
- синтаксис и зачем нужен - GROUP BY + агрегации (COUNT, SUM, AVG, MIN, MAX)
- Порядок выполнения запроса (FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BY)
Ловушки, на которых сыпятся:
- NULL в сравнениях и NOT IN
- Разница между DISTINCT и GROUP BY
- UNION vs UNION ALL (второй быстрее, не убирает дубли)
- Коррелированный подзапрос vs JOIN - почему второй быстрее
- Деление целых чисел без * 1.0
И самое важное - софт скиллы:
- Уточняйте условие задачи перед тем как писать
- Проговаривайте решение вслух
- Предлагайте несколько подходов
- Не паникуйте если не знаете ответ - покажите как вы думаете
Удачи. И помните: хороший интервьюер оценивает не знание синтаксиса, а умение мыслить. SQL - это инструмент, а не цель.