Оконные функции в PostgreSQL
Оконные функции (window functions) позволяют вычислять значения “по группе строк”,
не схлопывая результат, как это делает GROUP BY.
То есть каждая строка остаётся на месте, а ты получаешь дополнительные вычисленные колонки:
ранги, накопительные суммы, значения “предыдущей строки”, доли и т.д.
GROUP BY делает по одной строке на группу.
Окна сохраняют все строки, но считают “внутри окна” для каждой строки.
Синтаксис OVER
Почти любая оконная функция выглядит так:
func(...) OVER (
PARTITION BY ...
ORDER BY ...
ROWS/RANGE BETWEEN ...
)
PARTITION BY— “разделить” строки на группы (как мини-GROUP BY, но без схлопывания)ORDER BY— порядок строк внутри каждой группыROWS/RANGE ...— рамка окна (frame): какие строки участвуют в вычислении для текущей строки
PARTITION BY — “по каким группам считаем”, ORDER BY — “в каком порядке внутри группы”,
рамка — “какие строки берём”.
Пример 1 — SUM(...) OVER(PARTITION BY ...) (итог по группе без GROUP BY)
Посчитаем сумму зарплат по отделу, но оставим строки сотрудников.
Данные:
| id | name | department | salary |
|---|---|---|---|
| 1 | Alice | Sales | 1200 |
| 2 | Bob | Sales | 900 |
| 3 | Carol | IT | 1400 |
| 4 | Dave | IT | 1300 |
| 5 | Eve | HR | 900 |
Запрос:
SELECT
id,
name,
department,
salary,
SUM(salary) OVER (PARTITION BY department) AS dept_total_salary
FROM employees
ORDER BY department, salary DESC;
| id | name | department | salary | dept_total_salary |
|---|---|---|---|---|
| 5 | Eve | HR | 900 | 900 |
| 3 | Carol | IT | 1400 | 2700 |
| 4 | Dave | IT | 1300 | 2700 |
| 1 | Alice | Sales | 1200 | 2100 |
| 2 | Bob | Sales | 900 | 2100 |
С GROUP BY department ты получил бы 3 строки (по отделам).
Здесь ты получил 5 строк (по сотрудникам), но с доп. колонкой “сумма по отделу”.
Пример 2 — ROW_NUMBER, RANK, DENSE_RANK
Эти функции помогают нумеровать строки и строить рейтинги.
ROW_NUMBER()— уникальный номер строки внутри группы (1,2,3...)RANK()— рейтинг с “дырками” при равенстве (1,1,3...)DENSE_RANK()— рейтинг без “дырок” (1,1,2...)
Данные:
| id | name | department | salary |
|---|---|---|---|
| 1 | Alice | Sales | 1200 |
| 2 | Bob | Sales | 1200 |
| 3 | Carol | Sales | 900 |
| 4 | Dave | IT | 1400 |
| 5 | Eve | IT | 1300 |
Запрос:
SELECT
department,
name,
salary,
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC, id) AS row_number,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rank,
DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dense_rank
FROM employees
ORDER BY department, salary DESC, name;
| department | name | salary | row_number | rank | dense_rank |
|---|---|---|---|---|---|
| IT | Dave | 1400 | 1 | 1 | 1 |
| IT | Eve | 1300 | 2 | 2 | 2 |
| Sales | Alice | 1200 | 1 | 1 | 1 |
| Sales | Bob | 1200 | 2 | 1 | 1 |
| Sales | Carol | 900 | 3 | 3 | 2 |
Частая задача: “взять топ-2 сотрудников по зарплате в каждом отделе”.
Обычно это делается через подзапрос/CTE с ROW_NUMBER() и фильтрацию по нему.
Пример 3 — топ-2 в каждой группе (ROW_NUMBER + подзапрос)
Данные:
| id | name | department | salary |
|---|---|---|---|
| 1 | Alice | Sales | 1200 |
| 2 | Bob | Sales | 1100 |
| 3 | Carol | Sales | 900 |
| 4 | Dave | IT | 1400 |
| 5 | Eve | IT | 1300 |
| 6 | Mallory | IT | 800 |
Запрос:
SELECT department, name, salary
FROM (
SELECT
department,
name,
salary,
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC, id) AS rn
FROM employees
) t
WHERE rn <= 2
ORDER BY department, salary DESC;
| department | name | salary |
|---|---|---|
| IT | Dave | 1400 |
| IT | Eve | 1300 |
| Sales | Alice | 1200 |
| Sales | Bob | 1100 |
Пример 4 — LAG и LEAD (предыдущее/следующее значение)
LAG и LEAD позволяют “заглянуть” на предыдущую/следующую строку внутри окна.
Это удобно для расчёта изменений (дельт), трендов, сравнения с предыдущей покупкой и т.д.
Данные:
| day | amount |
|---|---|
| 2026-01-01 | 100 |
| 2026-01-02 | 130 |
| 2026-01-03 | 90 |
| 2026-01-04 | 160 |
Запрос:
SELECT
day,
amount,
LAG(amount) OVER (ORDER BY day) AS prev_amount,
amount - LAG(amount) OVER (ORDER BY day) AS delta_from_prev,
LEAD(amount) OVER (ORDER BY day) AS next_amount
FROM sales
ORDER BY day;
| day | amount | prev_amount | delta_from_prev | next_amount |
|---|---|---|---|---|
| 2026-01-01 | 100 | NULL | NULL | 130 |
| 2026-01-02 | 130 | 100 | 30 | 90 |
| 2026-01-03 | 90 | 130 | -40 | 160 |
| 2026-01-04 | 160 | 90 | 70 | NULL |
У LAG/LEAD есть третий параметр — значение по умолчанию:
LEAD(amount, 1, 0) вернёт 0, если следующей строки нет.
Пример 5 — скользящая сумма (running total)
Скользящая сумма — это сумма “с начала до текущей строки”.
Если у окна есть ORDER BY, то в PostgreSQL рамка по умолчанию обычно:
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.
При повторяющихся значениях это может считать “скачками”.
Для учебных задач и предсказуемости чаще явно пишут ROWS.
Данные:
| day | amount |
|---|---|
| 2026-01-01 | 100 |
| 2026-01-02 | 130 |
| 2026-01-03 | 90 |
| 2026-01-04 | 160 |
Запрос:
SELECT
day,
amount,
SUM(amount) OVER (
ORDER BY day
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_sum
FROM sales
ORDER BY day;
| day | amount | running_sum |
|---|---|---|
| 2026-01-01 | 100 | 100 |
| 2026-01-02 | 130 | 230 |
| 2026-01-03 | 90 | 320 |
| 2026-01-04 | 160 | 480 |
Пример 6 — скользящее среднее (moving average) за 3 строки
Оконная рамка позволяет брать “окрестность” строки: например текущая и две предыдущих.
Данные:
| day | amount |
|---|---|
| 2026-01-01 | 100 |
| 2026-01-02 | 130 |
| 2026-01-03 | 90 |
| 2026-01-04 | 160 |
| 2026-01-05 | 110 |
Запрос:
SELECT
day,
amount,
ROUND(
AVG(amount) OVER (
ORDER BY day
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
),
2
) AS moving_avg_3
FROM sales
ORDER BY day;
| day | amount | moving_avg_3 |
|---|---|---|
| 2026-01-01 | 100 | 100.00 |
| 2026-01-02 | 130 | 115.00 |
| 2026-01-03 | 90 | 106.67 |
| 2026-01-04 | 160 | 126.67 |
| 2026-01-05 | 110 | 120.00 |
Пример 7 — среднее по группе + сравнение с ним
Сравним зарплату сотрудника со средней по отделу.
Данные:
| name | department | salary |
|---|---|---|
| Alice | Sales | 1200 |
| Bob | Sales | 900 |
| Carol | Sales | 1100 |
| Dave | IT | 1400 |
| Eve | IT | 1300 |
Запрос:
SELECT
department,
name,
salary,
ROUND(AVG(salary) OVER (PARTITION BY department), 2) AS dept_avg_salary,
ROUND(salary - AVG(salary) OVER (PARTITION BY department), 2) AS diff_from_avg
FROM employees
ORDER BY department, salary DESC;
| department | name | salary | dept_avg_salary | diff_from_avg |
|---|---|---|---|---|
| IT | Dave | 1400 | 1350.00 | 50.00 |
| IT | Eve | 1300 | 1350.00 | -50.00 |
| Sales | Alice | 1200 | 1066.67 | 133.33 |
| Sales | Carol | 1100 | 1066.67 | 33.33 |
| Sales | Bob | 900 | 1066.67 | -166.67 |
Пример 8 — FIRST_VALUE и LAST_VALUE (нюанс рамки!)
FIRST_VALUE и LAST_VALUE возвращают первое/последнее значение в окне.
В PostgreSQL есть тонкость: LAST_VALUE зависит от рамки окна.
Данные:
| day | price |
|---|---|
| 2026-01-01 | 10 |
| 2026-01-02 | 12 |
| 2026-01-03 | 11 |
Запрос:
SELECT
day,
price,
FIRST_VALUE(price) OVER (ORDER BY day) AS first_price,
-- Без явной рамки last_value часто становится "текущим" значением
LAST_VALUE(price) OVER (ORDER BY day) AS last_value_default_frame,
-- Правильный "последний в полном окне":
LAST_VALUE(price) OVER (
ORDER BY day
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS last_price_full_window
FROM prices
ORDER BY day;
| day | price | first_price | last_value_default_frame | last_price_full_window |
|---|---|---|---|---|
| 2026-01-01 | 10 | 10 | 10 | 11 |
| 2026-01-02 | 12 | 10 | 12 | 11 |
| 2026-01-03 | 11 | 10 | 11 | 11 |
Если используешь LAST_VALUE и хочешь “последнее значение всей группы” —
почти всегда явно задавай рамку до UNBOUNDED FOLLOWING.
Пример 9 — доля в сумме (процент от итога)
“Какую долю от суммы по отделу составляет зарплата сотрудника?”
Данные:
| name | department | salary |
|---|---|---|
| Alice | Sales | 1200 |
| Bob | Sales | 900 |
| Carol | IT | 1400 |
| Dave | IT | 1300 |
Запрос:
SELECT
department,
name,
salary,
SUM(salary) OVER (PARTITION BY department) AS dept_total,
ROUND(
salary::numeric / SUM(salary) OVER (PARTITION BY department) * 100,
2
) AS pct_of_dept
FROM employees
ORDER BY department, salary DESC;
| department | name | salary | dept_total | pct_of_dept |
|---|---|---|---|---|
| IT | Carol | 1400 | 2700 | 51.85 |
| IT | Dave | 1300 | 2700 | 48.15 |
| Sales | Alice | 1200 | 2100 | 57.14 |
| Sales | Bob | 900 | 2100 | 42.86 |
В PostgreSQL целочисленное деление может привести к потере дробной части.
Каст к numeric даёт корректную математику.
Именованные окна (WINDOW) — меньше копипасты
В PostgreSQL можно объявить окно один раз через WINDOW и переиспользовать его.
Данные:
| id | name | department | salary |
|---|---|---|---|
| 1 | Alice | Sales | 1200 |
| 2 | Bob | Sales | 900 |
| 3 | Carol | IT | 1400 |
| 4 | Dave | IT | 1300 |
Запрос:
SELECT
department,
name,
salary,
SUM(salary) OVER w AS dept_total,
ROW_NUMBER() OVER w_ordered AS rn_in_dept
FROM employees
WINDOW
w AS (PARTITION BY department),
w_ordered AS (PARTITION BY department ORDER BY salary DESC, id)
ORDER BY department, salary DESC;
| department | name | salary | dept_total | rn_in_dept |
|---|---|---|---|---|
| IT | Carol | 1400 | 2700 | 1 |
| IT | Dave | 1300 | 2700 | 2 |
| Sales | Alice | 1200 | 2100 | 1 |
| Sales | Bob | 900 | 2100 | 2 |
FILTER и оконные функции
В PostgreSQL FILTER применяется к агрегатным функциям и позволяет учитывать
в агрегате только строки, удовлетворяющие условию.
И это работает и для оконных агрегатов:
SUM(total) FILTER (WHERE status = 'done')
OVER (PARTITION BY user_id)
FILTER не применяется к ROW_NUMBER, RANK, LAG/LEAD —
потому что они не агрегируют строки.
Но он отлично работает с SUM/COUNT/AVG/MIN/MAX + OVER.
Пример 10 — SUM(...) FILTER (...) OVER (...) (сумма “done” и сумма “всего” одновременно)
Данные:
| id | user_id | status | total |
|---|---|---|---|
| 1 | 1 | done | 100 |
| 2 | 1 | new | 50 |
| 3 | 1 | done | 70 |
| 4 | 2 | new | 40 |
| 5 | 2 | done | 60 |
Запрос:
SELECT
id,
user_id,
status,
total,
SUM(total) OVER (PARTITION BY user_id) AS total_sum,
SUM(total) FILTER (WHERE status = 'done')
OVER (PARTITION BY user_id) AS done_sum
FROM orders
ORDER BY user_id, id;
| id | user_id | status | total | total_sum | done_sum |
|---|---|---|---|---|---|
| 1 | 1 | done | 100 | 220 | 170 |
| 2 | 1 | new | 50 | 220 | 170 |
| 3 | 1 | done | 70 | 220 | 170 |
| 4 | 2 | new | 40 | 100 | 60 |
| 5 | 2 | done | 60 | 100 | 60 |
Без FILTER пришлось бы писать SUM(CASE WHEN ... THEN ... END) OVER (...).
С FILTER запрос короче и читаемее.
Пример 11 — COUNT(*) FILTER (...) OVER (...) (счётчики разных статусов)
Данные:
| id | user_id | status |
|---|---|---|
| 1 | 1 | done |
| 2 | 1 | new |
| 3 | 1 | done |
| 4 | 2 | new |
| 5 | 2 | done |
Запрос:
SELECT
id,
user_id,
status,
COUNT(*) OVER (PARTITION BY user_id) AS orders_total,
COUNT(*) FILTER (WHERE status = 'done') OVER (PARTITION BY user_id) AS orders_done,
COUNT(*) FILTER (WHERE status = 'new') OVER (PARTITION BY user_id) AS orders_new
FROM orders
ORDER BY user_id, id;
| id | user_id | status | orders_total | orders_done | orders_new |
|---|---|---|---|---|---|
| 1 | 1 | done | 3 | 2 | 1 |
| 2 | 1 | new | 3 | 2 | 1 |
| 3 | 1 | done | 3 | 2 | 1 |
| 4 | 2 | new | 2 | 1 | 1 |
| 5 | 2 | done | 2 | 1 | 1 |
Пример 12 — FILTER + running total (накопительная сумма только “done”)
Данные:
| id | user_id | status | total |
|---|---|---|---|
| 1 | 1 | done | 100 |
| 2 | 1 | new | 50 |
| 3 | 1 | done | 70 |
| 4 | 1 | done | 30 |
Запрос:
SELECT
id,
status,
total,
SUM(total) FILTER (WHERE status = 'done')
OVER (
PARTITION BY user_id
ORDER BY id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_done_sum
FROM orders
ORDER BY id;
| id | status | total | running_done_sum |
|---|---|---|---|
| 1 | done | 100 | 100 |
| 2 | new | 50 | 100 |
| 3 | done | 70 | 170 |
| 4 | done | 30 | 200 |
Потому что FILTER влияет только на агрегат, а строки результата не фильтрует.
Поэтому “new” строка тоже отображает накопленную сумму done на текущий момент.
Пример 13 — FILTER vs PARTITION BY (частая путаница)
Эти конструкции выглядят похоже, но делают разное:
-- 1) FILTER: окно по user_id, но агрегат считает только done
SUM(total) FILTER (WHERE status = 'done')
OVER (PARTITION BY user_id)
-- 2) PARTITION BY user_id, status: создаём отдельные окна для done и new
SUM(total)
OVER (PARTITION BY user_id, status)
Покажем разницу на примере.
Данные:
| id | user_id | status | total |
|---|---|---|---|
| 1 | 1 | done | 100 |
| 2 | 1 | new | 50 |
| 3 | 1 | done | 70 |
Запрос (оба варианта рядом):
SELECT
id,
status,
total,
SUM(total) FILTER (WHERE status = 'done') OVER (PARTITION BY user_id) AS done_sum_filter,
SUM(total) OVER (PARTITION BY user_id, status) AS sum_by_status_partition
FROM orders
ORDER BY id;
| id | status | total | done_sum_filter | sum_by_status_partition |
|---|---|---|---|---|
| 1 | done | 100 | 170 | 170 |
| 2 | new | 50 | 170 | 50 |
| 3 | done | 70 | 170 | 170 |
FILTER — “фильтруем строки внутри агрегата”.
PARTITION BY ... , status — “создаём разные окна для разных статусов”.
Частые ошибки и нюансы
-
Пытаться использовать
FILTERсROW_NUMBER()/LAG()— нельзя (они не агрегаты). -
Фильтровать по оконной колонке прямо в
WHERE— нужен подзапрос/CTE. -
Забывать стабильный порядок в ранжировании (добавляй
idкак tie-breaker). -
Не понимать разницу между
ROWSиRANGE— для учебных примеров чаще явно задаёмROWS.
“Сначала посчитай окно, потом фильтруй”:
SELECT *
FROM (
SELECT ..., ROW_NUMBER() OVER (...) AS rn
FROM ...
) t
WHERE rn <= 3;
Итого
- Окна сохраняют строки и добавляют вычисления “по группе/порядку”.
PARTITION BY— группы,ORDER BY— порядок, рамка — какие строки участвуют.- Ключевые функции:
SUM/AVG OVER,ROW_NUMBER/RANK,LAG/LEAD, running totals, moving averages. FILTERработает с агрегатами, в том числе оконными, и помогает считать “условные суммы/счётчики” безCASE WHEN.