PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 11: Window functions

Window functions

Level 3: Advanced SQL30 min

A window function computes a value for each row from a set of related rows, without collapsing them the way GROUP BY does. Rankings, running totals, moving averages and comparisons with the previous row all come from the same OVER clause.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Partition and order windows with OVER (PARTITION BY ... ORDER BY ...)
  • Rank with row_number, rank and dense_rank and compare rows with lag and lead
  • Control frames (ROWS, RANGE, GROUPS) and avoid the last_value trap

conceptThe concept

f(...) OVER (PARTITION BY a ORDER BY b) evaluates f for every row over the rows in the same partition. Without ORDER BY the window is the whole partition; with ORDER BY the default frame is "from the start of the partition up to the current row and its peers", which is exactly a running total.

Ranking: row_number() numbers rows 1, 2, 3 with ties broken arbitrarily; rank() gives ties the same number and leaves gaps (1, 1, 3); dense_rank() does not leave gaps (1, 1, 2). ntile(4) splits into quartiles. Offsets: lag(x) and lead(x) read the previous and next row; first_value, last_value and nth_value read inside the frame.

The frame can be set explicitly: ROWS BETWEEN 6 PRECEDING AND CURRENT ROW is a 7-row moving window; RANGE BETWEEN interval '7 days' PRECEDING AND CURRENT ROW is a time-based window. Any aggregate (sum, avg, count) becomes a window function when you add OVER.

PostgreSQL 19+ lag, lead, first_value, last_value and nth_value accept IGNORE NULLS, so "carry the last known price forward" becomes last_value(price) IGNORE NULLS OVER (ORDER BY day). On 18 (this course's engine) you still need the count(price) OVER grouping trick.

whyWhy it matters

Without window functions, "each order with the customer's previous order date" or "share of category revenue" needs self-joins or correlated subqueries that are slower and harder to read. With them it is one pass over sorted data.

Use caseReal-world scenario

The growth team wants monthly revenue with a running year-to-date total, month-over-month change and a 3-month moving average. One query, one sort.

Try itExamples

Run and edit these in the interactive lesson.

Rankings within a category
SELECT category, name, price,
       row_number() OVER w AS row_number,
       rank()       OVER w AS rank,
       dense_rank() OVER w AS dense_rank
FROM products
WHERE category = 'books'
WINDOW w AS (PARTITION BY category ORDER BY price DESC)
ORDER BY price DESC
LIMIT 8;
Running total, share and moving average
WITH monthly AS (
  SELECT date_trunc('month', created_at)::date AS month, sum(total) AS revenue
  FROM orders
  WHERE status <> 'cancelled'
  GROUP BY 1
)
SELECT month, revenue,
       sum(revenue) OVER (PARTITION BY extract(year FROM month) ORDER BY month) AS ytd,
       revenue - lag(revenue) OVER (ORDER BY month) AS vs_prev_month,
       round(avg(revenue) OVER (ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS moving_avg_3m,
       round(100 * revenue / sum(revenue) OVER (), 2) AS pct_of_all
FROM monthly
ORDER BY month;
lag: time between a customer's orders
SELECT customer_id, id AS order_id, created_at::date AS day,
       created_at - lag(created_at) OVER (PARTITION BY customer_id ORDER BY created_at) AS since_previous
FROM orders
WHERE customer_id = 42
ORDER BY created_at
LIMIT 8;

With ORDER BY, the default frame ends at the current row, so last_value returns the current row. Extend the frame to see the real last value.

The last_value trap
SELECT name, salary,
       last_value(name) OVER (ORDER BY salary) AS default_frame,
       last_value(name) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS full_frame
FROM employees
WHERE dept = 'Support'
ORDER BY salary;
Filtering on a window result
SELECT *
FROM (
  SELECT dept, name, salary,
         round(100.0 * salary / sum(salary) OVER (PARTITION BY dept), 1) AS pct_of_dept
  FROM employees
) s
WHERE pct_of_dept > 25
ORDER BY dept, pct_of_dept DESC;

internalsUnder the hood

Window functions run after WHERE, GROUP BY and HAVING, and before ORDER BY and LIMIT. The plan shows a WindowAgg node fed by a Sort on the PARTITION BY and ORDER BY keys, unless an index already provides that order. That is also why you cannot filter on a window result in WHERE; wrap it in a subquery or CTE.

Avoid / preferDo this, not that

Avoid
SELECT id, row_number() OVER (ORDER BY total DESC) AS rn
FROM orders WHERE rn <= 10;
Prefer
SELECT * FROM (
  SELECT id, row_number() OVER (ORDER BY total DESC) AS rn FROM orders
) t WHERE rn <= 10;

Window functions are computed after WHERE, so they cannot be filtered in the same query level. Wrap the query and filter outside.

Avoid
SELECT name, last_value(name) OVER (PARTITION BY dept ORDER BY salary) FROM employees;
Prefer
SELECT name, last_value(name) OVER (PARTITION BY dept ORDER BY salary
       ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) FROM employees;

With ORDER BY, the default frame ends at the current row, so last_value returns the current row's value. Spell out the frame.

PitfallsCommon mistakes

  • Using row_number() when ties should share a position. Pick rank or dense_rank deliberately.
  • Expecting last_value or a windowed avg to cover the whole partition while an ORDER BY is present. Set the frame explicitly.
  • Trying to filter on a window function in WHERE or HAVING. Compute it in a subquery or CTE first.

LimitsWhen not to use it

If you only need one row per group, GROUP BY or DISTINCT ON is simpler and often faster. Window functions keep every input row, which is what you want only when the detail rows matter.

SecuritySecurity notes

Rankings, percentiles and differences from an average reveal individual values even without names. Give reporting roles views that aggregate sensitive columns such as salary, not the raw table.

ChallengeTry it yourself

For every employee, show department, name, salary, the department average (rounded) and the difference from it, highest paid first within each department.

Show one solution
One solution
SELECT dept, name, salary,
       round(avg(salary) OVER w) AS dept_avg,
       salary - round(avg(salary) OVER w) AS diff
FROM employees
WINDOW w AS (PARTITION BY dept)
ORDER BY dept, salary DESC;

InterviewQuestions you should be able to answer

What is the difference between row_number, rank and dense_rank?
For the ordered values 100, 90, 90, 80: row_number gives 1, 2, 3, 4 (ties broken arbitrarily unless the ORDER BY is unique); rank gives 1, 2, 2, 4 (gaps after ties); dense_rank gives 1, 2, 2, 3 (no gaps). Choose based on whether ties should share a position and whether you need exactly N rows.
What is a window frame, and what is the default?
The frame is the subset of the partition that frame-aware functions (aggregates, first_value, last_value, nth_value) see for the current row. Without ORDER BY it is the whole partition. With ORDER BY the default is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, which includes peers of the current row; that gives running totals but makes last_value look broken. ROWS counts physical rows, RANGE uses value offsets, GROUPS counts peer groups.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation