Window functions advanced: ROWS BETWEEN и LAG-трюки
За пределами ROW_NUMBER
Базовые оконные функции (ROW_NUMBER, RANK, LAG, SUM OVER) знают все. Но настоящая сила открывается, когда начинаете использовать frame clauses, dynamic partitioning и комбинировать LAG/LEAD с нетривиальной логикой.
В этой статье — продвинутые паттерны, которые отличают senior-уровень владения SQL от middle.
Frame clause: ROWS vs RANGE
В оконной функции после ORDER BY можно указать рамку (frame), которая задаёт, какие строки входят в окно относительно текущей.
SUM(amount) OVER (
ORDER BY DATE
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS rolling_7d_sumЭто скользящая сумма за 7 дней — 6 предыдущих плюс текущий. Стандартный приём для smoothing метрик вроде DAU.
Но есть нюанс — ROWS и RANGE работают по-разному.
ROWS считает физические строки. ROWS BETWEEN 6 PRECEDING AND CURRENT ROW — ровно 7 строк независимо от значений.
RANGE работает по значениям. RANGE BETWEEN INTERVAL '6 day' PRECEDING AND CURRENT ROW — все строки, у которых дата в интервале 6 дней. Если в один день 5 строк — все они учитываются.
На собеседовании любят спрашивать разницу. Ответ: ROWS для физических строк, RANGE для значений (нужен для работы с дубликатами в ORDER BY).
Centered moving average
Скользящее среднее с центрированием — не просто «за последние 7 дней», а «3 до + текущий + 3 после»:
SELECT
DATE,
dau,
AVG(dau) OVER (
ORDER BY DATE
ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING
) AS centered_ma_7d
FROM daily_metrics;Даёт более сглаженную кривую, чем обычное rolling average. Полезно для анализа трендов без лага.
Минус: для последних 3 дней окно неполное. Надо либо exclude их из визуализации, либо смириться с искажением на концах.
UNBOUNDED PRECEDING
Для нарастающего итога используют UNBOUNDED PRECEDING:
SELECT
DATE,
daily_new_users,
SUM(daily_new_users) OVER (
ORDER BY DATE
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS cumulative_users
FROM signups_daily;По умолчанию, если указан ORDER BY но не указан frame, Postgres использует RANGE UNBOUNDED PRECEDING AND CURRENT ROW. Для уникальных значений это то же самое что ROWS, но для дубликатов — разное. Всегда лучше явно указывать ROWS.
LAG с N offset
Базовый LAG(col) возвращает предыдущее значение. Но есть второй параметр — offset:
SELECT
DATE,
revenue,
LAG(revenue, 1) OVER (ORDER BY DATE) AS prev_day,
LAG(revenue, 7) OVER (ORDER BY DATE) AS prev_week,
LAG(revenue, 30) OVER (ORDER BY DATE) AS prev_month
FROM daily_revenue;Это даёт сравнение с разными периодами в одном запросе. Полезно для расчёта недельных/месячных изменений.
Третий параметр — default value для случая, когда смещение выходит за границы:
LAG(revenue, 1, 0) OVER (ORDER BY DATE) AS prev_day_or_zeroБез default вернулся бы NULL. С ним — 0. Удобно, чтобы не обрабатывать NULL потом в процентных расчётах.
Session detection
Один из самых красивых приёмов оконных функций — определение сессий пользователя.
Задача: у каждого user есть event-ы, нужно разбить их на сессии (30-минутный gap разделяет сессии).
WITH events_with_gap AS (
SELECT
user_id,
event_time,
event_time - LAG(event_time) OVER (
PARTITION BY user_id ORDER BY event_time
) AS gap
FROM events
),
events_with_session_start AS (
SELECT
user_id,
event_time,
CASE
WHEN gap IS NULL OR gap > INTERVAL '30 min' THEN 1
ELSE 0
END AS is_session_start
FROM events_with_gap
)
SELECT
user_id,
event_time,
SUM(is_session_start) OVER (
PARTITION BY user_id ORDER BY event_time
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS session_id
FROM events_with_session_start;Три шага:
Первый — вычислить gap между событиями пользователя через LAG.
Второй — отметить события, где gap больше 30 минут, как начало новой сессии.
Третий — cumulative SUM по session starts даёт session_id: каждый новый session start увеличивает id.
После этого GROUP BY (user_id, session_id) позволяет считать метрики по сессиям.
Это classic-задача на middle+ SQL-собесе. Если умеете разложить на шаги и объяснить — зачёт.
Глубокая практика таких задач сильно помогает на технических интервью в топовых компаниях. В тренажёре Карьерник есть специальный блок сложных оконных задач с разборами.
Top-N по группе
Классическая задача — выбрать топ-3 товара в каждой категории:
WITH ranked AS (
SELECT
category,
product_id,
revenue,
ROW_NUMBER() OVER (
PARTITION BY category
ORDER BY revenue DESC
) AS rn
FROM product_stats
)
SELECT category, product_id, revenue
FROM ranked
WHERE rn <= 3;Важное различие: ROW_NUMBER даёт уникальные номера. RANK пропускает при дубликатах (1, 1, 3). DENSE_RANK не пропускает (1, 1, 2).
Для «топ-3 уникальных позиций по выручке» — DENSE_RANK. Для «строго 3 строки» — ROW_NUMBER.
Running max и min
Не очевидное применение — отслеживать максимум за всё время до текущей точки:
SELECT
DATE,
revenue,
MAX(revenue) OVER (
ORDER BY DATE
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS all_time_high
FROM daily_revenue;Это показывает, был ли это исторический максимум или нет. Удобно для финансовых метрик.
Аналогично MIN() OVER — исторический минимум.
NTILE: распределение на кванты
NTILE делит строки на N равных групп:
SELECT
user_id,
total_spent,
NTILE(10) OVER (ORDER BY total_spent DESC) AS decile
FROM user_ltv;Это разбивает пользователей на 10 дециль. Decile=1 — топ 10%, decile=10 — нижние 10%.
Популярно для RFM-анализа и сегментации. Быстро и без ручных порогов.
FIRST_VALUE и LAST_VALUE
Берут первое или последнее значение в окне. Ловушка — дефолтный frame для этих функций.
-- Это не то, что вы ожидаете
SELECT
user_id,
order_id,
order_date,
FIRST_VALUE(order_id) OVER (
PARTITION BY user_id ORDER BY order_date
) AS first_order,
LAST_VALUE(order_id) OVER (
PARTITION BY user_id ORDER BY order_date
) AS last_order -- БАГ: это не последний заказ!
FROM orders;FIRST_VALUE работает интуитивно. LAST_VALUE — нет. Дефолтный frame заканчивается на CURRENT ROW, поэтому LAST_VALUE возвращает не последнее значение в партиции, а последнее на данный момент — то есть текущее.
Чтобы получить реально последний:
LAST_VALUE(order_id) OVER (
PARTITION BY user_id
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS last_orderЯвно указываем, что окно — от начала до конца партиции.
Альтернатива — использовать FIRST_VALUE с обратным ORDER BY:
FIRST_VALUE(order_id) OVER (
PARTITION BY user_id ORDER BY order_date DESC
) AS last_orderПроще и без ловушки.
Процент от общей суммы
Удобный паттерн — считать процент вклада:
SELECT
category,
SUM(revenue) AS category_revenue,
SUM(SUM(revenue)) OVER () AS total_revenue,
SUM(revenue) * 100.0 / SUM(SUM(revenue)) OVER () AS pct_of_total
FROM products
GROUP BY category;Двойной SUM — внешний работает как оконная функция над агрегатом внутреннего GROUP BY. Даёт общую сумму в каждой строке результата.
Typical ошибки
Забыть про ORDER BY в оконной функции, когда она нужна. LAG без ORDER BY возвращает undefined.
Использовать RANGE по умолчанию, ожидая поведения ROWS. С дубликатами в ORDER BY результат будет другой.
LAST_VALUE без явного frame — получаете current row, не последнее значение.
Помещать оконную функцию в WHERE напрямую. Нельзя: оконные функции выполняются после WHERE. Нужен subquery или CTE.
Читайте также
FAQ
Можно ли использовать оконные функции в WHERE?
Нет, напрямую нельзя — они выполняются после WHERE. Оборачивайте в CTE или подзапрос, потом фильтруйте по результату оконной функции.
Window function или GROUP BY?
Window — когда нужно сохранить все строки и добавить агрегат в контексте. GROUP BY — когда нужно свернуть данные в сгруппированный результат.
Performance window functions?
Сравнимо с GROUP BY для простых случаев. Сложные окна с большими PARTITION могут быть тяжелее. Всегда смотрите EXPLAIN.
Можно ли несколько оконных функций в одном запросе?
Да, с одинаковыми или разными OVER. Postgres оптимизирует, где может, объединяя их обработку.