Вопросы по SQL на собеседовании: JOIN, GROUP BY, оконные функции, индексы и задачи с решениями
На собеседовании по SQL проверяют не знание синтаксиса наизусть, а понимание того, как база выполняет запрос: в каком порядке применяются FROM, WHERE, GROUP BY и SELECT, что происходит с NULL при соединениях и почему запрос вдруг теряет строки. Большинство задач сводится к пяти конструкциям: JOIN, GROUP BY с HAVING, подзапросы и CTE, оконные функции и EXISTS. На middle и выше к ним добавляются индексы, план запроса и уровни изоляции транзакций. Ниже разобраны все эти темы и семь типовых задач с решениями на PostgreSQL. Почти все решения без изменений работают и в MySQL 8.
Что спрашивают на разных уровнях
| Уровень | Типичные вопросы | Что хотят увидеть |
|---|---|---|
| Junior | SELECT с фильтрами, виды JOIN, GROUP BY, разница WHERE и HAVING, первичный и внешний ключ | Уверенное написание простых запросов без подсказок |
| Middle | Оконные функции, подзапросы и CTE, NULL-ловушки, индексы, EXPLAIN, транзакции | Корректные запросы на неудобных данных и понимание производительности |
| Senior | План запроса, составные и частичные индексы, уровни изоляции и аномалии, блокировки, денормализация | Решения с учётом нагрузки, объёма данных и компромиссов |
Для бэкенд-разработчика и аналитика SQL обычно идёт отдельным блоком технического интервью, для фронтенда встречается реже и на уровне основ. Общая структура такого интервью разобрана в статье про вопросы на техническом собеседовании. Для Java-разработчика SQL идёт рядом с JPA и транзакциями Spring, см. вопросы на собеседовании Java.
Учебная схема для всех примеров
Интервьюеры почти всегда берут похожую схему: сотрудники, отделы, пользователи, заказы. Все примеры ниже используют её:
CREATE TABLE departments (
id INT PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE employees (
id INT PRIMARY KEY,
name TEXT NOT NULL,
department_id INT REFERENCES departments (id),
manager_id INT REFERENCES employees (id),
salary NUMERIC(12, 2) NOT NULL
);
CREATE TABLE users (
id INT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL
);
CREATE TABLE orders (
id INT PRIMARY KEY,
user_id INT REFERENCES users (id),
amount NUMERIC(12, 2) NOT NULL,
created_at TIMESTAMP NOT NULL
);
Порядок выполнения запроса
Это самый полезный факт для собеседования: запрос пишется в одном порядке, а логически выполняется в другом.
FROMиJOIN— собрать строки из таблиц.WHERE— отфильтровать отдельные строки.GROUP BY— сгруппировать.HAVING— отфильтровать группы.- Оконные функции.
SELECT— вычислить выражения и псевдонимы.DISTINCT— убрать дубликаты.ORDER BY— отсортировать.LIMIT/OFFSET— обрезать результат.
Из этого порядка следуют ответы на несколько частых вопросов сразу:
- Почему нельзя использовать псевдоним из SELECT в WHERE? WHERE выполняется раньше, чем SELECT вычисляет псевдоним. В ORDER BY псевдоним доступен, потому что сортировка идёт после SELECT.
- Почему агрегат нельзя писать в WHERE? На этапе WHERE групп ещё нет. Условие на агрегат пишется в HAVING.
- Почему оконную функцию нельзя отфильтровать в WHERE? Она вычисляется позже. Нужно обернуть запрос в подзапрос или CTE и фильтровать снаружи.
JOIN: виды и подводные камни
| Вид | Что возвращает |
|---|---|
INNER JOIN | Только строки, для которых нашлась пара в обеих таблицах |
LEFT JOIN | Все строки левой таблицы; где пары нет, колонки правой таблицы равны NULL |
RIGHT JOIN | Зеркально LEFT JOIN, на практике почти всегда переписывают через LEFT |
FULL JOIN | Все строки обеих таблиц, недостающие стороны заполнены NULL |
CROSS JOIN | Декартово произведение: каждая строка с каждой |
| Self join | Таблица соединяется сама с собой, например сотрудник и его руководитель |
Ловушка: условие на правую таблицу в WHERE
Задача: посчитать заказы каждого пользователя за 2026 год, включая тех, у кого заказов нет.
-- Неправильно: пользователи без заказов пропадут
SELECT u.name, COUNT(o.id) AS orders_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.created_at >= '2026-01-01'
GROUP BY u.id, u.name;
-- Правильно: условие переносится в ON
SELECT u.name, COUNT(o.id) AS orders_count
FROM users u
LEFT JOIN orders o
ON o.user_id = u.id
AND o.created_at >= '2026-01-01'
GROUP BY u.id, u.name;
В первом варианте у пользователя без заказов o.created_at равно NULL, сравнение с датой даёт не TRUE, и WHERE отбрасывает строку. LEFT JOIN фактически превращается в INNER JOIN. Это один из самых частых вопросов «найдите ошибку».
Ловушка: COUNT(*) против COUNT(колонка)
COUNT(*) считает строки, COUNT(o.id) считает только непустые значения. После LEFT JOIN пользователь без заказов даёт одну строку с NULL, поэтому COUNT(*) вернёт 1, а COUNT(o.id) вернёт 0. Нужен второй вариант.
Ловушка: размножение строк
Если соединить пользователей одновременно с заказами и, например, с обращениями в поддержку, строки перемножатся: три заказа и два обращения дадут шесть строк, и суммы окажутся завышены. Решение — агрегировать каждую таблицу отдельно в подзапросе или CTE и только потом соединять.
NULL: три значения вместо двух
В SQL логика трёхзначная: TRUE, FALSE и UNKNOWN. Любое сравнение с NULL даёт UNKNOWN, а WHERE пропускает только TRUE.
WHERE manager_id = NULLне вернёт ничего. Правильно:WHERE manager_id IS NULL.NULL = NULL— это тоже UNKNOWN, а не TRUE.- Агрегаты
SUM,AVG,MIN,MAXпропускают NULL.AVGпо колонке с пропусками делит на число непустых значений, а не на число строк. COALESCE(salary, 0)подставляет значение по умолчанию.
Ловушка: NOT IN и NULL
-- Опасно: если в orders есть строка с user_id = NULL, запрос вернёт пустой результат
SELECT name FROM users
WHERE id NOT IN (SELECT user_id FROM orders);
-- Безопасно
SELECT u.name FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id
);
id NOT IN (1, 2, NULL) раскрывается в id <> 1 AND id <> 2 AND id <> NULL, а последнее всегда UNKNOWN. Если вы сами назовёте эту ловушку до того, как о ней спросят, это заметный плюс.
GROUP BY и HAVING
WHERE фильтрует строки до группировки, HAVING — группы после неё. Если условие можно написать в WHERE, лучше писать его там: в группировку попадёт меньше строк.
-- Отделы, где больше пяти сотрудников с зарплатой выше 100 000
SELECT department_id, COUNT(*) AS employees_count
FROM employees
WHERE salary > 100000 -- фильтр по строкам
GROUP BY department_id
HAVING COUNT(*) > 5; -- фильтр по группам
Правило для SELECT с GROUP BY: каждая колонка должна либо входить в GROUP BY, либо стоять внутри агрегата. MySQL в старых режимах это прощал, PostgreSQL выдаёт ошибку.
Оконные функции
Оконная функция считает значение по группе строк, но, в отличие от GROUP BY, не схлопывает их: каждая строка остаётся в результате. Синтаксис: функция() OVER (PARTITION BY ... ORDER BY ...).
Главный вопрос — разница между тремя функциями ранжирования. Пусть зарплаты в отделе 300, 200, 200, 100:
| salary | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|
| 300 | 1 | 1 | 1 |
| 200 | 2 | 2 | 2 |
| 200 | 3 | 2 | 2 |
| 100 | 4 | 4 | 3 |
ROW_NUMBERвсегда даёт уникальный номер, порядок среди равных не определён.RANKдаёт равным одинаковый ранг и оставляет пропуск после них.DENSE_RANKдаёт равным одинаковый ранг без пропусков.
Другие функции, которые встречаются в задачах:
LAG(col)иLEAD(col)— значение из предыдущей или следующей строки, для сравнения «день к дню».SUM(col) OVER (ORDER BY ...)— нарастающий итог.AVG(col) OVER (PARTITION BY ...)— среднее по группе рядом с каждой строкой, например чтобы сравнить зарплату сотрудника со средней по отделу.
Подробное объяснение с примерами есть в документации PostgreSQL по оконным функциям.
Семь типовых задач с решениями
1. Вторая по величине зарплата
SELECT MAX(salary) AS second_salary
FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);
Если второй зарплаты нет, MAX вернёт NULL, а не пустой результат, и это обычно и ожидают. Для N-й зарплаты удобнее оконная функция:
SELECT DISTINCT salary
FROM (
SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk
FROM employees
) ranked
WHERE rnk = 2;
Уточните у интервьюера, что считать второй зарплатой при одинаковых значениях: от ответа зависит выбор между DENSE_RANK и ROW_NUMBER.
2. Сотрудники, которые получают больше своего руководителя
SELECT e.name
FROM employees e
JOIN employees m ON m.id = e.manager_id
WHERE e.salary > m.salary;
Классический self join. Сотрудники без руководителя (manager_id IS NULL) отсеются INNER JOIN, и это правильно.
3. Найти и удалить дубликаты email
-- Найти
SELECT email, COUNT(*) AS cnt
FROM users
GROUP BY email
HAVING COUNT(*) > 1;
-- Удалить, оставив запись с минимальным id (синтаксис PostgreSQL)
DELETE FROM users u
USING users d
WHERE u.email = d.email
AND u.id > d.id;
Хорошо добавить, что после чистки стоит повесить на колонку уникальный индекс, иначе дубликаты появятся снова.
4. Топ-3 зарплаты в каждом отделе
WITH ranked AS (
SELECT d.name AS department,
e.name,
e.salary,
DENSE_RANK() OVER (
PARTITION BY e.department_id
ORDER BY e.salary DESC
) AS rnk
FROM employees e
JOIN departments d ON d.id = e.department_id
)
SELECT department, name, salary
FROM ranked
WHERE rnk <= 3
ORDER BY department, salary DESC;
Фильтр по rnk вынесен наружу, потому что оконная функция вычисляется после WHERE. С DENSE_RANK при равных зарплатах в выборку может попасть больше трёх человек, с ROW_NUMBER ровно три. Проговорите это вслух.
5. Пользователи без заказов
SELECT u.id, u.name
FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id
);
Равноценный вариант: LEFT JOIN orders o ON o.user_id = u.id WHERE o.id IS NULL. Вариант с NOT IN лучше не использовать из-за ловушки с NULL.
6. Выручка по дням и нарастающий итог
WITH daily AS (
SELECT created_at::date AS day, SUM(amount) AS revenue
FROM orders
GROUP BY created_at::date
)
SELECT day,
revenue,
SUM(revenue) OVER (ORDER BY day) AS running_total
FROM daily
ORDER BY day;
Сначала агрегируем по дню, потом считаем окно по уже сгруппированным строкам. Если считать окно по сырым заказам с ORDER BY created_at, у заказов с одинаковым временем итог окажется одинаковым: по умолчанию рамка окна RANGE включает все строки с тем же значением сортировки. Явная рамка ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW убирает этот эффект.
7. Изменение выручки к предыдущему дню
WITH daily AS (
SELECT created_at::date AS day, SUM(amount) AS revenue
FROM orders
GROUP BY created_at::date
)
SELECT day,
revenue,
revenue - LAG(revenue) OVER (ORDER BY day) AS diff
FROM daily
ORDER BY day;
Для первого дня LAG вернёт NULL, и разница тоже будет NULL. Спросите, нужно ли показывать дни без заказов: их в daily нет, и для них придётся генерировать календарь через generate_series.
Индексы
Индекс — отдельная структура, обычно B-дерево, которая позволяет находить строки без полного перебора таблицы. Что спрашивают:
- Зачем нужен индекс и чем он платит. Ускоряет поиск, сортировку и соединения, но замедляет INSERT, UPDATE и DELETE и занимает место. Индекс на каждую колонку — плохая идея.
- Какие колонки индексировать. Те, по которым часто фильтруют, соединяют и сортируют, с высокой селективностью. Индекс на колонке со значениями «да/нет» почти бесполезен.
- Составной индекс и порядок колонок. Индекс по
(user_id, created_at)помогает запросам с условием наuser_idи наuser_idвместе сcreated_at. Запросу только поcreated_atон обычно не помогает: работает правило левого префикса. - Когда индекс не используется. Функция над колонкой (
WHERE lower(email) = ...без индекса по выражению),LIKE '%текст'с ведущим процентом, несовпадение типов, маленькая таблица или условие, под которое попадает большая часть строк: тогда полный перебор дешевле. - Покрывающий индекс. Если индекс содержит все нужные запросу колонки, база может ответить только по нему, не обращаясь к таблице (index-only scan).
Как проверить, что происходит на самом деле: EXPLAIN показывает план, EXPLAIN ANALYZE выполняет запрос и показывает реальное время и число строк. Отличие Seq Scan от Index Scan в плане — минимум, который стоит уметь прочитать. Подробно в документации PostgreSQL по EXPLAIN.
Транзакции и уровни изоляции
Транзакция — группа операций, которая выполняется целиком или не выполняется вовсе. Свойства ACID: атомарность, согласованность, изолированность, долговечность.
Уровни изоляции по стандарту SQL и аномалии, которые они допускают:
| Уровень | Грязное чтение | Неповторяющееся чтение | Фантомное чтение |
|---|---|---|---|
| Read Uncommitted | Возможно | Возможно | Возможно |
| Read Committed | Нет | Возможно | Возможно |
| Repeatable Read | Нет | Нет | Возможно |
| Serializable | Нет | Нет | Нет |
- Грязное чтение — видим изменения чужой транзакции, которая ещё не зафиксирована и может откатиться.
- Неповторяющееся чтение — повторно читаем ту же строку и получаем другое значение, потому что её успели изменить.
- Фантом — повторяем запрос с условием и получаем новые строки, которые кто-то вставил.
Важные детали реализации, которые любят уточнять: в PostgreSQL уровень по умолчанию Read Committed, Read Uncommitted ведёт себя как Read Committed, а Repeatable Read не допускает и фантомов, потому что работает на снимке данных. В MySQL с InnoDB уровень по умолчанию Repeatable Read. Подробности в документации PostgreSQL по изоляции транзакций.
Короткие теоретические вопросы
- DELETE, TRUNCATE и DROP. DELETE удаляет строки по условию и может срабатывать триггерами; TRUNCATE быстро очищает всю таблицу; DROP удаляет саму таблицу вместе со структурой.
- UNION и UNION ALL. UNION убирает дубликаты и поэтому дороже, UNION ALL просто склеивает результаты. Если дубликатов быть не может, берите UNION ALL.
- Первичный ключ и уникальный индекс. Первичный ключ один на таблицу и не допускает NULL; уникальных ограничений может быть несколько.
- CTE и подзапрос. CTE (
WITH) читается лучше и может использоваться в запросе несколько раз; рекурсивный CTE нужен для обхода деревьев, например иерархии руководителей. С PostgreSQL 12 простые CTE по умолчанию встраиваются в запрос и не мешают оптимизатору. - Нормализация. 1НФ — атомарные значения без повторяющихся групп; 2НФ — нет зависимости от части составного ключа; 3НФ — нет зависимостей между неключевыми колонками. Денормализацию применяют осознанно, ради скорости чтения, и платят за неё сложностью обновлений.
- Проблема N+1. Код сначала выбирает список, а потом делает отдельный запрос на каждый элемент. Лечится JOIN-ом или одним запросом с
IN/ANY. Часто всплывает в разговоре про ORM.
Как решать SQL-задачу вслух
Подход тот же, что и на live coding: интервьюер оценивает ход мысли не меньше, чем итоговый запрос.
- Уточните схему и данные. Может ли колонка быть NULL, бывают ли дубликаты, что делать с равными значениями, нужны ли строки без пары.
- Назовите форму результата. «На выходе одна строка на отдел с тремя колонками».
- Стройте запрос по шагам. Сначала FROM и JOIN, потом фильтры, потом группировка. CTE помогает разбить задачу на понятные куски.
- Проверьте на краевых случаях. Пустой отдел, NULL в колонке, одинаковые зарплаты.
- Скажите о производительности. Какой индекс поможет этому запросу и что будет на ста миллионах строк. Если разговор уходит в масштаб, пригодится статья про system design.
Типичные ошибки
- Условие на правую таблицу LEFT JOIN в WHERE вместо ON.
= NULLвместоIS NULLиNOT INс подзапросом, который может вернуть NULL.COUNT(*)там, где нуженCOUNT(колонка).- Колонки в SELECT, которых нет ни в GROUP BY, ни в агрегате.
- Попытка фильтровать оконную функцию в WHERE.
- Соединение нескольких таблиц «один ко многим» с завышенными суммами.
SELECT *в ответе на задачу: интервьюер хочет видеть, что вы понимаете, какие колонки нужны.- Молчаливое допущение там, где стоило задать вопрос про дубликаты и NULL.
Чек-лист перед собеседованием
- Порядок выполнения запроса — наизусть.
- Виды JOIN и ловушка с условием в WHERE.
- NULL:
IS NULL,COALESCE,NOT INпротивNOT EXISTS. - WHERE против HAVING, правило колонок в GROUP BY.
ROW_NUMBER,RANK,DENSE_RANK,LAG, нарастающий итог.- Семь задач из этой статьи — написать самостоятельно, не подглядывая.
- Составной индекс, правило левого префикса, когда индекс не работает, чтение EXPLAIN.
- ACID, уровни изоляции и три аномалии.
Тренировать запросы удобно в любой локальной PostgreSQL или в онлайн-песочнице вроде DB Fiddle. Для задач на алгоритмы, которые часто идут в том же интервью, есть отдельная шпаргалка по алгоритмам и структурам данных.
Где здесь помогает Interview Assistant
На SQL-секции задачу обычно показывают в общем редакторе или зачитывают голосом вместе со схемой таблиц. Interview Assistant транскрибирует вопрос в реальном времени и распознаёт термины вроде DENSE_RANK, HAVING и Repeatable Read без искажений, а задачу со схемой на экране может разобрать по скриншоту: подсказать подход и краевые случаи, которые стоит проговорить. Первые 20 минут бесплатны — их хватит, чтобы прогнать пару задач из этой статьи и проверить, как ассистент слышит ваш звонок.
Частые вопросы
Какой диалект SQL учить к собеседованию?
Тот, что указан в вакансии; если он не указан, PostgreSQL. Базовый синтаксис SELECT, JOIN, GROUP BY и оконных функций у PostgreSQL, MySQL 8 и большинства других СУБД совпадает. Различия касаются в основном функций для дат и строк, синтаксиса DELETE с соединением и деталей изоляции транзакций.
Нужно ли знать оконные функции junior-разработчику?
Желательно знать хотя бы ROW_NUMBER и разницу между RANK и DENSE_RANK: задачи «топ-N в каждой группе» встречаются и на junior-позициях. Для middle и выше оконные функции — обязательный минимум.
Что делать, если не помню точный синтаксис?
Скажите об этом и опишите логику: «Здесь нужна оконная функция с разбиением по отделу и сортировкой по зарплате, синтаксис рамки уточню». Интервьюеру важнее понимание, чем память на ключевые слова. Неудобные моменты такого рода разобраны в статье про собеседование без опыта.
Чем подзапрос отличается от JOIN по производительности?
В современных СУБД оптимизатор часто переписывает одно в другое, поэтому правильный ответ — «смотреть план запроса». EXISTS и NOT EXISTS обычно эффективны для проверки наличия, JOIN удобнее, когда нужны колонки из обеих таблиц.
Сколько задач по SQL нужно решить для подготовки?
Важнее разнообразие, чем количество: 20–30 задач, которые покрывают JOIN, группировку, NULL, ранжирование и сравнение строк с соседними, дают больше, чем сотня однотипных. После каждой задачи проговорите вслух, где в ней ловушка.