SQL Course L3 Track / Module 04
Window Functions ~2h · hands-on
SQL · L3 Track · Module 04

Window Functions
Calculate across rows

An aggregate collapses many rows into one. A window function does the same math (sum, average, rank, running total) but keeps every row intact. Each row holds onto its own identity and gains a value computed over a "window" of related rows. This is the thing GROUP BY simply cannot do, and the single most common topic that separates an L3 SQL candidate from an L1 one. Maya, the new analyst at our small company, learns it the week she gets handed her first real reporting request.

~2h · core technique needs Modules 00–03 PostgreSQL 16
0
Section 00 · Orientation

Recap & the core idea

By now you can filter (WHERE), aggregate (GROUP BY with SUM, AVG, COUNT), join multiple tables, and reach for subqueries and CTEs to compose logic. Every aggregate you have written so far shares one trait: it collapses. Ten employees in a department become a single row holding their average salary. The individual rows are gone.

That is exactly the limitation window functions remove. Maya's first ticket reads: "show every employee, their salary, and the average salary of their department, on the same row." She tries GROUP BY and gets stuck. Grouping destroys the per-employee rows, so she would need a self-join or a correlated subquery to glue the average back on. A window function answers it directly.

The one-sentence definition

A window function performs a calculation across a set of rows related to the current row (its window) and returns a value for each row, without collapsing them. Same family of math as aggregates, opposite effect on row count. GROUP BY reduces rows; a window function annotates them.

The mechanism is a single new clause, OVER(...), bolted onto a function call. When the database sees OVER, it switches that function from "collapse mode" into "window mode." Everything in this module is really just learning what you can put inside those parentheses.

1
Section 01 · Anatomy

The OVER() clause

A window function is any aggregate-or-window function followed by OVER (...). Three optional pieces live inside the parentheses, and each one shapes the window differently.

AVG(salary) OVER (PARTITION BY dept_id ORDER BY salary frame) the function  ·  OVER keyword  ·  split into groups  ·  order within group  ·  which rows count

Maya starts with the simplest possible window: empty parentheses. OVER () means "the window is the entire result set." Every row sees every other row.

whole-table average attached to every row
SELECT name, dept_id, salary,
       AVG(salary) OVER ()::numeric(10,0) AS company_avg
FROM employees;

-- every row keeps its identity AND gets the same company-wide average
 name  | dept_id | salary | company_avg
-------+---------+--------+-------------
 Alice |      10 | 185000 |      119167
 Bob   |      10 | 120000 |      119167
 Carol |      20 | 135000 |      119167
 Dan   |      20 |  98000 |      119167
 Eve   |      30 | 105000 |      119167
 Frank |  (null) |  72000 |      119167

Six rows in, six rows out. The average (185000+120000+135000+98000+105000+72000)/6 = 119167 is computed once and stamped onto every row. No GROUP BY appeared anywhere, and we did not lose a single employee.

Now add PARTITION BY dept_id. This splits the rows into independent groups (partitions) and computes the average separately within each one, but, crucially, still emits one row per employee. That is what Maya's ticket actually asked for.

per-department average, rows preserved
SELECT name, dept_id, salary,
       AVG(salary) OVER (PARTITION BY dept_id)::numeric(10,0) AS dept_avg
FROM employees
ORDER BY dept_id, salary DESC;

 name  | dept_id | salary | dept_avg
-------+---------+--------+----------
 Alice |      10 | 185000 |   152500   <- (185000+120000)/2
 Bob   |      10 | 120000 |   152500
 Carol |      20 | 135000 |   116500   <- (135000+98000)/2
 Dan   |      20 |  98000 |   116500
 Eve   |      30 | 105000 |   105000   <- only one row in dept 30
 Frank |  (null) |  72000 |    72000   <- NULL is its own partition

Look at what Maya gets: salary and dept_avg sitting side by side on every row. Alice can instantly be compared to her department's mean (185000 vs 152500) without losing the row. That side-by-side shape is the whole point, and it is impossible with a plain GROUP BY.

NULL forms its own partition

Frank's dept_id is NULL, so he lands in a partition by himself. His dept_avg is just his own salary. PARTITION BY treats all NULLs as one group (the same way GROUP BY does), which here means a partition of one.

2
Section 02 · The contrast

PARTITION BY vs GROUP BY

Let us make it razor-sharp. Both clauses carve the data into the same groups using the same columns. The difference is what happens to the rows afterward.

GROUP BY (collapses)

Takes each group and returns one row for it. The detail rows vanish. SELECT dept_id, AVG(salary) gives you 3 rows for 3 departments. You cannot also show an individual name.

PARTITION BY (preserves)

Computes the same per-group value but keeps every original row, stamping the result onto each. 6 employees in, 6 rows out, each carrying its department's average alongside its own salary.

Same partitions, different row count

GROUP BY dept_id and PARTITION BY dept_id build the identical buckets. The only difference: GROUP BY emits one summary row per bucket and throws the detail away; the window keeps every detail row and attaches the summary. Reach for the window whenever you need both the row and a figure computed over its group at the same time.

A practical tell Maya now uses: if your question contains the word "each" pointing at individual records ("show each order with its running total," "rank each employee within their department"), you almost certainly want a window function, not GROUP BY.

3
Section 03 · Ordering within a window

Ranking: ROW_NUMBER, RANK, DENSE_RANK

Once a window has an ORDER BY inside it, you can number the rows. Three functions do this, and they differ only in how they treat ties. Knowing the difference cold is worth the few minutes it takes.

ROW_NUMBER()

Always a unique sequential integer: 1, 2, 3, 4… Ties are broken arbitrarily (whatever order the engine picks). No two rows ever share a number.

RANK()

Ties get the same rank, then the next rank skips. Two rows tied at 1 → next is 3. Leaves gaps, like Olympic medals (two golds, no silver).

DENSE_RANK()

Ties get the same rank, but the next rank is contiguous, no gap. Two rows tied at 1 → next is 2. Dense, no holes in the sequence.

Here is the classic task: rank employees by salary within each department, highest first. The PARTITION BY restarts the numbering for every department; the ORDER BY decides who is rank 1.

rank within department
SELECT name, dept_id, salary,
       ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rn,
       RANK()       OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rnk,
       DENSE_RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS drnk
FROM employees
ORDER BY dept_id, salary DESC;

To see the tie behavior, imagine department 20 had a tie, say Carol and a second employee both earn 135000. This is the difference the three functions produce on that tie:

namesalaryrow_numberrankdense_rank
Carol135000111
Mallory135000211
Dan98000332

Read the bottom row carefully. That is the whole lesson. After a two-way tie: ROW_NUMBER just kept counting (3). RANK skipped 2 and jumped to 3 (the gap). DENSE_RANK stayed contiguous at 2 (no gap).

ROW_NUMBER ties are non-deterministic

Because ROW_NUMBER must produce unique numbers, when two rows tie on the ORDER BY key it breaks the tie arbitrarily, and that choice can change between runs. If you need a stable, reproducible ordering, add a tiebreaker column: ORDER BY salary DESC, emp_id. Never rely on the engine's accidental order.

4
Section 04 · The killer pattern

Top-N-per-group

Maya's next ticket: "Find the highest-paid employee in each department." The clean answer is the canonical window-function pattern: number the rows, then keep the ones you want. But there is a catch that trips people, and understanding it is the real lesson.

highest paid per department (CTE pattern)
WITH ranked AS (
  SELECT name, dept_id, salary,
         ROW_NUMBER() OVER (PARTITION BY dept_id
                            ORDER BY salary DESC) AS rn
  FROM employees
)
SELECT name, dept_id, salary
FROM ranked
WHERE rn = 1;          -- top 1; use rn <= 2 for top 2, etc.

 name  | dept_id | salary
-------+---------+--------
 Alice |      10 | 185000
 Carol |      20 | 135000
 Eve   |      30 | 105000
 Frank |  (null) |  72000

The structure is always the same: compute ROW_NUMBER in an inner query (a CTE or subquery), then filter on that number in an outer query. Change rn = 1 to rn <= 2 and you get the top two per department, and so on.

Why you must wrap it

The obvious-looking shortcut does not work, and here is the exact reason:

this is a syntax error
-- ERROR: window functions are not allowed in WHERE
SELECT name, dept_id, salary
FROM employees
WHERE ROW_NUMBER() OVER (PARTITION BY dept_id
                       ORDER BY salary DESC) = 1;

Recall the logical order of operations from Module 00: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY. Window functions are computed at the SELECT stage (step 5) because they need the rows already filtered and grouped before they can rank them. But WHERE runs at step 2, before the window function exists. You cannot filter on a value that has not been calculated yet.

The wrap is a stage shift

Wrapping the query in a CTE or subquery forces the window function to finish in the inner query's SELECT stage. Its output (rn) becomes an ordinary column of an ordinary result set. The outer query's WHERE then filters that column like any other. You are not working around a quirk, you are respecting the execution order.

ROW_NUMBER vs RANK for ties matters here

If two employees tie for the top salary in a department and you want both, use RANK() ... WHERE rnk = 1 (ties share rank 1, so both survive). ROW_NUMBER would arbitrarily pick just one. Choosing the right ranking function is a deliberate decision, not a coin flip.

5
Section 05 · Reaching across rows

LAG & LEAD

Sometimes a row needs to look at its neighbor. When Maya is asked how each order compares to the one before it, this is the tool. LAG fetches a value from a previous row; LEAD fetches from a following row, both relative to the window's ORDER BY. That makes period-over-period comparisons trivial.

Let us use orders, sorted by order_date. Each row should show the previous order's amount and the change from it.

each order vs the previous order
SELECT order_id, order_date, amount,
       LAG(amount) OVER (ORDER BY order_date) AS prev_amount,
       amount - LAG(amount) OVER (ORDER BY order_date) AS delta
FROM orders
ORDER BY order_date;

 order_id | order_date | amount | prev_amount | delta
----------+------------+--------+-------------+--------
     1001 | 2024-01-05 |    500 |      (null) | (null)   <- no prior row
     1002 | 2024-01-09 |    800 |         500 |    300
     1003 | 2024-01-15 |    300 |         800 |   -300
     1004 | 2024-01-22 |    950 |         300 |    650

The very first row has no predecessor, so LAG returns NULL, and any arithmetic with that NULL is also NULL (recall Module 00's three-valued logic). LEAD(amount) works identically but looks forward, which is handy for "what is the next order" style questions.

Offset and default arguments

Both take two optional extra arguments: LAG(amount, 2, 0) means "go back 2 rows, and if there is no such row, use 0 instead of NULL." The offset defaults to 1 and the fallback defaults to NULL. Supplying a default like 0 is a clean way to avoid NULL-propagation in the very first rows.

You can partition too. LAG(amount) OVER (PARTITION BY emp_id ORDER BY order_date) compares each order to that employee's own previous order. The LAG resets at every employee boundary, never bleeding across partitions.

6
Section 06 · The frame clause

Running totals & frames

An aggregate inside a window with an ORDER BY becomes a running aggregate. A running total of order amounts (the cumulative sum as time moves forward) is the textbook example, and the next thing finance asks Maya for.

running total of order amounts
SELECT order_id, order_date, amount,
       SUM(amount) OVER (ORDER BY order_date
                          ROWS BETWEEN UNBOUNDED PRECEDING
                                   AND CURRENT ROW) AS running_total
FROM orders
ORDER BY order_date;

 order_id | order_date | amount | running_total
----------+------------+--------+---------------
     1001 | 2024-01-05 |    500 |           500
     1002 | 2024-01-09 |    800 |          1300
     1003 | 2024-01-15 |    300 |          1600
     1004 | 2024-01-22 |    950 |          2550

The new piece is the frame clause: ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. It defines exactly which rows in the ordered window contribute to each row's calculation. Here: "every row from the very start of the partition up to and including the current one." So row 3's total covers rows 1–3, giving 1600.

You can build other windows by changing the frame bounds:

UNBOUNDED PRECEDING → CURRENT ROWrunning total (start of partition to here)
2 PRECEDING → CURRENT ROW3-row moving window (this row + 2 before)
1 PRECEDING → 1 FOLLOWINGcentered 3-row window (neighbour each side)
CURRENT ROW → UNBOUNDED FOLLOWINGreverse running total (here to the end)

ROWS vs RANGE, the surprise

The frame can count in two units, and confusing them is a genuine trap. ROWS counts physical rows, "the 2 rows before this one," full stop. RANGE counts by value of the ORDER BY key: it includes all peer rows that share the current row's ordering value, treating them as a single logical block.

The default frame is RANGE, not ROWS

If you write SUM(amount) OVER (ORDER BY order_date) with no explicit frame, PostgreSQL silently applies RANGE UNBOUNDED PRECEDING AND CURRENT ROW. Usually identical to ROWS, until you have ties in the ORDER BY column. With RANGE, two orders on the same date are peers: each one's running total includes both, so they show the same cumulative value. This is the exact bug Maya hits when two orders land on one date. The total "jumps" by both amounts at once. When you mean physical rows, write ROWS explicitly.

A moving average (say, the average of the last three orders) is just a ROWS frame around AVG: AVG(amount) OVER (ORDER BY order_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW). The frame is what turns a static aggregate into a sliding one.

7
Section 07 · More window tools

FIRST_VALUE, LAST_VALUE, NTILE

Beyond ranking and offsets, a few more functions reach into the window to pull out positional values or to slice rows into buckets.

FIRST_VALUE / LAST_VALUE

Return the value from the first or last row of the (ordered) frame. E.g. the top earner's name attached to every row in the department. NTH_VALUE(col, n) grabs the n-th row.

NTILE(n)

Splits the ordered window into n roughly-equal buckets and labels each row 1…n. NTILE(4) gives quartiles, NTILE(100) percentiles. Great for "which salary band is this person in?"

top earner per dept + salary quartile
SELECT name, dept_id, salary,
       FIRST_VALUE(name) OVER (PARTITION BY dept_id
                              ORDER BY salary DESC) AS dept_top_earner,
       NTILE(4) OVER (ORDER BY salary DESC) AS salary_quartile
FROM employees;
The LAST_VALUE default-frame trap

Naively, LAST_VALUE(name) OVER (PARTITION BY dept_id ORDER BY salary DESC) looks like it returns the lowest earner. It does not. The default frame is RANGE ... CURRENT ROW, so the frame ends at the current row, meaning LAST_VALUE just returns the current row's own value, which is useless. To get the true last row of the partition, give it a full frame: LAST_VALUE(name) OVER (PARTITION BY dept_id ORDER BY salary DESC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING). FIRST_VALUE is safe because the default frame already starts at the partition's first row.

For true statistical percentiles, PostgreSQL offers PERCENT_RANK() and CUME_DIST() as window functions, plus the ordered-set aggregates PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY salary) for an interpolated median. NTILE is the quick, good-enough bucketing tool for most reporting.

8
Section 08 · Practice

Hands-on, do this now

Step into Maya's seat. Use the same three-table schema from Module 00, and add a handful of orders rows with varied order_date and amount values (include at least two orders on the same date, since you will want that for the ROWS-vs-RANGE task). Write each query before peeking at any hint.

  • Show every employee with their salary and the company-wide average on the same row, using AVG(...) OVER (). Confirm row count stays at 6.
  • Add a dept_avg column with PARTITION BY dept_id, and a third column salary - dept_avg showing how far each person is above or below their department mean.
  • Produce ROW_NUMBER, RANK, and DENSE_RANK for employees ordered by salary descending (whole company). Then introduce a salary tie and watch the three columns diverge.
  • Use the CTE + ROW_NUMBER pattern to return the highest-paid employee per department. Then change it to the top 2.
  • Try filtering on ROW_NUMBER() ... = 1 directly in WHERE, watch it error, and explain to yourself why (which execution stage).
  • On orders ordered by date, add prev_amount via LAG and delta = amount - prev_amount. Then use LAG(amount, 1, 0) so the first row shows 0 instead of NULL.
  • Build a running total of amount with an explicit ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW frame. Then remove the frame entirely and compare the two same-date rows, observing the RANGE default difference.
  • Compute a 3-order moving average of amount with ROWS BETWEEN 2 PRECEDING AND CURRENT ROW. Then try LAST_VALUE and reproduce the default-frame trap before fixing it with a full frame.
Predict, then run

Before each query executes, say out loud what value you expect in the new column for the first two or three rows. Window functions are where intuition quietly drifts from reality, especially around frames and ties. When the output disagrees with your prediction, that gap is the lesson. Chase it before moving on.