В этом практикуме разберём, как решать SQL-задачи на собеседовании и объяснять свои действия вслух. Пройдём шесть задач: от соединения таблиц с помощью JOIN и подсчёта итогов через GROUP BY до оконных функций. У каждой задачи будут условие, ход рассуждений, запрос и таблица с результатом.
Для всех задач возьмём учебный интернет-магазин. В его базе есть три таблицы: покупатели, их заказы и товары в этих заказах. Один человек может сделать несколько заказов, а в одном заказе может быть несколько товаров. Часть заказов оплачена, часть отменена. Все люди, заказы и суммы придуманы для обучения.
Одна из задач магазина — узнать, сколько потратил каждый покупатель. Перед написанием SQL полезно уточнить, какой ответ нужен: «Покажу каждого покупателя один раз, а рядом — сумму его оплаченных заказов. Если таких заказов нет, покажу ноль». Так мы сначала определяем, кого включить в результат и какие суммы складывать, а затем выбираем команды SQL.
Примеры написаны для PostgreSQL — программы для хранения данных и выполнения SQL-запросов. На собеседовании уточните, с какой базой работает компания: некоторые детали записи запросов могут отличаться.
- Что означает одна строка?
Один заказ, один покупатель или один товар в заказе?
- Какие записи нужны?
Только оплаченные заказы? Нужно ли оставить тех, у кого покупок нет?
- Как связаны таблицы?
Сколько заказов у покупателя и сколько строк с товарами у одного заказа?
- На чём проверить ответ?
Посчитайте вручную одного покупателя, затем проверьте случай без покупок.
Один набор таблиц для всех задач
В customers каждая строка — покупатель: id обозначает его уникальный номер, name — имя.
| id | name |
|---|---|
| 1 | Анна |
| 2 | Борис |
| 3 | Вера |
| 4 | Глеб |
В orders каждая строка — отдельный заказ. customer_id указывает номер покупателя, ordered_at — дату заказа, amount — полную сумму заказа в рублях. В примере суммы целые, без копеек. Статус paid означает «оплачен», cancelled — «отменён».
| id | customer_id | ordered_at | status | amount |
|---|---|---|---|---|
| 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 |
В order_items хранятся товары заказа: order_id — номер заказа, product — название товара. Каждая строка — одна товарная позиция. Полную сумму заказа мы уже знаем из orders.amount; повторно складывать её для каждого товара нельзя.
| id | order_id | product |
|---|---|---|
| 1 | 101 | Книга |
| 2 | 101 | Обложка |
| 3 | 102 | Книга |
| 4 | 103 | Набор |
| 5 | 104 | Курс |
| 6 | 104 | Тетрадь |
| 7 | 105 | Книга |
| 8 | 106 | Тетрадь |
У Анны три оплаченных заказа, причём два стоят одинаково. У Бориса есть оплаченный и отменённый заказы. У Веры — только отменённый, у Глеба заказов совсем нет. Заказы Анны 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_id | name | amount |
|---|---|---|
| 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 складывает суммы внутри каждой группы. Такие вычисления по нескольким строкам называют агрегатными. Группируем по номеру и имени: одинаковое имя у разных людей не должно объединить их покупки.
| id | name | paid_count | paid_total |
|---|---|---|---|
| 1 | Анна | 3 | 3500 |
| 2 | Борис | 1 | 2000 |
| 3 | Вера | 0 | 0 |
| 4 | Глеб | 0 | 0 |
Три места, где легко ошибиться
- Условие оплаты в 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;| id | name |
|---|---|
| 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;| id | name | paid_count | paid_total |
|---|---|---|---|
| 1 | Анна | 3 | 3500 |
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;| id | name | paid_count | paid_total |
|---|---|---|---|
| 1 | Анна | 4 | 4500 |
| 2 | Борис | 2 | 4000 |
В заказе 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. После соединения осталось только сложить число позиций по покупателю.
| id | name | paid_count | paid_total | item_lines |
|---|---|---|---|---|
| 1 | Анна | 3 | 3500 | 4 |
| 2 | Борис | 1 | 2000 | 2 |
| 3 | Вера | 0 | 0 | 0 |
| 4 | Глеб | 0 | 0 | 0 |
Проверка: у Анны три заказа на 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_id | name | order_id | ordered_at | amount |
|---|---|---|---|---|
| 1 | Анна | 103 | 2026-01-12 | 1500 |
| 2 | Борис | 104 | 2026-01-11 | 2000 |
| 3 | Вера | NULL | NULL | NULL |
| 4 | Глеб | NULL | NULL | NULL |
Проверка: у Анны выбрали 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_id | order_id | ordered_at | amount | running_total |
|---|---|---|---|---|
| 1 | 101 | 2026-01-10 | 1000 | 1000 |
| 1 | 102 | 2026-01-12 | 1000 | 2000 |
| 1 | 103 | 2026-01-12 | 1500 | 3500 |
| 2 | 104 | 2026-01-11 | 2000 | 2000 |
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW означает: взять строки от начала группы покупателя до текущей строки включительно. У Анны получаем 1000, затем 2000, затем 3500 рублей. У Бориса подсчёт начинается заново.
Если сортировать только по дате и оставить обычные правила окна по умолчанию, заказы с одинаковой датой могут попасть в сумму вместе. В нашем примере у обоих заказов Анны за 12 января получится 3500. Для пошагового подсчёта мы явно указали ROWS и добавили уникальный номер в порядок строк.
Порядок внутри OVER нужен для вычисления. Последний ORDER BY в запросе задаёт, в каком порядке показать готовые строки. Не стоит рассчитывать, что нужный порядок вывода возникнет сам.
Как потренироваться перед SQL-собеседованием
- Прочитайте только условие. Закройте решение и скажите, что означает одна строка будущего ответа.
- Уточните спорные места. Нужны ли покупатели без покупок? Учитываем ли отменённые заказы? Что делать с одинаковыми датами?
- Посчитайте пример руками. Для Анны проверьте три заказа, для Веры и Глеба — отсутствие оплаченных заказов.
- Напишите запрос и сравните результат. Сначала проверьте число строк и номера, затем суммы. Если деньги повторились, посмотрите, сколько строк получилось на один заказ после соединения.
- Объясните ошибочный вариант. Почему здесь нельзя использовать COUNT(*)? Что изменится, если перенести условие из ON в WHERE? Почему два одинаковых числа не обязательно означают ошибочный повтор заказа?
Для дополнительной практики измените условие: выберите покупки от 2000 рублей общей суммой или попросите вывести все заказы за самую позднюю дату. Сначала запишите ожидаемый ответ на наших данных, затем меняйте SQL. Это поможет проверить понимание, а не память о готовом запросе.
Что проверить перед завершением решения
- Одна строка ответа означает именно то, что требуется в условии.
- Нужные покупатели не потерялись при соединении и отборе строк.
- Заказы и суммы не повторились из-за нескольких товаров.
- NULL обработан по смыслу задачи; отсутствие покупок и неизвестное значение различаются.
- Для одинаковых дат или сумм понятно, как выбирается и показывается результат.
Начинайте с простого объяснения результата, проверяйте его на маленьком примере и только затем усложняйте запрос. Если вы можете показать, откуда взялась каждая строка и каждая сумма, обсуждать решение на собеседовании будет легче.
