В этом практикуме разберём, как решать SQL-задачи на собеседовании и объяснять свои действия вслух. Пройдём шесть задач: от соединения таблиц с помощью JOIN и подсчёта итогов через GROUP BY до оконных функций. У каждой задачи будут условие, ход рассуждений, запрос и таблица с результатом.

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

Одна из задач магазина — узнать, сколько потратил каждый покупатель. Перед написанием SQL полезно уточнить, какой ответ нужен: «Покажу каждого покупателя один раз, а рядом — сумму его оплаченных заказов. Если таких заказов нет, покажу ноль». Так мы сначала определяем, кого включить в результат и какие суммы складывать, а затем выбираем команды SQL.

Примеры написаны для PostgreSQL — программы для хранения данных и выполнения SQL-запросов. На собеседовании уточните, с какой базой работает компания: некоторые детали записи запросов могут отличаться.

Что проговорить до написания запроса
  1. Что означает одна строка?

    Один заказ, один покупатель или один товар в заказе?

  2. Какие записи нужны?

    Только оплаченные заказы? Нужно ли оставить тех, у кого покупок нет?

  3. Как связаны таблицы?

    Сколько заказов у покупателя и сколько строк с товарами у одного заказа?

  4. На чём проверить ответ?

    Посчитайте вручную одного покупателя, затем проверьте случай без покупок.

Один набор таблиц для всех задач

В customers каждая строка — покупатель: id обозначает его уникальный номер, name — имя.

customers — покупатели
idname
1Анна
2Борис
3Вера
4Глеб

В orders каждая строка — отдельный заказ. customer_id указывает номер покупателя, ordered_at — дату заказа, amount — полную сумму заказа в рублях. В примере суммы целые, без копеек. Статус paid означает «оплачен», cancelled — «отменён».

orders — заказы; amount — сумма в рублях
idcustomer_idordered_atstatusamount
10112026-01-10paid1000
10212026-01-12paid1000
10312026-01-12paid1500
10422026-01-11paid2000
10522026-01-13cancelled900
10632026-01-14cancelled500

В order_items хранятся товары заказа: order_id — номер заказа, product — название товара. Каждая строка — одна товарная позиция. Полную сумму заказа мы уже знаем из orders.amount; повторно складывать её для каждого товара нельзя.

order_items — товарные позиции в заказах
idorder_idproduct
1101Книга
2101Обложка
3102Книга
4103Набор
5104Курс
6104Тетрадь
7105Книга
8106Тетрадь

У Анны три оплаченных заказа, причём два стоят одинаково. У Бориса есть оплаченный и отменённый заказы. У Веры — только отменённый, у Глеба заказов совсем нет. Заказы Анны 102 и 103 сделаны в один день. Эти особенности пригодятся для проверки решений.

Скачать SQL для создания таблиц и данных. Выполните его один раз в отдельной пустой учебной базе PostgreSQL. Тот же код можно раскрыть ниже.

Код создания таблиц и заполнения данными
CREATE TABLE customers (
  id INTEGER PRIMARY KEY,
  name TEXT NOT NULL
);
CREATE TABLE orders (
  id INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL REFERENCES customers(id),
  ordered_at DATE NOT NULL,
  status TEXT NOT NULL CHECK (status IN ('paid', 'cancelled')),
  amount INTEGER NOT NULL CHECK (amount >= 0)
);
CREATE TABLE order_items (
  id INTEGER PRIMARY KEY,
  order_id INTEGER NOT NULL REFERENCES orders(id),
  product TEXT NOT NULL
);

INSERT INTO customers (id, name) VALUES
  (1, 'Анна'),
  (2, 'Борис'),
  (3, 'Вера'),
  (4, 'Глеб');

INSERT INTO orders (id, customer_id, ordered_at, status, amount) VALUES
  (101, 1, '2026-01-10', 'paid', 1000),
  (102, 1, '2026-01-12', 'paid', 1000),
  (103, 1, '2026-01-12', 'paid', 1500),
  (104, 2, '2026-01-11', 'paid', 2000),
  (105, 2, '2026-01-13', 'cancelled', 900),
  (106, 3, '2026-01-14', 'cancelled', 500);

INSERT INTO order_items (id, order_id, product) VALUES
  (1, 101, 'Книга'),
  (2, 101, 'Обложка'),
  (3, 102, 'Книга'),
  (4, 103, 'Набор'),
  (5, 104, 'Курс'),
  (6, 104, 'Тетрадь'),
  (7, 105, 'Книга'),
  (8, 106, 'Тетрадь');

Задача 1. JOIN: показать оплаченные заказы с именами покупателей

Условие: вывести номер каждого оплаченного заказа, имя покупателя и сумму. Одна строка результата должна означать один заказ.

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

SELECT o.id AS order_id, c.name, o.amount
FROM orders AS o
JOIN customers AS c ON c.id = o.customer_id
WHERE o.status = 'paid'
ORDER BY o.id;

JOIN, здесь это сокращение от INNER JOIN, оставляет совпавшие пары строк. Условие ON c.id = o.customer_id объясняет, какие строки соединять. Буквы c и o — короткие имена таблиц, заданные через AS. Например, o.amount — сумма из таблицы заказов.

Результат: четыре оплаченных заказа
order_idnameamount
101Анна1000
102Анна1000
103Анна1500
104Борис2000

Проверка: у Анны должно быть три строки, у Бориса — одна. Веры и Глеба здесь нет: условие просит именно оплаченные заказы. Повтор имени Анны ожидаем — у неё несколько покупок.

Задача 2. LEFT JOIN и GROUP BY: посчитать покупки каждого человека

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

«Теперь нужна одна строка на покупателя. Начну с покупателей, присоединю только оплаченные заказы, а затем посчитаю их количество и сумму».

SELECT c.id, c.name,
       COUNT(o.id) AS paid_count,
       COALESCE(SUM(o.amount), 0) AS paid_total
FROM customers AS c
LEFT JOIN orders AS o
  ON o.customer_id = c.id
 AND o.status = 'paid'
GROUP BY c.id, c.name
ORDER BY c.id;

LEFT JOIN сохраняет все строки слева — в данном случае всех покупателей. Если оплаченного заказа нет, в полях правой таблицы будет NULL: подходящего значения нет. Это не число ноль и не пустая строка.

GROUP BY c.id, c.name собирает строки одного покупателя вместе. После этого COUNT считает заказы, а SUM складывает суммы внутри каждой группы. Такие вычисления по нескольким строкам называют агрегатными. Группируем по номеру и имени: одинаковое имя у разных людей не должно объединить их покупки.

Результат: все четыре покупателя, включая двух без оплаченных заказов
idnamepaid_countpaid_total
1Анна33500
2Борис12000
3Вера00
4Глеб00

Три места, где легко ошибиться

  • Условие оплаты в ON. Если перенести o.status = 'paid' в WHERE, покупатели без оплаченных заказов исчезнут. После соединения WHERE отберёт только строки с нужным статусом; строки с NULL этому условию не соответствуют.
  • Считаем COUNT(o.id). COUNT(*) считает все строки, в том числе сохранённую строку покупателя без заказа. Поэтому Вера и Глеб получили бы по единице. COUNT(o.id) считает только непустые номера заказов и даёт ноль.
  • Ноль для отсутствующих покупок. Если складывать нечего, SUM вернёт NULL. Выражение COALESCE(..., 0) в этом случае выводит ноль. Это соответствует условию: нет оплаченных заказов — сумма покупок равна нулю. Неизвестную сумму существующего заказа подменять нулём без отдельного основания нельзя.

А если нужны только покупатели без оплаченных заказов?

Сохраним то же соединение и проверим, что номер подходящего заказа отсутствует:

SELECT c.id, c.name
FROM customers AS c
LEFT JOIN orders AS o
  ON o.customer_id = c.id
 AND o.status = 'paid'
WHERE o.id IS NULL
ORDER BY c.id;
Без оплаченных заказов: Вера и Глеб
idname
3Вера
4Глеб

Пишем IS NULL. Сравнение = NULL не даёт ответ «истина» и не выберет нужные строки. Проверять o.id удобно ещё и потому, что настоящий номер заказа не бывает NULL: он объявлен ключом таблицы.

Задача 3. HAVING: выбрать покупателей с общей суммой от 3000 рублей

Условие: из предыдущего результата оставить тех, чьи оплаченные заказы в сумме составляют не меньше 3000 рублей.

«Сначала нужно получить сумму по каждому покупателю. Проверять её буду после группировки, поэтому добавлю HAVING».

SELECT c.id, c.name,
       COUNT(o.id) AS paid_count,
       COALESCE(SUM(o.amount), 0) AS paid_total
FROM customers AS c
LEFT JOIN orders AS o
  ON o.customer_id = c.id
 AND o.status = 'paid'
GROUP BY c.id, c.name
HAVING COALESCE(SUM(o.amount), 0) >= 3000
ORDER BY c.id;
Условию соответствует только Анна
idnamepaid_countpaid_total
1Анна33500

WHERE проверяет отдельные строки до подсчёта общей суммы. HAVING проверяет уже собранные группы. Поэтому WHERE o.amount >= 3000 решало бы другую задачу: искать отдельные дорогие заказы. У Анны ни один заказ не стоит 3000 рублей, но вместе они дают 3500.

Задача 4. Соединить три таблицы и не посчитать заказ дважды

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

Сначала посмотрим на ошибку. Если сразу соединить заказы с товарами, у одного заказа появится несколько строк. Вот запрос, который выполнится, но неверно посчитает заказы и деньги:

SELECT c.id, c.name,
       COUNT(o.id) AS paid_count,
       SUM(o.amount) AS paid_total
FROM customers AS c
JOIN orders AS o
  ON o.customer_id = c.id AND o.status = 'paid'
JOIN order_items AS i ON i.order_id = o.id
GROUP BY c.id, c.name
ORDER BY c.id;
Неверный итог: заказы и деньги повторились из-за товаров
idnamepaid_countpaid_total
1Анна44500
2Борис24000

В заказе 101 две позиции: книга и обложка. После соединения сумма заказа 1000 рублей появилась дважды. Поэтому для Анны запрос насчитал четыре заказа и 4500 рублей вместо трёх заказов и 3500 рублей. Повторяющиеся строки появились из-за связи таблиц, даже если исходные номера заказов уникальны.

SUM(DISTINCT o.amount) тоже не исправит расчёт в общем случае. Это выражение складывает разные значения сумм. Два настоящих заказа Анны стоят по 1000 рублей; один из них пропадёт из подсчёта. Получится 2500 рублей вместо 3500.

Сначала посчитаем товары внутри каждого заказа

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

WITH item_counts AS (
  SELECT order_id, COUNT(*) AS item_lines
  FROM order_items
  GROUP BY order_id
)
SELECT c.id, c.name,
       COUNT(o.id) AS paid_count,
       COALESCE(SUM(o.amount), 0) AS paid_total,
       COALESCE(SUM(i.item_lines), 0) AS item_lines
FROM customers AS c
LEFT JOIN orders AS o
  ON o.customer_id = c.id AND o.status = 'paid'
LEFT JOIN item_counts AS i ON i.order_id = o.id
GROUP BY c.id, c.name
ORDER BY c.id;

WITH item_counts AS (...) даёт имя результату запроса в скобках. Сначала мы считаем позиции по order_id, затем обращаемся к этому результату как к таблице item_counts. После соединения осталось только сложить число позиций по покупателю.

Верный итог: заказы, сумма и число товарных позиций
idnamepaid_countpaid_totalitem_lines
1Анна335004
2Борис120002
3Вера000
4Глеб000

Проверка: у Анны три заказа на 3500 рублей и четыре позиции. У Бориса один оплаченный заказ на 2000 рублей и две позиции. Под «позицией» здесь понимаем строку таблицы товаров; количество одинаковых единиц товара в этой учебной схеме не хранится.

Задача 5. ROW_NUMBER: найти последний оплаченный заказ

Условие: показать последний оплаченный заказ каждого покупателя. Если таких заказов нет, оставить покупателя с NULL в полях заказа. Последним считаем заказ с самой поздней датой. Если дата совпадает, выбираем больший id — это отдельное правило нашего задания.

Оконная функция делает вычисление по связанным строкам, сохраняя сами строки. В отличие от группировки, несколько заказов не превращаются сразу в один общий итог. Функция ROW_NUMBER() пронумерует заказы каждого человека: 1, 2, 3 и так далее.

«Сначала оставлю оплаченные заказы. Для каждого покупателя расположу их от нового к старому и присвою номера. Затем возьму заказ с номером 1, сохранив покупателей без покупок».

WITH numbered AS (
  SELECT id, customer_id, ordered_at, amount,
         ROW_NUMBER() OVER (
           PARTITION BY customer_id
           ORDER BY ordered_at DESC, id DESC
         ) AS rn
  FROM orders
  WHERE status = 'paid'
)
SELECT c.id AS customer_id, c.name,
       n.id AS order_id, n.ordered_at, n.amount
FROM customers AS c
LEFT JOIN numbered AS n
  ON n.customer_id = c.id AND n.rn = 1
ORDER BY c.id;
  • OVER (...) описывает, по каким строкам считать номера.
  • PARTITION BY customer_id начинает нумерацию заново для каждого покупателя.
  • ORDER BY ordered_at DESC, id DESC ставит новые заказы первыми. DESC означает порядок от большего к меньшему.
  • rn — имя полученного номера. В следующем запросе выбираем n.rn = 1 через условие соединения.
Последний оплаченный заказ каждого покупателя
customer_idnameorder_idordered_atamount
1Анна1032026-01-121500
2Борис1042026-01-112000
3ВераNULLNULLNULL
4ГлебNULLNULLNULL

Проверка: у Анны выбрали 103, поскольку у 102 и 103 одинаковая дата, а 103 больше. У Бориса выбрали 104, хотя отменённый заказ 105 сделан позже. Если сначала нумеровать все заказы, результат будет отвечать другому условию.

Дополнительный порядок по id делает выбор понятным при одинаковых датах. Сам по себе больший номер не доказывает более позднее время покупки — мы договорились о нём в условии. Если нужны все заказы за последнюю дату, это уже другая задача.

В PostgreSQL оконный расчёт нельзя отфильтровать в WHERE того же уровня запроса: на этом шаге он ещё не вычислен. Поэтому нумерацию вынесли в numbered. Условие n.rn = 1 оставили в ON, чтобы Вера и Глеб не исчезли.

Задача 6. SUM OVER: сколько покупатель потратил к каждому заказу

Условие: вывести оплаченные заказы и рядом с каждым — сумму всех оплаченных заказов этого человека от первого до текущего. Одна строка результата снова означает один заказ.

«Сами заказы нужно сохранить. Поэтому использую SUM как оконную функцию. Для каждого покупателя буду складывать суммы по порядку дат, а при одинаковой дате — по номеру заказа».

SELECT customer_id, id AS order_id, ordered_at, amount,
       SUM(amount) OVER (
         PARTITION BY customer_id
         ORDER BY ordered_at, id
         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
       ) AS running_total
FROM orders
WHERE status = 'paid'
ORDER BY customer_id, ordered_at, id;
Общая сумма растёт с каждым следующим заказом покупателя
customer_idorder_idordered_atamountrunning_total
11012026-01-1010001000
11022026-01-1210002000
11032026-01-1215003500
21042026-01-1120002000

ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW означает: взять строки от начала группы покупателя до текущей строки включительно. У Анны получаем 1000, затем 2000, затем 3500 рублей. У Бориса подсчёт начинается заново.

Если сортировать только по дате и оставить обычные правила окна по умолчанию, заказы с одинаковой датой могут попасть в сумму вместе. В нашем примере у обоих заказов Анны за 12 января получится 3500. Для пошагового подсчёта мы явно указали ROWS и добавили уникальный номер в порядок строк.

Порядок внутри OVER нужен для вычисления. Последний ORDER BY в запросе задаёт, в каком порядке показать готовые строки. Не стоит рассчитывать, что нужный порядок вывода возникнет сам.

Как потренироваться перед SQL-собеседованием

  1. Прочитайте только условие. Закройте решение и скажите, что означает одна строка будущего ответа.
  2. Уточните спорные места. Нужны ли покупатели без покупок? Учитываем ли отменённые заказы? Что делать с одинаковыми датами?
  3. Посчитайте пример руками. Для Анны проверьте три заказа, для Веры и Глеба — отсутствие оплаченных заказов.
  4. Напишите запрос и сравните результат. Сначала проверьте число строк и номера, затем суммы. Если деньги повторились, посмотрите, сколько строк получилось на один заказ после соединения.
  5. Объясните ошибочный вариант. Почему здесь нельзя использовать COUNT(*)? Что изменится, если перенести условие из ON в WHERE? Почему два одинаковых числа не обязательно означают ошибочный повтор заказа?

Для дополнительной практики измените условие: выберите покупки от 2000 рублей общей суммой или попросите вывести все заказы за самую позднюю дату. Сначала запишите ожидаемый ответ на наших данных, затем меняйте SQL. Это поможет проверить понимание, а не память о готовом запросе.

Что проверить перед завершением решения

  • Одна строка ответа означает именно то, что требуется в условии.
  • Нужные покупатели не потерялись при соединении и отборе строк.
  • Заказы и суммы не повторились из-за нескольких товаров.
  • NULL обработан по смыслу задачи; отсутствие покупок и неизвестное значение различаются.
  • Для одинаковых дат или сумм понятно, как выбирается и показывается результат.

Начинайте с простого объяснения результата, проверяйте его на маленьком примере и только затем усложняйте запрос. Если вы можете показать, откуда взялась каждая строка и каждая сумма, обсуждать решение на собеседовании будет легче.