Оконные функции в PostgreSQL

Оконные функции (window functions) позволяют вычислять значения “по группе строк”, не схлопывая результат, как это делает GROUP BY. То есть каждая строка остаётся на месте, а ты получаешь дополнительные вычисленные колонки: ранги, накопительные суммы, значения “предыдущей строки”, доли и т.д.

Главное отличие от GROUP BY

GROUP BY делает по одной строке на группу.
Окна сохраняют все строки, но считают “внутри окна” для каждой строки.


Синтаксис OVER

Почти любая оконная функция выглядит так:

func(...) OVER (
  PARTITION BY ...
  ORDER BY ...
  ROWS/RANGE BETWEEN ...
)
💡 Запоминалка

PARTITION BY — “по каким группам считаем”, ORDER BY — “в каком порядке внутри группы”, рамка — “какие строки берём”.


Пример 1 — SUM(...) OVER(PARTITION BY ...) (итог по группе без GROUP BY)

Посчитаем сумму зарплат по отделу, но оставим строки сотрудников.

Данные:

employees
idnamedepartmentsalary
1AliceSales1200
2BobSales900
3CarolIT1400
4DaveIT1300
5EveHR900

Запрос:

SELECT
  id,
  name,
  department,
  salary,
  SUM(salary) OVER (PARTITION BY department) AS dept_total_salary
FROM employees
ORDER BY department, salary DESC;
Результат:
idnamedepartmentsalarydept_total_salary
5EveHR900900
3CarolIT14002700
4DaveIT13002700
1AliceSales12002100
2BobSales9002100
Чем это отличается от GROUP BY?

С GROUP BY department ты получил бы 3 строки (по отделам). Здесь ты получил 5 строк (по сотрудникам), но с доп. колонкой “сумма по отделу”.


Пример 2 — ROW_NUMBER, RANK, DENSE_RANK

Эти функции помогают нумеровать строки и строить рейтинги.

Данные:

employees
idnamedepartmentsalary
1AliceSales1200
2BobSales1200
3CarolSales900
4DaveIT1400
5EveIT1300

Запрос:

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;
Результат:
departmentnamesalaryrow_numberrankdense_rank
ITDave1400111
ITEve1300222
SalesAlice1200111
SalesBob1200211
SalesCarol900332
💡 Топ-N по группе

Частая задача: “взять топ-2 сотрудников по зарплате в каждом отделе”. Обычно это делается через подзапрос/CTE с ROW_NUMBER() и фильтрацию по нему.


Пример 3 — топ-2 в каждой группе (ROW_NUMBER + подзапрос)

Данные:

employees
idnamedepartmentsalary
1AliceSales1200
2BobSales1100
3CarolSales900
4DaveIT1400
5EveIT1300
6MalloryIT800

Запрос:

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;
Результат (топ-2 по зарплате в каждом отделе):
departmentnamesalary
ITDave1400
ITEve1300
SalesAlice1200
SalesBob1100

Пример 4 — LAG и LEAD (предыдущее/следующее значение)

LAG и LEAD позволяют “заглянуть” на предыдущую/следующую строку внутри окна. Это удобно для расчёта изменений (дельт), трендов, сравнения с предыдущей покупкой и т.д.

Данные:

sales
dayamount
2026-01-01100
2026-01-02130
2026-01-0390
2026-01-04160

Запрос:

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;
Результат:
dayamountprev_amountdelta_from_prevnext_amount
2026-01-01100NULLNULL130
2026-01-021301003090
2026-01-0390130-40160
2026-01-041609070NULL
💡 Параметр default

У LAG/LEAD есть третий параметр — значение по умолчанию: LEAD(amount, 1, 0) вернёт 0, если следующей строки нет.


Пример 5 — скользящая сумма (running total)

Скользящая сумма — это сумма “с начала до текущей строки”.

Нюанс PostgreSQL: RANGE vs ROWS

Если у окна есть ORDER BY, то в PostgreSQL рамка по умолчанию обычно: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. При повторяющихся значениях это может считать “скачками”. Для учебных задач и предсказуемости чаще явно пишут ROWS.

Данные:

sales
dayamount
2026-01-01100
2026-01-02130
2026-01-0390
2026-01-04160

Запрос:

SELECT
  day,
  amount,
  SUM(amount) OVER (
    ORDER BY day
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
  ) AS running_sum
FROM sales
ORDER BY day;
Результат:
dayamountrunning_sum
2026-01-01100100
2026-01-02130230
2026-01-0390320
2026-01-04160480

Пример 6 — скользящее среднее (moving average) за 3 строки

Оконная рамка позволяет брать “окрестность” строки: например текущая и две предыдущих.

Данные:

sales
dayamount
2026-01-01100
2026-01-02130
2026-01-0390
2026-01-04160
2026-01-05110

Запрос:

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;
Результат:
dayamountmoving_avg_3
2026-01-01100100.00
2026-01-02130115.00
2026-01-0390106.67
2026-01-04160126.67
2026-01-05110120.00

Пример 7 — среднее по группе + сравнение с ним

Сравним зарплату сотрудника со средней по отделу.

Данные:

employees
namedepartmentsalary
AliceSales1200
BobSales900
CarolSales1100
DaveIT1400
EveIT1300

Запрос:

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;
Результат:
departmentnamesalarydept_avg_salarydiff_from_avg
ITDave14001350.0050.00
ITEve13001350.00-50.00
SalesAlice12001066.67133.33
SalesCarol11001066.6733.33
SalesBob9001066.67-166.67

Пример 8 — FIRST_VALUE и LAST_VALUE (нюанс рамки!)

FIRST_VALUE и LAST_VALUE возвращают первое/последнее значение в окне. В PostgreSQL есть тонкость: LAST_VALUE зависит от рамки окна.

Данные:

prices
dayprice
2026-01-0110
2026-01-0212
2026-01-0311

Запрос:

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;
Результат:
daypricefirst_pricelast_value_default_framelast_price_full_window
2026-01-0110101011
2026-01-0212101211
2026-01-0311101111
💡 Вывод

Если используешь LAST_VALUE и хочешь “последнее значение всей группы” — почти всегда явно задавай рамку до UNBOUNDED FOLLOWING.


Пример 9 — доля в сумме (процент от итога)

“Какую долю от суммы по отделу составляет зарплата сотрудника?”

Данные:

employees
namedepartmentsalary
AliceSales1200
BobSales900
CarolIT1400
DaveIT1300

Запрос:

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;
Результат:
departmentnamesalarydept_totalpct_of_dept
ITCarol1400270051.85
ITDave1300270048.15
SalesAlice1200210057.14
SalesBob900210042.86
Зачем salary::numeric?

В PostgreSQL целочисленное деление может привести к потере дробной части. Каст к numeric даёт корректную математику.


Именованные окна (WINDOW) — меньше копипасты

В PostgreSQL можно объявить окно один раз через WINDOW и переиспользовать его.

Данные:

employees
idnamedepartmentsalary
1AliceSales1200
2BobSales900
3CarolIT1400
4DaveIT1300

Запрос:

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;
Результат:
departmentnamesalarydept_totalrn_in_dept
ITCarol140027001
ITDave130027002
SalesAlice120021001
SalesBob90021002

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” и сумма “всего” одновременно)

Данные:

orders
iduser_idstatustotal
11done100
21new50
31done70
42new40
52done60

Запрос:

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;
Результат:
iduser_idstatustotaltotal_sumdone_sum
11done100220170
21new50220170
31done70220170
42new4010060
52done6010060
💡 Почему это круто

Без FILTER пришлось бы писать SUM(CASE WHEN ... THEN ... END) OVER (...). С FILTER запрос короче и читаемее.


Пример 11 — COUNT(*) FILTER (...) OVER (...) (счётчики разных статусов)

Данные:

orders
iduser_idstatus
11done
21new
31done
42new
52done

Запрос:

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;
Результат:
iduser_idstatusorders_totalorders_doneorders_new
11done321
21new321
31done321
42new211
52done211

Пример 12 — FILTER + running total (накопительная сумма только “done”)

Данные:

orders
iduser_idstatustotal
11done100
21new50
31done70
41done30

Запрос:

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;
Результат:
idstatustotalrunning_done_sum
1done100100
2new50100
3done70170
4done30200
Почему строка new тоже показывает running_done_sum?

Потому что 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)

Покажем разницу на примере.

Данные:

orders
iduser_idstatustotal
11done100
21new50
31done70

Запрос (оба варианта рядом):

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;
Результат:
idstatustotaldone_sum_filtersum_by_status_partition
1done100170170
2new5017050
3done70170170
Вывод

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;

Итого