murkshelf

SQL: window functions in five examples

Window functions feel like a dialect of SQL you learn in a parallel universe, but they come down to one idea: aggregate without collapsing rows. A GROUP BY squashes the table; a window function keeps every row and adds the aggregate beside it.

All examples below work in PostgreSQL; most are identical elsewhere.

1. A total next to every row

SELECT
  employee,
  department,
  salary,
  SUM(salary) OVER () AS payroll_total
FROM salaries;

Every row survives, and each carries the same grand total.

2. Subtotals per group

SELECT
  employee,
  department,
  salary,
  SUM(salary) OVER (PARTITION BY department) AS dept_total,
  ROUND(
    100.0 * salary / SUM(salary) OVER (PARTITION BY department)
  , 1) AS pct_of_dept
FROM salaries;

PARTITION BY is “group by, but without collapsing”. This query shape — a row’s share of its group’s total — replaces most of the self-joins people used to write.

3. Ranking: ROW_NUMBER vs RANK vs DENSE_RANK

SELECT
  employee,
  department,
  salary,
  ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS row_num,
  RANK()       OVER (PARTITION BY department ORDER BY salary DESC) AS rnk,
  DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dense_rnk
FROM salaries;

For salaries 100, 100, 90:

functionresult
ROW_NUMBER()1, 2, 3 — strict positions, ties broken arbitrarily
RANK()1, 1, 3 — ties share a rank, gaps after
DENSE_RANK()1, 1, 2 — ties share a rank, no gaps

The classic “top N per group” is just this plus a filter:

SELECT * FROM (
  SELECT *,
         ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS n
  FROM salaries
) t
WHERE n <= 3;

4. Running totals

SELECT
  ordered_at::date AS day,
  amount,
  SUM(amount) OVER (ORDER BY ordered_at) AS running_total
FROM orders;

With ORDER BY inside OVER, the frame grows row by row — a cumulative sum. Add PARTITION BY customer_id and it restarts per customer.

5. Comparing to the previous row

SELECT
  ordered_at::date AS day,
  amount,
  amount - LAG(amount) OVER (ORDER BY ordered_at) AS change_from_prev,
  amount - FIRST_VALUE(amount) OVER (ORDER BY ordered_at) AS change_from_first
FROM orders;

LAG/LEAD peek one row back or forward — day-over-day deltas, the previous status of a ticket, the time between events.

One mental model

Read OVER (PARTITION BY p ORDER BY o) as: for each row, take the rows that share its p, sort them by o, and compute the function over the rows up to and including the current one. Everything else — ROWS BETWEEN, named windows, FILTER — is refinement of that sentence.