Skip to content

Window functions

Chapter 7 10 code blocks 4137 words

If a student learns one advanced SQL topic, make it this one. Window functions are what separate someone who can query a database from someone who can analyse one — running totals, rankings, month-over-month change, "top 3 per category" — and they appear in almost every serious data analytics interview.

Availability: SQLite 3.25+ (2018), PostgreSQL, MySQL 8.0+, SQL Server 2012+. All modern versions support them.

The problem they solve

GROUP BY has one hard limitation: it destroys your rows. Once you group by course, the individual enrollments are gone. But most real questions need both at once:

  • "Show every enrollment and how it compares to its course average."
  • "Rank the students within each course."
  • "Show each month's total and the running total so far."

A window function computes an aggregate over a set of rows related to the current row, while keeping the current row.

GROUP BY — 5 rows in, 2 rows out c1 · 92 c1 · 75 c1 · 58 c2 · 88 c2 · 95 c1 · avg 75.0 c2 · avg 91.5 detail is gone WINDOW — 5 rows in, 5 rows out c1 · 92 c1 · 75 c1 · 58 c2 · 88 c2 · 95 avg 75.0 · rank 1 avg 75.0 · rank 2 avg 75.0 · rank 3 avg 91.5 · rank 2 avg 91.5 · rank 1 detail kept, summary added The one-line difference: GROUP BY course_id → AVG(grade) no GROUP BY → AVG(grade) OVER (PARTITION BY course_id)

The OVER() clause

Any aggregate becomes a window function the moment you add OVER (...). Empty brackets mean "the window is every row in the result".

sql10 lines
SELECT
    enrollment_id,
    grade,
    ROUND(AVG(grade) OVER (), 1)      AS overall_avg,
    grade - AVG(grade) OVER ()        AS diff,
    COUNT(*) OVER ()                  AS total_rows
FROM enrollments
WHERE grade IS NOT NULL
ORDER BY grade DESC
LIMIT 4;
enrollment_idgradeoverall_avgdifftotal_rows
1351007327.0241 826
3221007327.0241 826
5001007327.0241 826
6991007327.0241 826

4 rows

1 ms

enrollment_idgradeoverall_avgdifftotal_rows

1310079.4+20.5714 69579.4+15.5714 19279.4+12.5714 149179.4+11.5714

Fourteen rows kept, plus a summary column on each one. The general shape:

Anatomy of a window functionfunction() OVER ( PARTITION BY col -- split rows into independent groups (optional) ORDER BY col -- order inside each group (optional) ROWS BETWEEN ... -- which rows count as "the window" (optional) )

PARTITION BY

PARTITION BY is "GROUP BY that does not collapse". The function restarts for each partition.

sql9 lines
SELECT
    e.enrollment_id,
    e.course_id,
    e.grade,
    ROUND(AVG(e.grade) OVER (PARTITION BY e.course_id), 1) AS course_avg,
    e.grade - AVG(e.grade) OVER (PARTITION BY e.course_id) AS vs_course
FROM enrollments e
WHERE e.grade IS NOT NULL AND e.course_id IN (1, 2)
ORDER BY e.course_id, e.grade DESC;
enrollment_idcourse_idgradecourse_avgvs_course
2 128110079.220.848
2 377110079.220.848
48319979.219.848
1 45919979.219.848
14219879.218.848
1 28919879.218.848
32119679.216.848
1 69519679.216.848
9619579.215.848
21019579.215.848
19419379.213.848
2 49519379.213.848
1 76919279.212.848
36919179.211.848
1 03819179.211.848

… first 15 of 278 rows

278 rows

1 ms

enrollment_idcourse_idgradecourse_avgvs_course

119268.0+24.0 417568.0+7.0 915868.0−10.0 1514768.0−21.0 13210087.2+12.8 629587.2+7.8 228887.2+0.8 1728387.2−4.2 1027087.2−17.2

Every enrollment is still visible, and each carries its own course's average. Doing this with GROUP BY would require computing the averages separately and joining them back — two queries instead of one clause.

ROW_NUMBER, RANK, DENSE_RANK

Three ranking functions that differ only in how they handle ties. Our enrollment counts per course contain a genuine tie (courses 1 and 2 both have 5), which makes the difference visible:

sql9 lines
WITH per_course AS (
    SELECT course_id, COUNT(*) AS n FROM enrollments GROUP BY course_id
)
SELECT
    course_id, n,
    ROW_NUMBER() OVER (ORDER BY n DESC) AS row_number,
    RANK()       OVER (ORDER BY n DESC) AS rank,
    DENSE_RANK() OVER (ORDER BY n DESC) AS dense_rank
FROM per_course;
course_idnrow_numberrankdense_rank
11200111
1193222
14189333
15188444
13187555
2186666
10179777
3174888
6174988
817010109
4167111110
5167121110
7165131311
9161141412

14 rows

1 ms

course_idnROW_NUMBERRANKDENSE_RANK

15111 25211 54332 33443 43543

FunctionTies getThen itUse it when

ROW_NUMBER()Different numbers (arbitrary order)Never repeats, never skipsYou need exactly one row per group RANK()The same numberSkips the next values (1,1,3)Sports-style ranking: two golds, no silver DENSE_RANK()The same numberNo gaps (1,1,2)"Which distinct level is this?"

:::gotcha ⚠️ ROW_NUMBER on ties is non-deterministic Courses 1 and 2 both have 5 enrollments, so which one gets number 1 is up to the engine and can change between runs. If the order must be stable, add a tie-breaker: ORDER BY n DESC, course_id ASC. Any time ROW_NUMBER() decides which row survives, an unstable sort makes your results irreproducible. :::

Top-N per group

"The best student in each course", "the 3 highest-value orders per customer" — this is the canonical window-function interview question, and there is no clean way to do it without one.

The pattern is always two steps: number the rows inside each partition, then keep the numbers you want.

sql18 lines
WITH ranked AS (
    SELECT
        e.course_id,
        s.first_name,
        e.grade,
        ROW_NUMBER() OVER (
            PARTITION BY e.course_id
            ORDER BY e.grade DESC
        ) AS rn
    FROM enrollments e
    JOIN students s ON s.student_id = e.student_id
    WHERE e.grade IS NOT NULL
)
SELECT c.title, r.first_name, r.grade
FROM ranked r
JOIN courses c ON c.course_id = r.course_id
WHERE r.rn = 1                       -- change to rn <= 3 for a top-3
ORDER BY r.grade DESC;
titlefirst_namegrade
Python BasicsGulnora100
Data VisualizationKamola100
Excel for BusinessNilufar100
Project ManagementOtabek100
Graphic DesignRustam100
Financial AnalysisMadina100
UX DesignRustam97
Java BasicsNilufar97
SQL for AnalyticsGulnora96
Machine LearningBekzod94
Web DevelopmentRustam94
Linear AlgebraJasur92
ProbabilityRustam91
Statistics 101Aziza86

14 rows

2 ms

:::gotcha ⚠️ Why the CTE is mandatory here You cannot write WHERE ROW_NUMBER() OVER (...) = 1. Window functions are evaluated at the SELECT step, which is after WHERE (Chapter 1's execution order). The window function does not exist yet when WHERE runs. Compute it in a CTE or derived table, then filter in the outer query. Students hit this error constantly, and the execution-order diagram is the explanation. :::

💡 ROW_NUMBER or RANK for top-N?

If two students tie for the best grade, ROW_NUMBER returns exactly one of them (arbitrarily) and RANK returns both. Neither is "correct" — it depends on whether the business wants one winner or all tied winners. Ask, then choose deliberately.

LAG and LEAD

LAG(col, n) reaches back n rows; LEAD(col, n) reaches forward. Both default to 1. This is how you compute change over time — and it replaces the awkward self-join people used before window functions existed.

sql13 lines
WITH monthly AS (
    SELECT strftime('%Y-%m', enrolled_at) AS month, COUNT(*) AS n
    FROM enrollments GROUP BY month
)
SELECT
    month,
    n,
    LAG(n)  OVER (ORDER BY month)                    AS prev_month,
    n - LAG(n) OVER (ORDER BY month)                 AS change,
    ROUND(100.0 * (n - LAG(n) OVER (ORDER BY month))
          / LAG(n) OVER (ORDER BY month), 1)         AS pct_change
FROM monthly
ORDER BY month;
monthnprev_monthchangepct_change
2025-021 077NaNNaNNaN
2025-031311 077-946-87.8
2025-091 1881311 057806.9
2025-101041 188-1 084-91.2

4 rows

1 ms

monthnprev_monthchangepct_change

2025-096NULLNULLNULL 2026-02126+6+100.0 2026-09212−10−83.3

The first row is NULL because there is no previous month — correct behaviour, not an error. Supply a default if a number is required: LAG(n, 1, 0).

⚠️ Only existing rows can be "previous"

LAG steps back one row, not one month. Because the months with zero enrollments produce no rows, the "previous month" for 2026-02 is September 2025 — five months earlier. This is exactly why Chapter 6's generated date series matters: build the complete series first, then LAG over it. Getting this wrong quietly turns a month-over-month report into a nonsense report.

Running totals and frames

Adding ORDER BY inside OVER() changes the meaning fundamentally: the window becomes "all rows from the start up to the current row" instead of "all rows". That is a running total.

sql11 lines
WITH monthly AS (
    SELECT strftime('%Y-%m', enrolled_at) AS month, COUNT(*) AS n
    FROM enrollments GROUP BY month
)
SELECT
    month, n,
    SUM(n) OVER (ORDER BY month)                     AS running_total,
    SUM(n) OVER ()                                   AS grand_total,
    ROUND(100.0 * SUM(n) OVER (ORDER BY month)
                / SUM(n) OVER (), 1)                 AS pct_of_total
FROM monthly ORDER BY month;
monthnrunning_totalgrand_totalpct_of_total
2025-021 0771 0772 50043.1
2025-031311 2082 50048.3
2025-091 1882 3962 50095.8
2025-101042 5002 500100

4 rows

1 ms

monthnrunning_totalgrand_totalpct_of_total

2025-09662030.0 2026-0212182090.0 2026-0922020100.0

Note the same function, SUM(n), gives a running total with ORDER BY and a grand total without it. Both in one query.

Frames

The frame spells out which rows the window covers. When you write ORDER BY and no frame, the default is:

The implicit default frameRANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
Frame Covers
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW Start → here (running total)
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW The last 3 rows (moving average)
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING Neighbours on both sides (smoothing)
ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING Here → end (remaining total)
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING Everything (same as no ORDER BY)
⚠️ ROWS vs RANGE with duplicate ORDER BY values

ROWS counts physical rows. RANGE groups rows with the same ORDER BY value into one step. With duplicate dates, RANGE includes all rows sharing that date while ROWS stops mid-day — so a "daily running total" built with ROWS can cut a day in half. Prefer ROWS for moving windows over a fixed number of rows, and RANGE when the ORDER BY value is what defines the group.

Moving averages

A moving (rolling) average smooths a noisy series. Here it is over the grades of one course, in enrollment order:

sql10 lines
SELECT
    e.enrolled_at,
    e.grade,
    ROUND(AVG(e.grade) OVER (
        ORDER BY e.enrolled_at
        ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
    ), 2) AS moving_avg_3
FROM enrollments e
WHERE e.course_id = 2 AND e.grade IS NOT NULL
ORDER BY e.enrolled_at;
enrolled_atgrademoving_avg_3
2025-02-038585
2025-02-048384
2025-02-057280
2025-02-056774
2025-02-059076.33
2025-02-068079
2025-02-089488
2025-02-088987.67
2025-02-087385.33
2025-02-087779.67
2025-02-087575
2025-02-087676
2025-02-097374.67
2025-02-099079.67
2025-02-106977.33

… first 15 of 146 rows

146 rows

1 ms

enrolled_atgrademoving_avg_3

2025-09-058888.00 2025-09-189591.50 2026-02-047084.33 2026-02-0810088.33 2026-02-148384.33

The first two rows average over fewer than three values, because there are not three rows yet — the window shrinks at the edges rather than returning NULL. If a report must only show full windows, filter on COUNT(*) OVER (same frame) = 3.

NTILE and PERCENT_RANK

NTILE(n) splits ordered rows into n roughly equal buckets — quartiles, deciles, customer segments.

sql7 lines
SELECT
    enrollment_id, grade,
    NTILE(4)      OVER (ORDER BY grade DESC) AS quartile,
    ROUND(PERCENT_RANK() OVER (ORDER BY grade DESC), 2) AS pct_rank
FROM enrollments
WHERE grade IS NOT NULL
ORDER BY grade DESC;
enrollment_idgradequartilepct_rank
13510010
32210010
50010010
69910010
81410010
1 09610010
1 20210010
1 83210010
1 86210010
2 12610010
2 12810010
2 16110010
2 37710010
2 42410010
4839910.01

… first 15 of 1826 rows

1826 rows

3 ms

💡 Where this is used in practice

NTILE is how customer segmentation is built — RFM analysis scores customers 1–5 on recency, frequency and monetary value using NTILE(5), then concatenates the three scores. It is one of the most common real analytics tasks that is pure SQL.

Window vs GROUP BY

GROUP BY Window function
Rows out One per group Same as rows in
Detail rows Destroyed Kept
Can see neighbouring rows No Yes (LAG, LEAD, frames)
Filter the result with HAVING A CTE + WHERE
Ranking Not possible ROW_NUMBER, RANK
Runs at step 3 5 (with SELECT)

They combine happily — a window function can operate on the output of a GROUP BY in the same query:

sql9 lines
SELECT
    course_id,
    COUNT(*)                                   AS enrollments,
    SUM(COUNT(*)) OVER ()                      AS total_enrollments,
    ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct_of_all,
    RANK() OVER (ORDER BY COUNT(*) DESC)       AS popularity_rank
FROM enrollments
GROUP BY course_id
ORDER BY enrollments DESC;
course_idenrollmentstotal_enrollmentspct_of_allpopularity_rank
112002 50081
11932 5007.72
141892 5007.63
151882 5007.54
131872 5007.55
21862 5007.46
101792 5007.27
61742 50078
31742 50078
81702 5006.810
51672 5006.711
41672 5006.711
71652 5006.613
91612 5006.414

14 rows

1 ms

course_idenrollmentstotal_enrollmentspct_of_allpopularity_rank

152025.01 252025.01 542020.03 332015.04 432015.04

SUM(COUNT(*)) OVER () looks strange but follows directly from the execution order: COUNT(*) is computed at the grouping step, and the window function then runs over those grouped rows. "Each row's share of the total" is one of the most-requested report columns there is, and this is the one-query way to produce it.

Practice set

python48 lines
-- 1. Every enrollment with its course average and the difference
SELECT enrollment_id, course_id, grade,
       ROUND(AVG(grade) OVER (PARTITION BY course_id), 1) AS course_avg
FROM enrollments WHERE grade IS NOT NULL ORDER BY course_id, grade DESC;

-- 2. Rank students by overall average grade
WITH avg_per_student AS (
    SELECT student_id, AVG(grade) AS avg_grade
    FROM enrollments WHERE grade IS NOT NULL GROUP BY student_id
)
SELECT s.first_name, ROUND(a.avg_grade,1) AS avg_grade,
       RANK() OVER (ORDER BY a.avg_grade DESC) AS rank
FROM avg_per_student a JOIN students s USING (student_id);

-- 3. Best grade in each course (top-1 per group)
WITH r AS (
    SELECT course_id, student_id, grade,
           ROW_NUMBER() OVER (PARTITION BY course_id ORDER BY grade DESC) AS rn
    FROM enrollments WHERE grade IS NOT NULL
)
SELECT * FROM r WHERE rn = 1;

-- 4. Each student's enrollments numbered in date order
SELECT student_id, enrolled_at, course_id,
       ROW_NUMBER() OVER (PARTITION BY student_id ORDER BY enrolled_at) AS nth_course
FROM enrollments ORDER BY student_id, enrolled_at;

-- 5. Days between a student's consecutive enrollments
SELECT student_id, enrolled_at,
       LAG(enrolled_at) OVER (PARTITION BY student_id ORDER BY enrolled_at) AS prev,
       JULIANDAY(enrolled_at)
         - JULIANDAY(LAG(enrolled_at) OVER (PARTITION BY student_id ORDER BY enrolled_at)) AS days_gap
FROM enrollments ORDER BY student_id, enrolled_at;

-- 6. Running total of enrollments over time
SELECT enrolled_at, COUNT(*) AS n,
       SUM(COUNT(*)) OVER (ORDER BY enrolled_at) AS running_total
FROM enrollments GROUP BY enrolled_at ORDER BY enrolled_at;

-- 7. Split grades into quartiles
SELECT enrollment_id, grade, NTILE(4) OVER (ORDER BY grade DESC) AS quartile
FROM enrollments WHERE grade IS NOT NULL;

-- 8. Each course's share of total revenue
SELECT c.title, SUM(c.price) AS revenue,
       ROUND(100.0 * SUM(c.price) / SUM(SUM(c.price)) OVER (), 1) AS pct
FROM enrollments e JOIN courses c USING (course_id)
GROUP BY c.course_id, c.title ORDER BY revenue DESC;

Result 1

enrollment_idcourse_idgradecourse_avg
2 128110079.2
2 377110079.2
48319979.2
1 45919979.2
14219879.2
1 28919879.2
32119679.2
1 69519679.2
9619579.2
21019579.2
19419379.2
2 49519379.2
1 76919279.2
36919179.2
1 03819179.2

… first 15 of 1826 rows

1826 rows

Result 2

first_nameavg_graderank
Otabek87.31
Shahnoza86.32
Akmal843
Zulfiya843
Bekzod82.85
Aziz82.56
Shahnoza82.37
Doston82.28
Rustam81.89
Gulnora81.610
Ulug'bek8111
Nilufar8111
Aziz80.813
Malika80.814
Bekzod80.315

… first 15 of 300 rows

300 rows

Result 3

course_idstudent_idgradern
1241001
224961
3232861
41291001
5136941
6206941
72471001
81971001
9195971
10411001
11202921
13222971
141781001
15206911

14 rows

Result 4

student_idenrolled_atcourse_idnth_course
12025-09-0681
12025-09-07132
12025-09-0933
12025-09-13114
12025-09-1335
12025-09-17116
12025-09-26107
22025-02-1321
22025-02-14102
22025-02-15133
22025-02-19114
22025-02-2535
22025-02-2766
22025-03-0277
22025-03-09118

… first 15 of 2500 rows

2500 rows

Result 5

student_idenrolled_atprevdays_gap
12025-09-06NoneNaN
12025-09-072025-09-061
12025-09-092025-09-072
12025-09-132025-09-094
12025-09-132025-09-130
12025-09-172025-09-134
12025-09-262025-09-179
22025-02-13NoneNaN
22025-02-142025-02-131
22025-02-152025-02-141
22025-02-192025-02-154
22025-02-252025-02-196
22025-02-272025-02-252
22025-03-022025-02-273
22025-03-092025-03-027

… first 15 of 2500 rows

2500 rows

Result 6

enrolled_atnrunning_total
2025-02-0366
2025-02-041925
2025-02-053560
2025-02-063090
2025-02-0742132
2025-02-0856188
2025-02-0958246
2025-02-1051297
2025-02-1155352
2025-02-1260412
2025-02-1364476
2025-02-1459535
2025-02-1555590
2025-02-1653643
2025-02-1751694

… first 15 of 94 rows

94 rows

Result 7

enrollment_idgradequartile
1351001
3221001
5001001
6991001
8141001
1 0961001
1 2021001
1 8321001
1 8621001
2 1261001
2 1281001
2 1611001
2 3771001
2 4241001
483991

… first 15 of 1826 rows

1826 rows

Result 8

titlerevenuepct
Financial Analysis283 500 0009.8
Project Management255 000 0008.8
Machine Learning242 150 0008.4
Linear Algebra240 000 0008.3
Probability235 000 0008.1
Python Basics231 600 0008
Java Basics215 050 0007.4
Statistics 101208 800 0007.2
Web Development200 100 0006.9
Excel for Business181 500 0006.3
Graphic Design170 050 0005.9
Data Visualization150 300 0005.2
SQL for Analytics148 800 0005.1
UX Design136 850 0004.7

14 rows

16 ms

Interview questions

Q: What is a window function and how does it differ from an aggregate?

Both compute over a set of rows, but an aggregate with GROUP BY collapses those rows into one, whereas a window function returns a value for every row and keeps them all.

Q: ROW_NUMBER vs RANK vs DENSE_RANK?

On ties: ROW_NUMBER assigns different consecutive numbers; RANK assigns the same number and then skips (1,1,3); DENSE_RANK assigns the same number with no gap (1,1,2).

Q: How do you get the top 3 rows per group?

ROW_NUMBER() OVER (PARTITION BY group_col ORDER BY sort_col DESC) in a CTE, then WHERE rn <= 3 in the outer query.

Q: Why can't you filter on a window function in WHERE?

Because window functions are evaluated with SELECT (step 5), after WHERE (step 2). The value does not exist yet. Wrap the query in a CTE or derived table and filter outside.

Q: What does ORDER BY inside OVER() change?

It orders rows within the partition and, for aggregates, switches the default frame to "from the start of the partition to the current row" — turning SUM into a running total.

Q: How do you compute month-over-month growth?

(n - LAG(n) OVER (ORDER BY month)) * 100.0 / LAG(n) OVER (ORDER BY month). Make sure every month exists as a row first, or LAG will step to the wrong period.

Q: ROWS vs RANGE in a frame?

ROWS counts physical rows; RANGE treats all rows with the same ORDER BY value as one unit. They differ only when there are duplicate ordering values.

Q: What is SUM(COUNT(*)) OVER () doing?

The inner COUNT(*) aggregates per group; the window function then sums those group totals across all groups — giving each group's share of the overall total in a single query.

Summary

  • A window function computes over related rows without collapsing them — the detail stays, the summary is added as a column.
  • OVER () = all rows · OVER (PARTITION BY x) = restart per group · OVER (ORDER BY x) = running / cumulative.
  • Ranking: ROW_NUMBER (always unique), RANK (ties share, then skip), DENSE_RANK (ties share, no gaps). Add a tie-breaker to keep ROW_NUMBER deterministic.
  • Top-N per group = ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) in a CTE, then filter. This is the classic interview task.
  • You cannot filter a window function in WHERE — it is computed at the SELECT step. Use a CTE.
  • LAG/LEAD reach to neighbouring rows — generate a complete date series first, or period-over-period maths silently compares the wrong periods.
  • Frames (ROWS BETWEEN ...) define the window: running totals, moving averages, look-ahead sums.
  • NTILE(n) buckets rows into segments (quartiles, RFM scores); PERCENT_RANK gives a 0–1 relative position.
  • Window functions can sit on top of a GROUP BY — SUM(COUNT(*)) OVER () gives each group's share of the total.

Next: Chapter 8 — string and date functions plus CASE WHEN, the tools for reshaping and categorising values inside a query.

Questions about this chapter?

The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.

Write on Telegram Contents