Шпора.tech

Блог

Вопросы по SQL на собеседовании: 20 задач с разбором решений

Задачи, которые встречаются чаще всего, — с решениями и объяснением, почему интервьюер спрашивает именно это.

SQL-секция обманчива: синтаксис учится за неделю, а срезаются на ней регулярно. Причина в том, что спрашивают не синтаксис, а понимание того, что запрос делает с данными.

С чего всё начинается: порядок выполнения

Вопрос выглядит теоретическим, но из него следуют ответы на половину остальных.

Как пишут и как выполняется Порядок записи SELECT FROM WHERE GROUP BY HAVING ORDER BY LIMIT Порядок выполнения FROM и JOIN WHERE GROUP BY HAVING SELECT ORDER BY LIMIT Отсюда правило: алиас из SELECT нельзя использовать в WHERE — на момент WHERE его ещё не существует
Порядок выполнения SQL-запроса отличается от порядка записи

Запрос пишут начиная с SELECT, а выполняется он начиная с FROM. Отсюда три следствия, которые и проверяют:

  • Алиас из SELECT нельзя использовать в WHERE — на тот момент его ещё нет. В ORDER BY можно: он выполняется позже.
  • WHERE фильтрует строки до группировки, HAVING — группы после. Условие по агрегату в WHERE не работает.
  • WHERE дешевле HAVING: отсекать нужно как можно раньше.

Джойны

Чем LEFT JOIN отличается от INNER JOIN?

INNER оставляет только совпавшие строки, LEFT — все строки левой таблицы, подставляя NULL там, где справа ничего не нашлось.

Найдите пользователей без единого заказа

Задача на LEFT JOIN с проверкой на NULL:

SELECT u.id, u.email
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.id IS NULL;

Здесь же любят спросить про альтернативы: NOT EXISTS обычно читается лучше и на больших таблицах чаще выигрывает, а NOT IN — ловушка, потому что при NULL в подзапросе он вернёт пустой результат.

SELECT id, email FROM users u
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);

Почему после джойна строк стало больше?

Частый вопрос с подвохом. Если справа на одну строку слева приходится несколько совпадений, строки размножатся. Классический случай — джойн заказов с позициями заказов, после которого сумма по заказам считается неверно.

Правильная реакция: сначала агрегировать, потом джойнить.

SELECT o.id, o.created_at, i.total
FROM orders o
JOIN (
    SELECT order_id, SUM(price * qty) AS total
    FROM order_items GROUP BY order_id
) i ON i.order_id = o.id;

Группировки и агрегаты

Чем COUNT(*) отличается от COUNT(column)?

COUNT(*) считает строки, COUNT(column) — строки, где столбец не NULL. COUNT(DISTINCT column) — уникальные непустые значения. На этом строится масса ошибок в отчётности.

Как ведут себя агрегаты с NULL?

SUM, AVG, MAX пропускают NULL. Отсюда неприятность: AVG по столбцу с пропусками считает среднее по заполненным, а не по всем строкам — если нужно иначе, COALESCE(col, 0).

Топ-3 товара по выручке в каждой категории

Задача на оконные функции — самая частая на позициях аналитика.

SELECT category, product, revenue
FROM (
    SELECT category, product, SUM(price * qty) AS revenue,
           ROW_NUMBER() OVER (
               PARTITION BY category ORDER BY SUM(price * qty) DESC
           ) AS rn
    FROM sales
    GROUP BY category, product
) t
WHERE rn <= 3;

Оконную функцию нельзя фильтровать в WHERE того же уровня — она вычисляется после. Отсюда подзапрос или CTE.

Оконные функции

Чем ROW_NUMBER отличается от RANK и DENSE_RANK?

  • ROW_NUMBER — сквозная нумерация, при равных значениях порядок произвольный: 1, 2, 3, 4.
  • RANK — одинаковым значениям одинаковый ранг, следующий с пропуском: 1, 2, 2, 4.
  • DENSE_RANK — то же, но без пропуска: 1, 2, 2, 3.

Уточняющий вопрос обычно такой: какую взять для «топ-3 с учётом ничьих»? Ответ — DENSE_RANK, иначе при двух вторых местах третье потеряется.

Накопительный итог по дням

SELECT day, revenue,
       SUM(revenue) OVER (ORDER BY day
           ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running
FROM daily;

Указывать рамку явно стоит: по умолчанию при наличии ORDER BY используется RANGE, и на одинаковых датах результат будет не тем, которого ждут.

Насколько выросли продажи по сравнению с прошлым месяцем

SELECT month, revenue,
       revenue - LAG(revenue) OVER (ORDER BY month) AS diff
FROM monthly;

LAG берёт предыдущую строку, LEAD — следующую. Оба принимают смещение и значение по умолчанию: LAG(revenue, 12, 0) — год назад.

Практические задачи

Удалите дубликаты, оставив по одной строке

DELETE FROM t
WHERE id NOT IN (
    SELECT MIN(id) FROM t GROUP BY email
);

Вариант через оконную функцию читается лучше и обычно быстрее:

DELETE FROM t
WHERE id IN (
    SELECT id FROM (
        SELECT id, ROW_NUMBER() OVER (
            PARTITION BY email ORDER BY created_at
        ) AS rn FROM t
    ) x WHERE rn > 1
);

Найдите вторую по величине зарплату

Задача-классика. Наивное ORDER BY salary DESC LIMIT 1 OFFSET 1 ломается при дубликатах — вернёт ту же максимальную.

SELECT MAX(salary) FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);

Если спросят про N-ю — DENSE_RANK.

Посчитайте удержание по когортам

Тяжёлая задача, которую дают аналитикам. Смысл: сгруппировать пользователей по месяцу первого визита и посмотреть, сколько вернулось через месяц, два, три.

WITH first_seen AS (
    SELECT user_id, DATE_TRUNC('month', MIN(event_at)) AS cohort
    FROM events GROUP BY user_id
)
SELECT f.cohort,
       DATE_TRUNC('month', e.event_at) AS period,
       COUNT(DISTINCT e.user_id) AS users
FROM events e
JOIN first_seen f ON f.user_id = e.user_id
GROUP BY 1, 2
ORDER BY 1, 2;

Здесь оценивают не столько запрос, сколько понимание, что такое когорта и зачем она нужна.

Производительность

Почему индекс не помогает?

Частые причины, и хорошо назвать хотя бы три:

  • Функция на столбце: WHERE DATE(created_at) = '2026-01-01' индекс не использует. Переписывать через диапазон.
  • LIKE '%текст' с ведущим процентом — обычный B-tree бесполезен.
  • Низкая селективность: если условие отбирает половину таблицы, планировщик предпочтёт полный проход, и будет прав.
  • Порядок столбцов в составном индексе: индекс по (a, b) работает для условия по a, но не по одному b.

Как понять, что запрос медленный из-за плана?

EXPLAIN ANALYZE. Достаточно уметь читать три вещи: используется ли индекс или идёт seq scan, сколько строк планировщик ожидал против фактических (сильное расхождение — устаревшая статистика), и где в дереве накапливается время.

Ответ «добавил бы индекс» без вопроса о том, какие запросы к таблице идут, — слабый. Индексы ускоряют чтение и замедляют запись, и на таблице с интенсивной вставкой это реальный размен.

Что делать, если задача не решается

Пишите по шагам и вслух. Соберите сначала промежуточный набор данных, посмотрите на него, потом навешивайте агрегацию. CTE для этого удобнее вложенных подзапросов — и читается интервьюером легче.

Если синтаксис вылетел из головы, скажите словами, что хотите сделать: «здесь нужна оконная функция с разбиением по категории». Идея важнее точного написания — её и оценивают.

Коротко

  • Порядок выполнения объясняет половину остальных вопросов.
  • NOT IN с NULL и размножение строк после джойна — две самые частые ловушки.
  • ROW_NUMBER, RANK, DENSE_RANK нужно уметь различать вслух.
  • При вопросах о скорости начинайте с EXPLAIN, а не с «добавлю индекс».
  • Не решается — стройте запрос шагами через CTE и проговаривайте.

Шпора — ИИ-помощник, который слышит вопрос интервьюера и за секунду выводит опору для ответа. Работает на вашем компьютере с любой видеосвязью.

Попробовать бесплатно
Читать дальше

«Почему вы увольняетесь?»: как ответить честно и не навредить себе