SQL на собеседовании: задачи, вопросы и подготовка в 2026

SQL на собеседовании: задачи, вопросы и подготовка в 2026

Собеседование по SQL- это почти всегда теория + практика. Сначала спрашивают про JOIN, индексы и оконные функции, потом дают задачу и смотрят как вы думаете. Аналитик или дата-инженер - не важно, SQL будет в 90% случаев.

Я прошел больше десятка таких собесов сам и проводил их для кандидатов. Собрал здесь реальные задачи, теоретические вопросы на которых чаще всего валятся и типичные ошибки.

В этой статье мы пройдёмся по всему от самой базы до не самых простых задач. Помните: лучше знать базу досконально, но не знать что-то сложное, чем плавать в простых вопросах, но уметь писать сложные рекурсивные CTE. Пробелы в базовых знаниях всегда бьют по вам больнее всего.

Что спрашивают и как устроено SQL-собеседование

Формат зависит от компании, но обычно SQL-часть выглядит так:

  1. Теоретический блок (10-15 минут). JOIN, WHERE vs HAVING, NULL, индексы. Проверяют не заучил ли ты синтаксис, а понимаешь ли как SQL работает под капотом.
  2. Практическая задача (20-40 минут). Дают схему таблиц и просят написать запрос. Иногда на бумаге, иногда в онлайн-редакторе. Смотрят не только на результат, но и на ход мыслей.
  3. Разбор решения. Могут попросить оптимизировать запрос или предложить альтернативный подход.

Теория: вопросы, которые задают всегда

Эти вопросы настолько частые, что не знать ответы - просто стыдно.

Типы 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, а NULL
  • NULL <> 1 тоже возвращает NULL, а не TRUE
  • COUNT(*) считает строки с 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;

Что не так: 

  1. YEAR(created_at) - функция убивает индекс. Переписать: created_at >= '2025-01-01' AND created_at < '2026-01-01' 
  2. SELECT * - тянет все столбцы, covering index не сработает 
  3. Нет подходящего составного индекса

После оптимизации:

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 - будьте готовы. Вот самые частые вопросы:
ФичаPostgreSQLClickHouse
UPDATE/DELETEСтандартныеТолько ALTER TABLE ... DELETE WHERE
BOOLEANПолноценный типЭмулируется через UInt8
LIMIT с OFFSETLIMIT 10 OFFSET 5LIMIT 5, 10 (сначала offset)
Округление датыdate_trunc('month', ts)toStartOfMonth(ts)
GROUP BYGROUP BY 1 (по позиции)GROUP BY name (только по имени)

Не нужно знать все наизусть. Но если в вакансии указана конкретная СУБД - базовые отличия знать обязательно.

Частые ошибки на SQL-собеседованиях

Одни и те же грабли, раз за разом:

  1. Забывают про NULL в NOT IN. Подзапрос вернул NULL - весь NOT IN возвращает пустоту. Используйте NOT EXISTS.
  2. Путают WHERE и HAVING. Пишут агрегатную функцию в WHERE, ловят ошибку, теряют время и нервы.
  3. Не знают порядок выполнения. Пытаются использовать алиас из SELECT в WHERE - не работает и непонятно почему.
  4. Игнорируют дубликаты при JOIN. Два LEFT JOIN подряд к таблицам "один ко многим" - данные размножаются.
  5. Не думают о производительности. Коррелированный подзапрос в SELECT на миллионе строк - это миллион подзапросов. Переписывайте на JOIN или оконку.
  6. Молчат. Думают молча 10 минут. Интервьюер не видит ход мыслей и не может подтолкнуть в нужную сторону. Проговаривайте рассуждения вслух - это реально часть оценки.
  7. Зубрят вместо того чтобы думать. 100 задач наизусть не помогут, если на собесе дадут 101-ю. Лучше разобрать 30 задач до полного понимания паттерна.

Где тренироваться

Теорию прочитали - нужна практика. Вот что реально помогает:

РесурсЧто даетУровень
LeetCodeЗадачи из реальных собеседований FAANGJunior - 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 - это инструмент, а не цель.