📘 Банк задач для собеседований по SQL
В этом разделе собраны практические задачи, которые часто встречаются на собеседованиях.
SQL-песочница
Снизу в редактор уже подставлен стартовый DDL для первой задачи. Выполни его, чтобы создать таблицу, а потом замени код своим запросом и смотри, что получается.
SQL Playground: задача про конкурентов
Сначала выполни DDL и INSERT, потом замени код своим решением. Кнопка сброса полностью очищает БД и редактор.
Ctrl+/ / Cmd+/. Выполнить: Ctrl+Enter / Cmd+EnterЗадача Конкуренты
Есть таблица competitors_prices со следующими данными:
- sku_id_competitor — идентификатор SKU конкурента
- competitor_id — идентификатор конкурента
- promo_price_competitor — промо-цена конкурента
- regular_price_competitor — регулярная цена конкурента
- parsing_date — дата парсинга
Нужно получить витрину, где для каждой комбинации (sku_id_competitor, competitor_id, parsing_date) остаётся только одно значение цены:
- если есть
promo_price_competitor→ берём её, - если промо отсутствует (
NULL) → берёмregular_price_competitor.
DDL и наполнение таблицы
CREATE TABLE IF NOT EXISTS public.competitors_prices (
sku_id_competitor INT,
competitor_id INT,
promo_price_competitor DECIMAL,
regular_price_competitor DECIMAL,
parsing_date DATE
);
INSERT INTO competitors_prices VALUES
(15, 200, 147.0, 174.0, DATE '2025-08-30'),
(16, 200, NULL, 222.0, DATE '2025-08-27'),
(15, 200, NULL , 174.0, DATE '2025-08-30');Показать результат
| sku_id_competitor | competitor_id | price | parsing_date |
|---|---|---|---|
| 15 | 200 | 147 | 2025-08-30 |
| 16 | 200 | 222 | 2025-08-27 |
Показать решение
WITH ranked AS (
SELECT
sku_id_competitor,
competitor_id,
promo_price_competitor,
regular_price_competitor,
parsing_date,
COALESCE(promo_price_competitor, regular_price_competitor) AS price,
ROW_NUMBER() OVER (
PARTITION BY sku_id_competitor, competitor_id, parsing_date
ORDER BY CASE WHEN promo_price_competitor IS NOT NULL THEN 1 ELSE 0 END DESC
) AS rn
FROM public.competitors_prices
)
SELECT
sku_id_competitor,
competitor_id,
price,
parsing_date
FROM ranked
WHERE rn = 1;Задача: Retail компания
Есть таблица движений товара на складе:
dttm- дата и время операцииproduct- товарamt- изменение остатка
Также есть стартовый остаток по каждому товару.
Нужно посчитать, сколько товара осталось на складе на конец каждого дня по формуле:
остаток на конец дня = стартовый остаток + все движения до конца этого дня
DDL и наполнение таблицы
DROP TABLE IF EXISTS stock_movements;
DROP TABLE IF EXISTS stock_balance;
CREATE TABLE stock_balance (
product TEXT,
start_balance INT
);
CREATE TABLE stock_movements (
dttm TIMESTAMP,
product TEXT,
amt INT
);
INSERT INTO stock_balance (product, start_balance) VALUES
('Бананы', 10),
('Апельсины', 20);
INSERT INTO stock_movements (dttm, product, amt) VALUES
('2022-05-16 12:01:15', 'Бананы', 5),
('2022-05-16 12:03:15', 'Апельсины', -5),
('2022-05-16 12:03:20', 'Апельсины', -4),
('2022-05-17 12:01:15', 'Бананы', 2),
('2022-05-17 12:03:15', 'Апельсины', -3),
('2022-05-18 12:01:15', 'Бананы', 3),
('2022-05-18 12:03:15', 'Апельсины', -1);Показать результат
| dt | product | stock_left |
|---|---|---|
| 2022-05-16 | Апельсины | 11 |
| 2022-05-16 | Бананы | 15 |
| 2022-05-17 | Апельсины | 8 |
| 2022-05-17 | Бананы | 17 |
| 2022-05-18 | Апельсины | 7 |
| 2022-05-18 | Бананы | 20 |
Показать решение
WITH movements AS (
SELECT
dttm::date AS dt,
dttm,
product,
SUM(amt) OVER (
PARTITION BY product
ORDER BY dttm
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS movement_total,
ROW_NUMBER() OVER (
PARTITION BY product, dttm::date
ORDER BY dttm DESC
) AS rn
FROM stock_movements
)
SELECT
m.dt,
m.product,
b.start_balance + m.movement_total AS stock_left
FROM movements AS m
JOIN stock_balance AS b
ON b.product = m.product
WHERE m.rn = 1
ORDER BY dt, product;Задача: Общие встречи пользователей
Есть таблица Встречи. В ней есть user_id и id встречи room_id. Надо найти все пары пользователей,
которые были на общих встречах более 1 раза и само кол-во общих встреч.
Представь, что два человека побыли на одной встрече, подружились,
а потом решили ходить вместе еще на другие встречи. Вот надо найти все такие пары.
Вывести user1, user2, shared_meetings (кол-во общих встреч)
user1user2- количество общих комнат
shared_meetings
DDL и наполнение таблицы
CREATE TABLE meetings (
user_id INT,
room_id INT
);
INSERT INTO meetings (user_id, room_id) VALUES
(1, 100),
(2, 100),
(1, 200),
(3, 200),
(2, 300),
(3, 300),
(1, 300),
(2, 400),
(3, 400),
(4, 400),
(1, 500),
(2, 500);Показать результат
| user1 | user2 | shared_meetings |
|---|---|---|
| 1 | 2 | 3 |
| 1 | 3 | 2 |
| 2 | 3 | 2 |
Показать решение
SELECT
m1.user_id AS user1,
m2.user_id AS user2,
COUNT(DISTINCT m1.room_id) AS shared_meetings
FROM meetings m1
JOIN meetings m2
ON m1.room_id = m2.room_id
AND m1.user_id < m2.user_id
GROUP BY m1.user_id, m2.user_id
HAVING COUNT(DISTINCT m1.room_id) > 1Задачи с собеседований МТС
1. Менеджеры, у которых в подчинении больше 5 сотрудников
Есть таблица:
Employee(id, name, department, managerId)Нужно написать SQL-запрос, который вернёт менеджеров, у которых в подчинении больше 5 сотрудников.
Показать решение
SELECT
managerId,
COUNT(*) AS employee_count
FROM Employee
WHERE managerId IS NOT NULL
GROUP BY managerId
HAVING COUNT(*) > 5;2. Курьеры, получившие премию за июнь 2021 года
У компании по доставке еды есть таблица заказов пеших курьеров:
date, courier_id, order_id, distance, travel_timeПример данных:
date courier_id order_id distance travel_time
2021-06-01 c1010 ord12 1.2 20
2021-06-01 c1010 ord13 0.5 10
2021-06-01 c1011 ord10 3.1 35
2021-06-02 c1011 ord116 1.4 30Таблица: orders
В конце каждого месяца компания выдаёт премию тем курьерам, у которых средняя скорость доставки выше средней скорости среди всех курьеров.
Нужно вывести курьеров, которые получили премию за июнь 2021 года.
Показать решение
WITH courier_speed AS (
SELECT
courier_id,
AVG(distance / travel_time) AS avg_speed
FROM orders
WHERE EXTRACT(YEAR FROM date) = 2021
AND EXTRACT(MONTH FROM date) = 6
GROUP BY courier_id
),
overall_speed AS (
SELECT
AVG(avg_speed) AS avg_speed_all
FROM courier_speed
)
SELECT
cs.courier_id,
cs.avg_speed
FROM courier_speed cs
CROSS JOIN overall_speed os
WHERE cs.avg_speed > os.avg_speed_all
ORDER BY cs.courier_id;Дополнительные задачи
Из интересного: спросили, что такое hint в БД.
Hint — это подсказка оптимизатору запроса (например, принудительно выбрать тип join, индекс, порядок доступа), когда нужно повлиять на план выполнения.
1) Топ-3 сотрудников по зарплате в каждом отделе
Есть таблица:
employees (
id INT PRIMARY KEY,
name VARCHAR(100),
department VARCHAR(50),
salary DECIMAL(10,2),
fired_date DATE
)Задание: Для каждого отдела вывести трёх сотрудников с самой высокой зарплатой. Для каждого такого сотрудника также показать:
- его зарплату,
- среднюю зарплату по его отделу,
- разницу между его зарплатой и максимальной зарплатой в отделе.
Результат отсортировать по названию отдела и по убыванию зарплаты.
Показать решение
WITH ranked AS (
SELECT
name,
department,
salary,
AVG(salary) OVER (PARTITION BY department) AS avg_salary_per_department,
MAX(salary) OVER (PARTITION BY department) AS max_salary_per_department,
ROW_NUMBER() OVER (
PARTITION BY department
ORDER BY salary DESC, id
) AS rn
FROM employees
WHERE fired_date IS NULL
)
SELECT
name,
department,
salary,
avg_salary_per_department,
salary - max_salary_per_department AS salary_max_diff
FROM ranked
WHERE rn <= 3
ORDER BY department, salary DESC, name;2) Количество двоек у учеников, у которых больше 10 пятёрок
Таблица:
student_marks (
name VARCHAR(100),
mark INT
)В таблице имена не повторяются между учениками (одно имя = один ученик). Нужно получить имя и количество двоек у тех учеников, у которых больше 10 пятёрок.
Показать решение
SELECT
name,
SUM(CASE WHEN mark = 2 THEN 1 ELSE 0 END) AS cnt_twos
FROM student_marks
GROUP BY name
HAVING SUM(CASE WHEN mark = 5 THEN 1 ELSE 0 END) > 10;3) Сотрудники с зарплатой выше, чем у руководителя
Атрибуты:
employee_idnamedepartmentid_master(employee_id руководителя)salary
Нужно выбрать имена сотрудников, получающих зарплату больше своих руководителей.
Показать решение
SELECT
e.name
FROM employees e
JOIN employees m
ON e.id_master = m.employee_id
WHERE e.salary > m.salary;4) Уровень сотрудника в иерархии
Есть таблица:
employees (
id INT PRIMARY KEY,
name VARCHAR(100),
manager_id INT REFERENCES employees(id) -- NULL для топ-менеджера
)Задание: Для каждого сотрудника вывести:
- его имя,
- уровень в иерархии (
0для топ-менеджера).
Отсортировать по уровню, затем по имени.
Показать решение
WITH RECURSIVE hierarchy AS (
SELECT
id,
name,
manager_id,
0 AS level
FROM employees
WHERE manager_id IS NULL
UNION ALL
SELECT
e.id,
e.name,
e.manager_id,
h.level + 1 AS level
FROM employees e
JOIN hierarchy h
ON e.manager_id = h.id
)
SELECT
name,
level
FROM hierarchy
ORDER BY level, name;5) Сравнение таблиц по пяти полям
Есть две таблицы A и B с одинаковой структурой по 20 столбцов.
Нужно получить из таблицы A те записи, которые отсутствуют в таблице B,
если сравнивать строки только по пяти конкретным полям
(key1, key2, key3, key4, key5).
Показать решение
SELECT a.*
FROM A a
LEFT JOIN B b
ON a.key1 = b.key1
AND a.key2 = b.key2
AND a.key3 = b.key3
AND a.key4 = b.key4
AND a.key5 = b.key5
WHERE b.key1 IS NULL;Если нужно корректно обрабатывать NULL в ключевых полях в PostgreSQL,
лучше использовать IS NOT DISTINCT FROM:
SELECT a.*
FROM A a
WHERE NOT EXISTS (
SELECT 1
FROM B b
WHERE a.key1 IS NOT DISTINCT FROM b.key1
AND a.key2 IS NOT DISTINCT FROM b.key2
AND a.key3 IS NOT DISTINCT FROM b.key3
AND a.key4 IS NOT DISTINCT FROM b.key4
AND a.key5 IS NOT DISTINCT FROM b.key5
);6) Исключение дубликатов по условному ключу
В таблице A нужно исключить дублирующиеся записи.
Дубль определяется по совпадению значений в пяти столбцах
(key1, key2, key3, key4, key5),
но в результат должны попасть все 20 столбцов исходной таблицы.
Интервьюер отдельно просил показать решение через оконные функции,
в частности через ROW_NUMBER().
Показать решение
WITH ranked AS (
SELECT
a.*,
ROW_NUMBER() OVER (
PARTITION BY key1, key2, key3, key4, key5
ORDER BY id
) AS rn
FROM A a
)
SELECT *
FROM ranked
WHERE rn = 1;Если служебный столбец rn не нужен в результате, можно явно перечислить поля:
WITH ranked AS (
SELECT
a.*,
ROW_NUMBER() OVER (
PARTITION BY key1, key2, key3, key4, key5
ORDER BY id
) AS rn
FROM A a
)
SELECT
col1, col2, col3, col4, col5,
col6, col7, col8, col9, col10,
col11, col12, col13, col14, col15,
col16, col17, col18, col19, col20
FROM ranked
WHERE rn = 1;ORDER BY id определяет, какую именно строку оставить внутри группы дублей.
Вместо id можно использовать, например, created_at DESC или updated_at DESC,
если нужно оставлять самую новую запись.
7) Топ-5 клиентов по сумме транзакций за прошлый месяц
Есть таблица транзакций со структурой:
client_idtransaction_dateamount
Нужно получить пять клиентов, которые в прошлом месяце совершили транзакции на наибольшую общую сумму.
Показать решение
SELECT
client_id,
SUM(amount) AS total_amount
FROM transactions
WHERE transaction_date >= date_trunc('month', current_date) - INTERVAL '1 month'
AND transaction_date < date_trunc('month', current_date)
GROUP BY client_id
ORDER BY total_amount DESC, client_id
LIMIT 5;Задача: Запрос с использованием SELECT
Есть таблица employees с полями:
- id
- name
- salary
- department_id
Нужно написать SQL-запрос, который возвращает имена сотрудников и их зарплаты для всех сотрудников с зарплатой выше средней по всей таблице.
Показать решение
SELECT
name,
salary
FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees);Задача: Оконные функции и подзапросы
В таблице employees напишите запрос, который возвращает:
- имя сотрудника
- зарплату
- ранг по зарплате внутри своего
department_id(нумерация по убыванию зарплаты).
Показать решение
SELECT
name,
salary,
department_id,
RANK() OVER (
PARTITION BY department_id
ORDER BY salary DESC
) AS salary_rank
FROM employees;Задача Последняя загрузка по таблице за день
Есть таблица public.load_status со следующими данными: • dt — дата/время загрузки • table_name — имя таблицы/вьюхи • status — статус загрузки
Нужно построить выборку/витрину, где для каждой таблицы в рамках каждого календарного дня остаётся только одна строка — с максимальной dt (если в день было несколько загрузок). Отфильтровать последние 14 дней.
DROP TABLE IF EXISTS public.load_status;
CREATE TABLE public.load_status (
id bigserial PRIMARY KEY, -- уникальный ключ
dt timestamp with time zone NOT NULL, -- время загрузки
table_name text NOT NULL, -- имя таблицы/вьюхи
status text NOT NULL -- статус загрузки
);
INSERT INTO public.load_status (dt, table_name, status) VALUES
-- Таблица alpha — два прогона в один день
('2025-09-15 09:15:00+03', 'alpha.orders_daily', 'OK'),
('2025-09-15 11:45:00+03', 'alpha.orders_daily', 'OK'),
-- Таблица beta — три прогона в один день
('2025-09-15 07:20:00+03', 'beta.customer_snapshot', 'OK'),
('2025-09-15 09:55:00+03', 'beta.customer_snapshot', 'OK'),
('2025-09-15 14:10:00+03', 'beta.customer_snapshot', 'OK'),
-- Таблица gamma — один прогон сегодня, два вчера
('2025-09-15 08:30:00+03', 'gamma.product_catalog', 'OK'),
('2025-09-14 10:05:00+03', 'gamma.product_catalog', 'OK'),
('2025-09-14 16:25:00+03', 'gamma.product_catalog', 'OK'),
-- Таблица delta — разные даты
('2025-09-13 06:40:00+03', 'delta.pricing_rules', 'OK'),
('2025-09-14 07:55:00+03', 'delta.pricing_rules', 'OK'),
('2025-09-15 12:20:00+03', 'delta.pricing_rules', 'OK'),
-- Таблица epsilon — только один прогон
('2025-09-15 10:10:00+03', 'epsilon.stock_levels', 'OK');Показать результат
| dt | table_name | status | day |
|---|---|---|---|
| 2025-09-15 14:10:00+03 | beta.customer_snapshot | OK | 2025-09-15 |
| 2025-09-15 12:20:00+03 | delta.pricing_rules | OK | 2025-09-15 |
| 2025-09-15 11:45:00+03 | alpha.orders_daily | OK | 2025-09-15 |
| 2025-09-15 10:10:00+03 | epsilon.stock_levels | OK | 2025-09-15 |
| 2025-09-15 08:30:00+03 | gamma.product_catalog | OK | 2025-09-15 |
| 2025-09-14 16:25:00+03 | gamma.product_catalog | OK | 2025-09-14 |
| 2025-09-14 07:55:00+03 | delta.pricing_rules | OK | 2025-09-14 |
| 2025-09-13 06:40:00+03 | delta.pricing_rules | OK | 2025-09-13 |
Показать решение
WITH ranked AS (
SELECT
ls.dt,
ls.table_name,
ls.status,
ls.dt::date AS day,
ROW_NUMBER() OVER (
PARTITION BY ls.table_name, ls.dt::date
ORDER BY ls.dt DESC
) AS rn
FROM public.load_status AS ls
WHERE ls.dt >= current_date - INTERVAL '14 days'
)
SELECT
dt,
table_name,
status,
day
FROM ranked
WHERE rn = 1
ORDER BY dt DESC;Задача: Создание функции
Создайте функцию get_department_salary_summary(dept_id INT), которая принимает ID отдела и возвращает таблицу с:
- общим количеством сотрудников
- средней зарплатой в этом отделе.
Показать решение
CREATE OR REPLACE FUNCTION get_department_salary_summary(dept_id INT)
RETURNS TABLE (
employees_count INT,
avg_salary NUMERIC
)
AS $$
BEGIN
RETURN QUERY
SELECT
COUNT(*) AS employees_count,
AVG(salary) AS avg_salary
FROM employees
WHERE department_id = dept_id;
END;
$$ LANGUAGE plpgsql;Задача: Поставщики в Лемана Про
Напишите запрос, который для всех поставщиков с типом (supplier_type) отличным от "WAREHOUSE" выдаст идентификатор поставщика и сумму заказов у этого поставщика. В запросе не использовать оконные функции.
order_id | store_id | item_id | supplier_id | price | quantity | dt
---------+----------+---------+-------------+-------+----------+------------------------
1 | 1 | 10001 | 1 | 20 | 1 | 2023-10-11 13:54:00.000
2 | 2 | 10001 | 2 | 30 | 2 | 2023-11-11 17:14:05.000
2 | 3 | 10002 | 2 | 100 | 3 | 2023-09-01 09:32:50.000
3 | 3 | 10002 | 3 | 130 | 1 | 2023-10-21 14:51:45.000
supplier_id | supplier_name | supplier_type
------------+---------------+--------------
1 | Supplier 1 | WAREHOUSE
2 | Supplier 2 | NULL
3 | Supplier 3 | SUPPLIER
4 | Supplier 4 | SUPPLIERПоказать решение
SELECT
o.supplier_id,
SUM(o.price * o.quantity) AS total_order_amount
FROM orders o
JOIN suppliers s
ON o.supplier_id = s.supplier_id
WHERE s.supplier_type <> 'WAREHOUSE'
OR s.supplier_type IS NULL
GROUP BY o.supplier_id;Задача: Яндекс Такси
Имеем таблицу RIDE с поездками пользователей на такси. Найти всех пользователей, которые совершали поездки два (или более) дня подряд.
| ride_id | user_id | tariff | dt | cost |
|--------:|--------:|---------|------------|-----:|
| 2305001 | 1001 | econom | 2023-05-02 | 340 |
| 2305002 | 1001 | comfort | 2023-05-03 | 500 |
| 2305003 | 2001 | econom | 2023-05-01 | 120 | 2023-05-01
| 2305004 | 2001 | econom | 2023-05-01 | 150 |
| 2305007 | 2001 | econom | 2023-05-02 | 999 |
| 2305097 | 2001 | econom | 2023-05-03 | 999 |
Показать решение
SELECT DISTINCT r1.user_id
FROM ride r1
JOIN ride r2
ON r1.user_id = r2.user_id
AND r2.dt = r1.dt + INTERVAL '1 day';Задача: Оптимизация JOIN больших таблиц
Вам необходимо выполнить запрос:
SELECT
o.id,
o.created_at,
SUM(oi.price * oi.quantity)
FROM orders o
JOIN order_items oi
ON o.id = oi.order_id
WHERE o.created_at >= DATE '2024-01-01'
GROUP BY o.id, o.created_at;Задание:
Предложите стратегию индексации для обеих таблиц, чтобы СУБД выполнила соединение через Nested Loop Join или Merge Join без полного сканирования (Full Scan) таблицы order_items.
Показать решение
-- Индекс для быстрого отбора заказов по дате
CREATE INDEX idx_orders_created_at_id
ON orders (created_at, id);
-- Индекс для быстрого поиска позиций заказа по ключу соединения
CREATE INDEX idx_order_items_order_id_inc
ON order_items (order_id)
INCLUDE (price, quantity);Почему это работает:
orders(created_at, id)позволяет быстро отфильтровать только нужные строки изorders;- после отбора заказов оптимизатор может идти в
order_itemsточечно поorder_id, а не читать всю таблицу; - индекс
order_items(order_id)подходит дляNested Loop Join, когда для каждой строки изordersищутся связанные строки вorder_items; - если оптимизатор выберет
Merge Join, наличие индексов по ключам соединения уменьшает стоимость сортировки и чтения.
Практический вывод:
- для этого запроса самый полезный индекс на
ordersэто(created_at, id), потому что сначала идёт фильтр по дате; - для
order_itemsдостаточно индекса по(order_id), аINCLUDE (price, quantity)в PostgreSQL помогает сделать чтение дешевле.
Вариант индексации с акцентом на Merge Join:
CREATE INDEX idx_orders_id
ON orders (id);
CREATE INDEX idx_order_items_order_id
ON order_items (order_id);Но без индекса по `created_at` таблицу `orders` всё равно может быть дорого фильтровать, поэтому в реальной задаче лучше оставить индекс `(created_at, id)`.Задача: CPI по инсталлам
Дана таблица в PostgreSQL с количеством установок приложения на каждый день и затратами на маркетинг:
CREATE TABLE user_installs (
date_ DATE PRIMARY KEY,
num_of_installs BIGINT,
ua_costs_usd BIGINT
);1. CPI за неделю
Нужно написать запрос, который выведет среднюю стоимость одного инсталла (CPI) за неделю с 2025-01-01 по 2025-01-07.
Показать решение
SELECT
SUM(ua_costs_usd)::numeric / NULLIF(SUM(num_of_installs), 0) AS cpi
FROM user_installs
WHERE date_ BETWEEN DATE '2025-01-01' AND DATE '2025-01-07';2. CPI последних 7 дней на каждую календарную дату
Нужно изменить запрос так, чтобы на каждый календарный день за весь 2025 год, включая даты, которых нет в user_installs, отображалась CPI последних 7 календарных дней.
Показать решение
WITH calendar AS (
SELECT generate_series(
DATE '2025-01-01',
DATE '2025-12-31',
INTERVAL '1 day'
)::date AS date_
),
installs_by_day AS (
SELECT
c.date_,
COALESCE(ui.num_of_installs, 0) AS num_of_installs,
COALESCE(ui.ua_costs_usd, 0) AS ua_costs_usd
FROM calendar c
LEFT JOIN user_installs ui
ON ui.date_ = c.date_
)
SELECT
date_,
SUM(ua_costs_usd) OVER (
ORDER BY date_
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
)::numeric
/
NULLIF(
SUM(num_of_installs) OVER (
ORDER BY date_
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
),
0
) AS cpi_7d
FROM installs_by_day
ORDER BY date_;Задача: Клиенты без заказов за последние 90 дней
Нужно вернуть клиентов, у которых не было ни одного заказа за последние 90 дней.
CREATE TABLE customers (
customer_id BIGINT,
created_at TIMESTAMP
);
CREATE TABLE orders (
order_id BIGINT,
customer_id BIGINT,
order_dttm TIMESTAMP,
amount NUMERIC(18,2)
);Показать решение
SELECT
c.customer_id
FROM customers c
WHERE NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
AND o.order_dttm >= NOW() - INTERVAL '90 days'
AND o.order_dttm < NOW()
);Задача: Оставить только актуальную версию заказа
Известно, что по одному order_id может приходить несколько версий записи.
Нужно оставить только одну, самую актуальную по правилам:
- берём запись с максимальным
updated_at - если
updated_atодинаковый, приоритет уsource_system = 'core' - если и это одинаково, берём запись с максимальным
order_dttm
Нужно вернуть итоговый набор без дублей.
CREATE TABLE raw_orders (
order_id BIGINT,
user_id BIGINT,
order_dttm TIMESTAMP,
amount NUMERIC(18,2),
status TEXT,
updated_at TIMESTAMP,
source_system TEXT
);Показать решение
WITH ranked AS (
SELECT
ro.*,
ROW_NUMBER() OVER (
PARTITION BY ro.order_id
ORDER BY
ro.updated_at DESC,
CASE WHEN ro.source_system = 'core' THEN 0 ELSE 1 END,
ro.order_dttm DESC
) AS rn
FROM raw_orders ro
)
SELECT
order_id,
user_id,
order_dttm,
amount,
status,
updated_at,
source_system
FROM ranked
WHERE rn = 1;Задача: Схлопнуть последовательные периоды с одинаковой ценой
Задача с собеседования в it-one.
Есть историческая таблица с изменениями цен на товары:
CREATE TABLE price_history (
product_id INT,
price DECIMAL(10,2),
effective_from TIMESTAMP,
effective_to TIMESTAMP,
version_id SERIAL PRIMARY KEY
);
INSERT INTO price_history (product_id, price, effective_from, effective_to) VALUES
(1, 100.00, '2025-01-01 00:00:00', '2025-01-05 00:00:00'),
(1, 100.00, '2025-01-05 00:00:00', '2025-01-10 00:00:00'),
(1, 110.00, '2025-01-10 00:00:00', '2025-01-15 00:00:00'),
(1, 100.00, '2025-01-15 00:00:00', '2025-01-20 00:00:00'),
(2, 200.00, '2025-01-01 00:00:00', '2025-01-05 00:00:00'),
(2, 210.00, '2025-01-05 00:00:00', '2025-01-10 00:00:00');Таблица содержит разные версии цен на товары. Некоторые записи могут содержать одинаковые цены на последовательные периоды времени.
Нужно написать SQL-запрос, который:
- схлопнет последовательные записи с одинаковыми ценами для каждого товара;
- вернёт таблицу с корректными временными промежутками;
- сохранит первую дату начала периода и последнюю дату окончания для последовательных одинаковых цен.
Результат должен содержать:
product_idpriceeffective_fromeffective_to
Показать результат
| product_id | price | effective_from | effective_to |
|---|---|---|---|
| 1 | 100.00 | 2025-01-01 00:00:00 | 2025-01-10 00:00:00 |
| 1 | 110.00 | 2025-01-10 00:00:00 | 2025-01-15 00:00:00 |
| 1 | 100.00 | 2025-01-15 00:00:00 | 2025-01-20 00:00:00 |
| 2 | 200.00 | 2025-01-01 00:00:00 | 2025-01-05 00:00:00 |
| 2 | 210.00 | 2025-01-05 00:00:00 | 2025-01-10 00:00:00 |
Показать решение
WITH marked AS (
SELECT
product_id,
price,
effective_from,
effective_to,
version_id,
CASE
WHEN price = LAG(price) OVER (
PARTITION BY product_id
ORDER BY effective_from, version_id
)
THEN 0
ELSE 1
END AS is_new_group
FROM price_history
),
grouped AS (
SELECT
product_id,
price,
effective_from,
effective_to,
SUM(is_new_group) OVER (
PARTITION BY product_id
ORDER BY effective_from, version_id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS group_id
FROM marked
)
SELECT
product_id,
price,
MIN(effective_from) AS effective_from,
MAX(effective_to) AS effective_to
FROM grouped
GROUP BY product_id, price, group_id
ORDER BY product_id, effective_from;Идея решения:
- через
LAG(price)проверяем, изменилась ли цена относительно предыдущего периода товара; - если цена изменилась, начинаем новую группу;
- через накопительный
SUM(is_new_group)получаем номер последовательного блока; - затем агрегируем блоки через
MIN(effective_from)иMAX(effective_to).
Задача: Развернуть значения параметров в колонки
Задача с лайв-собеседования в Лигу на проект Альфы.
Дана таблица T:
NAME PARAMETER AMOUNT
N1 A 100
N1 A 200
N1 B 100
N2 A 300
N2 B 100
N2 B 400Нужно написать запрос, который выведет сумму AMOUNT по каждому NAME,
разложив значения PARAMETER по колонкам.
Ожидаемый результат:
NAME A B
N1 300 100
N2 300 500Показать решение
SELECT
name,
SUM(CASE WHEN parameter = 'A' THEN amount ELSE 0 END) AS a,
SUM(CASE WHEN parameter = 'B' THEN amount ELSE 0 END) AS b
FROM t
GROUP BY name
ORDER BY name;В PostgreSQL можно записать компактнее через FILTER:
SELECT
name,
SUM(amount) FILTER (WHERE parameter = 'A') AS a,
SUM(amount) FILTER (WHERE parameter = 'B') AS b
FROM t
GROUP BY name
ORDER BY name;Задача: Посчитать пользователей по классам с приоритетом класса B
Есть таблица, где одному и тому же пользователю user_id
могут соответствовать разные значения class.
user_id class
1 A
1 B
1 B
2 B
3 AНужно посчитать количество пользователей в каждом итоговом классе.
Если у пользователя есть обе метки A и B, он должен относиться к классу B.
Ожидаемый результат:
class users_count
A 1
B 2Показать решение
WITH user_classes AS (
SELECT
user_id,
CASE
WHEN SUM(CASE WHEN class = 'B' THEN 1 ELSE 0 END) > 0 THEN 'B'
ELSE 'A'
END AS final_class
FROM user_class
GROUP BY user_id
)
SELECT
final_class AS class,
COUNT(*) AS users_count
FROM user_classes
GROUP BY final_class
ORDER BY final_class;Важно: сначала нужно определить итоговый класс на уровне пользователя, и только потом считать пользователей по классам. Иначе пользователь с несколькими строками может быть посчитан несколько раз.
В PostgreSQL можно использовать BOOL_OR:
WITH user_classes AS (
SELECT
user_id,
CASE WHEN BOOL_OR(class = 'B') THEN 'B' ELSE 'A' END AS final_class
FROM user_class
GROUP BY user_id
)
SELECT
final_class AS class,
COUNT(*) AS users_count
FROM user_classes
GROUP BY final_class
ORDER BY final_class;Задача: Retention по недельным когортам в ClickHouse
Есть таблица events со следующей схемой:
CREATE TABLE events (
user_id UInt64,
event_date Date,
event_type String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (user_id, event_date);Нужно написать запрос, который считает retention по неделям для когорт пользователей, сгруппированных по неделе первой активности.
Ожидаемый формат результата:
cohort_week week_number users
2024-01-01 0 1000
2024-01-01 1 450
2024-01-01 2 310Показать решение
WITH first_activity AS (
SELECT
user_id,
toStartOfWeek(min(event_date), 1) AS cohort_week
FROM events
GROUP BY user_id
),
user_weeks AS (
SELECT DISTINCT
user_id,
toStartOfWeek(event_date, 1) AS activity_week
FROM events
)
SELECT
fa.cohort_week,
dateDiff('week', fa.cohort_week, uw.activity_week) AS week_number,
countDistinct(uw.user_id) AS users
FROM first_activity fa
JOIN user_weeks uw
ON uw.user_id = fa.user_id
WHERE uw.activity_week >= fa.cohort_week
GROUP BY
fa.cohort_week,
week_number
ORDER BY
fa.cohort_week,
week_number;Идея решения:
first_activityнаходит неделю первой активности пользователя;user_weeksоставляет одну строку на пользователя и неделю активности, чтобы не считать пользователя несколько раз внутри одной недели;dateDiff('week', cohort_week, activity_week)считает номер недели относительно когорты;week_number = 0— это неделя первой активности.
Если нужен retention в процентах, можно добавить размер когорты и поделить количество пользователей недели на week_number = 0.
WITH first_activity AS (
SELECT
user_id,
toStartOfWeek(min(event_date), 1) AS cohort_week
FROM events
GROUP BY user_id
),
user_weeks AS (
SELECT DISTINCT
user_id,
toStartOfWeek(event_date, 1) AS activity_week
FROM events
),
retention AS (
SELECT
fa.cohort_week,
dateDiff('week', fa.cohort_week, uw.activity_week) AS week_number,
countDistinct(uw.user_id) AS users
FROM first_activity fa
JOIN user_weeks uw
ON uw.user_id = fa.user_id
WHERE uw.activity_week >= fa.cohort_week
GROUP BY
fa.cohort_week,
week_number
),
cohort_sizes AS (
SELECT
cohort_week,
users AS cohort_users
FROM retention
WHERE week_number = 0
)
SELECT
r.cohort_week,
r.week_number,
r.users,
round(r.users / cs.cohort_users * 100, 2) AS retention_percent
FROM retention r
JOIN cohort_sizes cs
ON cs.cohort_week = r.cohort_week
ORDER BY
r.cohort_week,
r.week_number;Задача: Оптимизация большой выборки в Greenplum
Есть запрос:
-- факт (очень большая)
fact_events (
event_id BIGINT,
user_id BIGINT,
event_dttm TIMESTAMP,
event_type TEXT,
amount NUMERIC
)
DISTRIBUTED BY (event_id);
-- пользователи
dim_users (
user_id BIGINT,
country TEXT,
is_active BOOLEAN
)
DISTRIBUTED RANDOMLY;
-- календарь
dim_dates (
dt DATE
)
DISTRIBUTED RANDOMLY;
SELECT
d.dt,
u.country,
COUNT(*) AS events_cnt,
SUM(f.amount) AS total_amount
FROM fact_events f
JOIN dim_users u
ON f.user_id = u.user_id
JOIN dim_dates d
ON DATE_TRUNC('day', f.event_dttm) = d.dt
WHERE u.is_active = true
AND d.dt BETWEEN DATE '2026-01-01' AND DATE '2026-01-31'
GROUP BY d.dt, u.country;Нужно предложить более эффективный вариант.
Показать решение
CREATE TEMP TABLE t_fact_events
DISTRIBUTED BY (user_id)
AS
SELECT
user_id,
event_id,
event_dttm::date AS dt,
amount
FROM fact_events
WHERE event_dttm >= TIMESTAMP '2026-01-01 00:00:00'
AND event_dttm < TIMESTAMP '2026-02-01 00:00:00';
CREATE TEMP TABLE t_dim_users
DISTRIBUTED BY (user_id)
AS
SELECT
user_id,
country
FROM dim_users
WHERE is_active = true;
SELECT
f.dt,
u.country,
COUNT(*) AS events_cnt,
SUM(f.amount) AS total_amount
FROM t_fact_events f
JOIN t_dim_users u
ON f.user_id = u.user_id
GROUP BY f.dt, u.country;Почему это лучше:
- фильтрация по дате применяется сразу к большой факт-таблице;
DATE_TRUNCубран из условияJOIN, вместо этого дата вычисляется заранее;- обе временные таблицы распределены по
user_id, поэтому join становится colocation join без лишнего motion; dim_datesздесь не нужна, так как диапазон дат уже задаётся фильтром.
Задача: Средний чек
Даны таблицы магазинов и чеков.Средний чек покупателя за последнюю неделю его активности
CREATE TABLE shop (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
city VARCHAR(255) NOT NULL
);
CREATE TABLE cheque (
uid UUID NOT NULL DEFAULT gen_random_uuid(),
created_at TIMESTAMP NOT NULL DEFAULT now(),
"sum" DECIMAL(10, 2) NOT NULL,
shop_id INT NOT NULL,
customer_id BIGINT NOT NULL,
PRIMARY KEY (uid),
FOREIGN KEY (shop_id) REFERENCES shop (id)
);Показать решение
WITH last_purchase AS (
SELECT
customer_id,
MAX(created_at) AS last_purchase_at
FROM cheque
GROUP BY customer_id
),
weekly_avg AS (
SELECT
c.customer_id,
AVG(c."sum") AS avg_sum,
lp.last_purchase_at
FROM cheque c
JOIN last_purchase lp
ON lp.customer_id = c.customer_id
WHERE c.created_at >= lp.last_purchase_at - INTERVAL '7 days'
AND c.created_at <= lp.last_purchase_at
GROUP BY c.customer_id, lp.last_purchase_at
)
SELECT
customer_id,
avg_sum,
last_purchase_at::date AS last_purchase_date
FROM weekly_avg
ORDER BY customer_id;