The classroom explanation

Imagine a teacher just handed back a spelling test to a class of six kids. She wants every kid to see two things on their own paper: their own score, and their class's average score — without erasing anyone's name or merging two kids onto one paper.

That's exactly what a window function does. It "looks through a window" at a group of related rows — in this case, everyone in the same class — calculates something across them, and writes the answer next to every original row, instead of collapsing them into a single summary.

Here's the class roster we'll use for every example on this page:

student_idnameclassscore
1Mia5A92
2Leo5A78
3Ana5A85
4Sam5B88
5Zoe5B91
6Kai5B73

Six kids, two classes (5A and 5B), one spelling test.

Why not just use GROUP BY?

GROUP BY can absolutely calculate a class average — but it can only give you one row per class, and it throws away every individual kid's name and score to do it:

group-by.sql
SELECT class, AVG(score) AS class_avg
FROM spelling_test
GROUP BY class;
classclass_avg
5A85.0
5B84.0

Mia, Leo, and Ana are gone. If the teacher wants each kid's own row to also show the class average next to it, GROUP BY can't do that on its own — this is precisely the gap a window function fills.

The OVER() clause, piece by piece

A window function looks like a normal aggregate function (AVG, SUM, COUNT), plus an OVER() clause that tells it which rows count as "the window":

anatomy.sql
AVG(score) OVER (
  PARTITION BY class   -- split into windows: one per class
  ORDER BY student_id  -- optional: order rows within each window
)

Both PARTITION BY and ORDER BY inside OVER() are optional. With neither, the window is the entire result set — every row sees every other row.

PARTITION BY — splitting into windows

PARTITION BY class tells the database: "calculate this separately for each class, and don't let 5A's scores leak into 5B's average." Every kid keeps their own row — they just get their class average attached:

partition-by.sql
SELECT name, class, score,
       AVG(score) OVER (PARTITION BY class) AS class_avg
FROM spelling_test;
nameclassscoreclass_avg
Mia5A9285.0
Leo5A7885.0
Ana5A8585.0
Sam5B8884.0
Zoe5B9184.0
Kai5B7384.0

Six rows in, six rows out — every kid keeps their name and score, and now knows exactly how they compare to their own class's average.

Think of PARTITION BY as GROUP BY's gentler cousin: it creates the same groups, but instead of collapsing them, it just labels every row with its group's answer.

ORDER BY inside OVER() — running totals

Add an ORDER BY inside OVER() and the window function stops looking at "everyone" and starts looking at "everyone so far." This is how running totals work — each row's window is every row from the start up to itself:

running-total.sql
SELECT name, score,
       SUM(score) OVER (ORDER BY student_id) AS running_total
FROM spelling_test;
namescorerunning_total
Mia9292
Leo78170
Ana85255
Sam88343
Zoe91434
Kai73507

Each row adds its own score to the sum of every row before it — like adding coins to a jar one at a time and writing down the new total after each coin.

A few window functions worth knowing

Almost any aggregate function (SUM, AVG, COUNT, MIN, MAX) can be used as a window function just by adding OVER(). On top of those, SQL has functions that only make sense as window functions:

FunctionWhat it does
ROW_NUMBER()Gives every row a unique, sequential number within its window (1, 2, 3, ...).
RANK() / DENSE_RANK()Like ROW_NUMBER, but handles tied values differently. Full comparison coming soon.
LAG(col)Looks at the previous row's value — great for "how much did this change since last time?"
LEAD(col)The opposite of LAG — looks ahead to the next row's value.
FIRST_VALUE(col)Returns the first value in the window, attached to every row in it.

Here's LAG() in action — showing how each kid's score compares to the kid listed right before them:

lag-example.sql
SELECT name, score,
       LAG(score) OVER (ORDER BY student_id) AS previous_score
FROM spelling_test;   -- Leo's previous_score is Mia's 92

Putting it all together

The real power shows up when you combine PARTITION BY and ORDER BY in the same window. Here's every kid ranked within their own class, highest score first — using ROW_NUMBER():

combined.sql
SELECT name, class, score,
       ROW_NUMBER() OVER (
         PARTITION BY class
         ORDER BY score DESC
       ) AS class_rank
FROM spelling_test;
nameclassscoreclass_rank
Mia5A921
Ana5A852
Leo5A783
Zoe5B911
Sam5B882
Kai5B733

The ranking resets to 1 for each new class — that's PARTITION BY at work — and within each class, the highest scorer comes first — that's the ORDER BY. Every kid still has their own row.

Common mistakes

  • Trying to filter a window function in WHERE. WHERE class_rank = 1 fails, because window functions are calculated during SELECT — after WHERE has already run. Wrap the query in a subquery or CTE and filter in the outer query instead. (Full explanation in SQL execution order explained.)
  • Forgetting PARTITION BY. Without it, the window is the whole table — a "running total" without PARTITION BY runs across every row, not per group.
  • Confusing the OVER() ORDER BY with the query's ORDER BY. They're independent. The one inside OVER() controls calculation order; the one at the end of the query controls display order.

Key takeaways

  • A window function attaches a calculated value to every row instead of collapsing rows like GROUP BY does.
  • PARTITION BY splits rows into independent windows — like a class average per class.
  • ORDER BY inside OVER() enables running totals and rankings.
  • Window functions run during SELECT, after WHERE/GROUP BY/HAVING — so they can't be filtered in WHERE directly.
  • Combine PARTITION BY + ORDER BY for "ranked within group" results like class_rank.