Оконные функции SQL: шпаргалка для собеседования
Карьерник — Duolingo для аналитиков: 10 минут в день тренируй SQL, Python, A/B, статистику, метрики и ещё 3 темы собеса. 1500+ вопросов в Telegram-боте. Бесплатно.
Зачем аналитику оконные функции
Оконные функции — самая частая тема на собеседованиях для middle-аналитиков. Если знаете только GROUP BY — этого мало.
Оконные функции — тема номер один на техническом собеседовании аналитика уровня middle и выше. Они позволяют делать вычисления по группам строк, не сворачивая результат в одну строку, как GROUP BY. Это незаменимо для расчёта рангов, нарастающих итогов, скользящих средних и сравнения строк между собой.
Если вы готовитесь к собеседованию по SQL, оконные функции — первое, что нужно довести до автоматизма.
Общий синтаксис
функция() OVER (
PARTITION BY столбец_группировки
ORDER BY столбец_сортировки
ROWS BETWEEN начало AND конец
)- PARTITION BY — делит данные на группы (окна). Аналог GROUP BY, но без свёртки строк.
- ORDER BY — задаёт порядок строк внутри окна.
- ROWS BETWEEN — определяет рамку: какие строки участвуют в вычислении.
Не все части обязательны — зависит от конкретной функции.
Функции ранжирования
ROW_NUMBER()
Присваивает уникальный порядковый номер каждой строке внутри окна.
SELECT
user_id,
order_date,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_date) AS order_num
FROM ordersРезультат: у каждого пользователя заказы пронумерованы 1, 2, 3… по дате. Номера всегда уникальны, даже при одинаковых значениях order_date.
На собеседовании спрашивают: «Найдите первый заказ каждого пользователя». Решение — обернуть в подзапрос и отфильтровать WHERE order_num = 1.
RANK()
Присваивает ранг с учётом одинаковых значений. При дубликатах — одинаковый ранг, следующий номер пропускается.
SELECT
student_id,
score,
RANK() OVER (ORDER BY score DESC) AS rank
FROM exam_resultsЕсли у двух студентов балл 95, оба получат ранг 1. Следующий студент получит ранг 3 (не 2).
DENSE_RANK()
Как RANK(), но без пропусков в нумерации.
SELECT
student_id,
score,
DENSE_RANK() OVER (ORDER BY score DESC) AS dense_rank
FROM exam_resultsДва студента с баллом 95 — ранг 1. Следующий — ранг 2.
Классический вопрос на собеседовании: «Чем отличаются ROW_NUMBER, RANK и DENSE_RANK?» Ответ: ROW_NUMBER всегда уникален, RANK пропускает номера при дубликатах, DENSE_RANK не пропускает.
NTILE(n)
Делит строки на n равных групп.
SELECT
user_id,
total_spent,
NTILE(4) OVER (ORDER BY total_spent DESC) AS quartile
FROM user_statsПользователи разбиты на 4 квартиля по сумме покупок. Первый квартиль — топ-25% по расходам.
На собеседовании: используется в задачах про сегментацию — «Разделите пользователей на 10 равных групп по активности».
Функции смещения
LAG(столбец, смещение)
Возвращает значение из предыдущей строки (или на n строк назад).
SELECT
DATE,
revenue,
LAG(revenue, 1) OVER (ORDER BY DATE) AS prev_day_revenue,
revenue - LAG(revenue, 1) OVER (ORDER BY DATE) AS daily_change
FROM daily_statsПолучаем выручку за предыдущий день и разницу — рост или падение.
LEAD(столбец, смещение)
Обратная LAG — берёт значение из следующей строки.
SELECT
user_id,
session_date,
LEAD(session_date, 1) OVER (PARTITION BY user_id ORDER BY session_date) AS next_session
FROM sessionsДля каждой сессии пользователя видим дату следующей сессии. Если следующей нет — NULL.
На собеседовании: «Посчитайте среднее время между сессиями пользователя». Решение — через LAG или LEAD вычислить разницу дат, затем AVG.
Агрегатные функции с окном
SUM() OVER
Нарастающий итог — одна из самых частых задач.
SELECT
DATE,
new_users,
SUM(new_users) OVER (ORDER BY DATE) AS cumulative_users
FROM daily_registrationsКаждая строка показывает сумму всех предыдущих значений включая текущее — нарастающий итог регистраций.
Нарастающий итог по группам:
SELECT
user_id,
order_date,
amount,
SUM(amount) OVER (PARTITION BY user_id ORDER BY order_date) AS running_total
FROM ordersAVG() OVER
Скользящее среднее — незаменимо для сглаживания метрик.
SELECT
DATE,
dau,
AVG(dau) OVER (
ORDER BY DATE
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS dau_7d_avg
FROM daily_metricsСреднее DAU за последние 7 дней (текущий + 6 предыдущих). Именно так считают скользящее среднее в продуктовой аналитике.
Оконные рамки: ROWS BETWEEN
Рамка определяет, какие строки участвуют в вычислении относительно текущей.
| Синтаксис | Значение |
|---|---|
UNBOUNDED PRECEDING |
С начала окна |
n PRECEDING |
n строк назад |
CURRENT ROW |
Текущая строка |
n FOLLOWING |
n строк вперёд |
UNBOUNDED FOLLOWING |
До конца окна |
Примеры:
-- Сумма текущей + 2 предыдущих строки
SUM(x) OVER (ORDER BY DATE ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)
-- Среднее в окне ±1 строка от текущей
AVG(x) OVER (ORDER BY DATE ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING)
-- Нарастающий итог с начала
SUM(x) OVER (ORDER BY DATE ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)Важный нюанс: по умолчанию (без явного ROWS BETWEEN) при наличии ORDER BY рамка = RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Это ведёт себя иначе при дубликатах в ORDER BY. На собеседовании этот вопрос задают, чтобы проверить глубину понимания.
Комбинированные задачи
На собеседованиях часто просят совместить несколько оконных функций. Пример:
Задача: Для каждого пользователя найдите заказ с максимальной суммой и выведите долю этого заказа от общей суммы покупок.
WITH ranked AS (
SELECT
user_id,
order_id,
amount,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY amount DESC) AS rn,
SUM(amount) OVER (PARTITION BY user_id) AS total_amount
FROM orders
)
SELECT
user_id,
order_id,
amount,
ROUND(amount::numeric / total_amount * 100, 1) AS pct_of_total
FROM ranked
WHERE rn = 1Здесь ROW_NUMBER находит максимальный заказ, а SUM OVER считает общую сумму по пользователю — обе функции в одном запросе.
15 задач на оконные функции
Задачи от простых к сложным — как на настоящем собеседовании.
Задача 1. Ранжирование клиентов по выручке
Пронумеруйте клиентов по убыванию суммарных покупок.
SELECT
user_id,
SUM(amount) AS total,
ROW_NUMBER() OVER (ORDER BY SUM(amount) DESC) AS rank
FROM orders
GROUP BY user_id;Задача 2. Топ-1 товар в каждой категории
WITH ranked AS (
SELECT product_id, category,
SUM(quantity) AS sold,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY SUM(quantity) DESC) AS rn
FROM order_items JOIN products USING (product_id)
GROUP BY product_id, category
)
SELECT * FROM ranked WHERE rn = 1;Задача 3. Разница между RANK и DENSE_RANK
Если два товара имеют одинаковые продажи: ROW_NUMBER даст разные номера (1, 2), RANK — одинаковые с пропуском (1, 1, 3), DENSE_RANK — одинаковые без пропуска (1, 1, 2). Выбирайте в зависимости от задачи.
Задача 4. Выручка за предыдущий месяц (LAG)
WITH monthly AS (
SELECT DATE_TRUNC('month', created_at) AS month,
SUM(amount) AS revenue
FROM orders GROUP BY 1
)
SELECT month, revenue,
LAG(revenue) OVER (ORDER BY month) AS prev_month,
ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
/ NULLIF(LAG(revenue) OVER (ORDER BY month), 0), 1) AS growth_pct
FROM monthly;Задача 5. Нарастающий итог выручки по дням
SELECT created_at::DATE AS day, SUM(amount) AS daily,
SUM(SUM(amount)) OVER (ORDER BY created_at::DATE
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS cumulative
FROM orders
GROUP BY created_at::DATE;Задача 6. Скользящее среднее за 7 дней
SELECT day, revenue,
AVG(revenue) OVER (
ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS ma_7d
FROM daily_revenue;ROWS BETWEEN 6 PRECEDING AND CURRENT ROW — это 7 строк (текущая + 6 предыдущих).
Задача 7. Доля каждого заказа от общей выручки пользователя
SELECT order_id, user_id, amount,
ROUND(100.0 * amount / SUM(amount) OVER (PARTITION BY user_id), 1) AS pct
FROM orders;SUM OVER без ORDER BY считает сумму по всему окну (все заказы пользователя).
Задача 8. Первый и последний заказ пользователя
SELECT DISTINCT user_id,
FIRST_VALUE(order_id) OVER w AS first_order,
LAST_VALUE(order_id) OVER (w ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS last_order
FROM orders
WINDOW w AS (PARTITION BY user_id ORDER BY created_at);Задача 9. Время между последовательными заказами
SELECT user_id, created_at,
created_at - LAG(created_at) OVER (PARTITION BY user_id ORDER BY created_at) AS gap
FROM orders;Задача 10. Нарастающий итог с обнулением каждый месяц
SELECT created_at::DATE AS day, amount,
SUM(amount) OVER (
PARTITION BY DATE_TRUNC('month', created_at)
ORDER BY created_at
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS monthly_cumulative
FROM orders;PARTITION BY месяц обнуляет сумму в начале каждого месяца.
Задача 11. Процентиль пользователя по активности
SELECT user_id, total_orders,
PERCENT_RANK() OVER (ORDER BY total_orders) AS percentile
FROM (
SELECT user_id, COUNT(*) AS total_orders FROM orders GROUP BY user_id
) t;PERCENT_RANK возвращает значение от 0 до 1 — позиция пользователя среди всех.
Задача 12. Разница между текущим и максимальным значением в группе
SELECT user_id, order_id, amount,
MAX(amount) OVER (PARTITION BY user_id) - amount AS diff_from_max
FROM orders;Задача 13. Нумерация с перезапуском при смене группы
SELECT user_id, category, order_id,
ROW_NUMBER() OVER (PARTITION BY user_id, category ORDER BY created_at) AS order_in_category
FROM orders JOIN products USING (product_id);Задача 14. NTILE — разбиение на квартили
SELECT user_id, total_spent,
NTILE(4) OVER (ORDER BY total_spent) AS quartile
FROM (
SELECT user_id, SUM(amount) AS total_spent FROM orders GROUP BY user_id
) t;NTILE(4) делит все строки на 4 равные группы. Полезно для RFM-сегментации.
Задача 15. Сложная: воронка с проверкой последовательности событий
WITH ordered AS (
SELECT user_id, event_name, event_time,
LEAD(event_name) OVER (PARTITION BY user_id ORDER BY event_time) AS next_event,
LEAD(event_time) OVER (PARTITION BY user_id ORDER BY event_time) AS next_time
FROM events
)
SELECT
COUNT(DISTINCT user_id) FILTER (WHERE event_name = 'view') AS step1,
COUNT(DISTINCT user_id) FILTER (
WHERE event_name = 'view' AND next_event = 'cart'
) AS step2,
COUNT(DISTINCT user_id) FILTER (
WHERE event_name = 'cart' AND next_event = 'purchase'
) AS step3
FROM ordered;LEAD помогает проверить, что события идут в правильном порядке.
Как тренироваться
Оконные функции нужно не просто понять — их нужно написать руками десятки раз. Теория забывается, навык остаётся.
Тренажёр Карьерник содержит вопросы по оконным функциям с разборами: от базового отличия RANK и ROW_NUMBER до сложных комбинаций с ROWS BETWEEN. Можно тренироваться по 15 минут в день в Telegram — этого достаточно, чтобы за неделю довести тему до автоматизма.
Больше примеров вопросов по SQL — в разделе подготовки. А если готовитесь комплексно, посмотрите примеры вопросов по всем темам.
Совет: выучите шаблон OVER(PARTITION BY ... ORDER BY ...). 80% оконных вопросов на собеседовании — это вариации этого шаблона.
Читайте также
FAQ
Какие оконные функции чаще всего спрашивают на собеседовании?
ROW_NUMBER, RANK и LAG/LEAD — абсолютные лидеры. За ними идут SUM OVER для нарастающих итогов и AVG OVER для скользящих средних. NTILE встречается реже, но появляется в задачах на сегментацию. Обязательно знайте разницу между ROW_NUMBER, RANK и DENSE_RANK — этот вопрос задают почти всегда.
Чем ROWS отличается от RANGE в оконной рамке?
ROWS считает физические строки: ROWS BETWEEN 2 PRECEDING AND CURRENT ROW — это ровно 3 строки. RANGE работает по значениям: если в ORDER BY есть дубликаты, RANGE включит все строки с одинаковым значением. На практике почти всегда нужен ROWS. Если не указать рамку явно, по умолчанию используется RANGE — это частая ловушка на собеседованиях.
Можно ли использовать несколько оконных функций в одном запросе?
Да, и это делают постоянно. Каждая оконная функция может иметь свой PARTITION BY и ORDER BY. Например, в одном SELECT можно одновременно пронумеровать строки через ROW_NUMBER и посчитать нарастающую сумму через SUM OVER — они будут работать независимо.
Тренируйся к собесу в @kariernik_bot — 1500+ вопросов по SQL, Python, A/B и метрикам в Telegram.