Documentation
📚 Вопросы с собеседований
SQL задачи

📘 Банк задач для собеседований по SQL

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

SQL-песочница

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

SQL Playground: задача про конкурентов

Сначала выполни DDL и INSERT, потом замени код своим решением. Кнопка сброса полностью очищает БД и редактор.

Инициализация...
Что можно пробовать: Сначала выполни DDL/INSERT, затем пиши SELECT, CTE, COALESCE, ROW_NUMBER и проверяй результат.. Комментарий: 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_competitorcompetitor_idpriceparsing_date
152001472025-08-30
162002222025-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);
Показать результат
dtproductstock_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 (кол-во общих встреч)

  • user1
  • user2
  • количество общих комнат 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);
Показать результат
user1user2shared_meetings
123
132
232
Показать решение
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_id
  • name
  • department
  • id_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_id
  • transaction_date
  • amount

Нужно получить пять клиентов, которые в прошлом месяце совершили транзакции на наибольшую общую сумму.

Показать решение
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');
Показать результат
dttable_namestatusday
2025-09-15 14:10:00+03beta.customer_snapshotOK2025-09-15
2025-09-15 12:20:00+03delta.pricing_rulesOK2025-09-15
2025-09-15 11:45:00+03alpha.orders_dailyOK2025-09-15
2025-09-15 10:10:00+03epsilon.stock_levelsOK2025-09-15
2025-09-15 08:30:00+03gamma.product_catalogOK2025-09-15
2025-09-14 16:25:00+03gamma.product_catalogOK2025-09-14
2025-09-14 07:55:00+03delta.pricing_rulesOK2025-09-14
2025-09-13 06:40:00+03delta.pricing_rulesOK2025-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_id
  • price
  • effective_from
  • effective_to
Показать результат
product_idpriceeffective_fromeffective_to
1100.002025-01-01 00:00:002025-01-10 00:00:00
1110.002025-01-10 00:00:002025-01-15 00:00:00
1100.002025-01-15 00:00:002025-01-20 00:00:00
2200.002025-01-01 00:00:002025-01-05 00:00:00
2210.002025-01-05 00:00:002025-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;