PostgreSQL Window Functions

A window function computes across a set of rows without collapsing them — unlike GROUP BY, every input row still appears in the output.

SQL
SELECT
    title,
    author,
    price,
    AVG(price) OVER (PARTITION BY author) AS author_avg
FROM books;

Each book keeps its own row and gains its author's average.

Ranking

SQL
SELECT
    title,
    price,
    ROW_NUMBER() OVER (ORDER BY price DESC) AS row_num,
    RANK()       OVER (ORDER BY price DESC) AS rank,
    DENSE_RANK() OVER (ORDER BY price DESC) AS dense_rank
FROM books;
Function On ties
ROW_NUMBER 1, 2, 3, 4 — always distinct
RANK 1, 2, 2, 4 — leaves gaps
DENSE_RANK 1, 2, 2, 3 — no gaps

Top N per group

The classic use — "the two most expensive books per author":

SQL
WITH ranked AS (
    SELECT
        title, author, price,
        ROW_NUMBER() OVER (PARTITION BY author ORDER BY price DESC) AS rn
    FROM books
)
SELECT title, author, price
FROM ranked
WHERE rn <= 2;

You cannot filter on a window function in WHERE directly — it is computed after WHERE runs — hence the CTE.

LAG and LEAD

Compare a row with its neighbours:

SQL
SELECT
    month,
    revenue,
    LAG(revenue)  OVER (ORDER BY month) AS prev_month,
    revenue - LAG(revenue) OVER (ORDER BY month) AS change
FROM monthly_sales
ORDER BY month;

Running totals

SQL
SELECT
    order_date,
    amount,
    SUM(amount) OVER (ORDER BY order_date
                      ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total
FROM orders;

Named windows

When several functions share a window, define it once:

SQL
SELECT
    title,
    ROW_NUMBER() OVER w AS rn,
    SUM(price)   OVER w AS running
FROM books
WINDOW w AS (PARTITION BY author ORDER BY published);

Other useful ones

SQL
FIRST_VALUE(price) OVER (PARTITION BY author ORDER BY published)
LAST_VALUE(price)  OVER (PARTITION BY author ORDER BY published
                         ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)
NTILE(4) OVER (ORDER BY price)     -- quartiles
PERCENT_RANK() OVER (ORDER BY price)

LAST_VALUE needs that explicit frame clause — the default frame stops at the current row, which surprises everyone the first time.