Window functions
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.
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".
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_id | grade | overall_avg | diff | total_rows |
|---|---|---|---|---|
| 135 | 100 | 73 | 27.024 | 1 826 |
| 322 | 100 | 73 | 27.024 | 1 826 |
| 500 | 100 | 73 | 27.024 | 1 826 |
| 699 | 100 | 73 | 27.024 | 1 826 |
4 rows
1 ms
| enrollment_id | grade | overall_avg | diff | total_rows |
|---|---|---|---|---|
| 13 | 100 | 79.4 | +20.57 | 14 |
| 6 | 95 | 79.4 | +15.57 | 14 |
| 1 | 92 | 79.4 | +12.57 | 14 |
| 14 | 91 | 79.4 | +11.57 | 14 |
Fourteen rows kept, plus a summary column on each one. The general shape:
PARTITION BY
PARTITION BY is "GROUP BY that does not collapse". The function restarts for each partition.
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_id | course_id | grade | course_avg | vs_course |
|---|---|---|---|---|
| 2 128 | 1 | 100 | 79.2 | 20.848 |
| 2 377 | 1 | 100 | 79.2 | 20.848 |
| 483 | 1 | 99 | 79.2 | 19.848 |
| 1 459 | 1 | 99 | 79.2 | 19.848 |
| 142 | 1 | 98 | 79.2 | 18.848 |
| 1 289 | 1 | 98 | 79.2 | 18.848 |
| 321 | 1 | 96 | 79.2 | 16.848 |
| 1 695 | 1 | 96 | 79.2 | 16.848 |
| 96 | 1 | 95 | 79.2 | 15.848 |
| 210 | 1 | 95 | 79.2 | 15.848 |
| 194 | 1 | 93 | 79.2 | 13.848 |
| 2 495 | 1 | 93 | 79.2 | 13.848 |
| 1 769 | 1 | 92 | 79.2 | 12.848 |
| 369 | 1 | 91 | 79.2 | 11.848 |
| 1 038 | 1 | 91 | 79.2 | 11.848 |
… first 15 of 278 rows
278 rows
1 ms
| enrollment_id | course_id | grade | course_avg | vs_course |
|---|---|---|---|---|
| 1 | 1 | 92 | 68.0 | +24.0 |
| 4 | 1 | 75 | 68.0 | +7.0 |
| 9 | 1 | 58 | 68.0 | −10.0 |
| 15 | 1 | 47 | 68.0 | −21.0 |
| 13 | 2 | 100 | 87.2 | +12.8 |
| 6 | 2 | 95 | 87.2 | +7.8 |
| 2 | 2 | 88 | 87.2 | +0.8 |
| 17 | 2 | 83 | 87.2 | −4.2 |
| 10 | 2 | 70 | 87.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:
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_id | n | row_number | rank | dense_rank |
|---|---|---|---|---|
| 11 | 200 | 1 | 1 | 1 |
| 1 | 193 | 2 | 2 | 2 |
| 14 | 189 | 3 | 3 | 3 |
| 15 | 188 | 4 | 4 | 4 |
| 13 | 187 | 5 | 5 | 5 |
| 2 | 186 | 6 | 6 | 6 |
| 10 | 179 | 7 | 7 | 7 |
| 3 | 174 | 8 | 8 | 8 |
| 6 | 174 | 9 | 8 | 8 |
| 8 | 170 | 10 | 10 | 9 |
| 4 | 167 | 11 | 11 | 10 |
| 5 | 167 | 12 | 11 | 10 |
| 7 | 165 | 13 | 13 | 11 |
| 9 | 161 | 14 | 14 | 12 |
14 rows
1 ms
| course_id | n | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|---|
| 1 | 5 | 1 | 1 | 1 |
| 2 | 5 | 2 | 1 | 1 |
| 5 | 4 | 3 | 3 | 2 |
| 3 | 3 | 4 | 4 | 3 |
| 4 | 3 | 5 | 4 | 3 |
| Function | Ties get | Then it | Use it when |
|---|---|---|---|
ROW_NUMBER() |
Different numbers (arbitrary order) | Never repeats, never skips | You need exactly one row per group |
RANK() |
The same number | Skips the next values (1,1,3) | Sports-style ranking: two golds, no silver |
DENSE_RANK() |
The same number | No gaps (1,1,2) | "Which distinct level is this?" |
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.
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;
| title | first_name | grade |
|---|---|---|
| Python Basics | Gulnora | 100 |
| Data Visualization | Kamola | 100 |
| Excel for Business | Nilufar | 100 |
| Project Management | Otabek | 100 |
| Graphic Design | Rustam | 100 |
| Financial Analysis | Madina | 100 |
| UX Design | Rustam | 97 |
| Java Basics | Nilufar | 97 |
| SQL for Analytics | Gulnora | 96 |
| Machine Learning | Bekzod | 94 |
| Web Development | Rustam | 94 |
| Linear Algebra | Jasur | 92 |
| Probability | Rustam | 91 |
| Statistics 101 | Aziza | 86 |
14 rows
2 ms
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.
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.
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;
| month | n | prev_month | change | pct_change |
|---|---|---|---|---|
| 2025-02 | 1 077 | NaN | NaN | NaN |
| 2025-03 | 131 | 1 077 | -946 | -87.8 |
| 2025-09 | 1 188 | 131 | 1 057 | 806.9 |
| 2025-10 | 104 | 1 188 | -1 084 | -91.2 |
4 rows
1 ms
| month | n | prev_month | change | pct_change |
|---|---|---|---|---|
| 2025-09 | 6 | NULL | NULL | NULL |
| 2026-02 | 12 | 6 | +6 | +100.0 |
| 2026-09 | 2 | 12 | −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).
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.
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;
| month | n | running_total | grand_total | pct_of_total |
|---|---|---|---|---|
| 2025-02 | 1 077 | 1 077 | 2 500 | 43.1 |
| 2025-03 | 131 | 1 208 | 2 500 | 48.3 |
| 2025-09 | 1 188 | 2 396 | 2 500 | 95.8 |
| 2025-10 | 104 | 2 500 | 2 500 | 100 |
4 rows
1 ms
| month | n | running_total | grand_total | pct_of_total |
|---|---|---|---|---|
| 2025-09 | 6 | 6 | 20 | 30.0 |
| 2026-02 | 12 | 18 | 20 | 90.0 |
| 2026-09 | 2 | 20 | 20 | 100.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:
| 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 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:
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_at | grade | moving_avg_3 |
|---|---|---|
| 2025-02-03 | 85 | 85 |
| 2025-02-04 | 83 | 84 |
| 2025-02-05 | 72 | 80 |
| 2025-02-05 | 67 | 74 |
| 2025-02-05 | 90 | 76.33 |
| 2025-02-06 | 80 | 79 |
| 2025-02-08 | 94 | 88 |
| 2025-02-08 | 89 | 87.67 |
| 2025-02-08 | 73 | 85.33 |
| 2025-02-08 | 77 | 79.67 |
| 2025-02-08 | 75 | 75 |
| 2025-02-08 | 76 | 76 |
| 2025-02-09 | 73 | 74.67 |
| 2025-02-09 | 90 | 79.67 |
| 2025-02-10 | 69 | 77.33 |
… first 15 of 146 rows
146 rows
1 ms
| enrolled_at | grade | moving_avg_3 |
|---|---|---|
| 2025-09-05 | 88 | 88.00 |
| 2025-09-18 | 95 | 91.50 |
| 2026-02-04 | 70 | 84.33 |
| 2026-02-08 | 100 | 88.33 |
| 2026-02-14 | 83 | 84.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.
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_id | grade | quartile | pct_rank |
|---|---|---|---|
| 135 | 100 | 1 | 0 |
| 322 | 100 | 1 | 0 |
| 500 | 100 | 1 | 0 |
| 699 | 100 | 1 | 0 |
| 814 | 100 | 1 | 0 |
| 1 096 | 100 | 1 | 0 |
| 1 202 | 100 | 1 | 0 |
| 1 832 | 100 | 1 | 0 |
| 1 862 | 100 | 1 | 0 |
| 2 126 | 100 | 1 | 0 |
| 2 128 | 100 | 1 | 0 |
| 2 161 | 100 | 1 | 0 |
| 2 377 | 100 | 1 | 0 |
| 2 424 | 100 | 1 | 0 |
| 483 | 99 | 1 | 0.01 |
… first 15 of 1826 rows
1826 rows
3 ms
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:
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_id | enrollments | total_enrollments | pct_of_all | popularity_rank |
|---|---|---|---|---|
| 11 | 200 | 2 500 | 8 | 1 |
| 1 | 193 | 2 500 | 7.7 | 2 |
| 14 | 189 | 2 500 | 7.6 | 3 |
| 15 | 188 | 2 500 | 7.5 | 4 |
| 13 | 187 | 2 500 | 7.5 | 5 |
| 2 | 186 | 2 500 | 7.4 | 6 |
| 10 | 179 | 2 500 | 7.2 | 7 |
| 6 | 174 | 2 500 | 7 | 8 |
| 3 | 174 | 2 500 | 7 | 8 |
| 8 | 170 | 2 500 | 6.8 | 10 |
| 5 | 167 | 2 500 | 6.7 | 11 |
| 4 | 167 | 2 500 | 6.7 | 11 |
| 7 | 165 | 2 500 | 6.6 | 13 |
| 9 | 161 | 2 500 | 6.4 | 14 |
14 rows
1 ms
| course_id | enrollments | total_enrollments | pct_of_all | popularity_rank |
|---|---|---|---|---|
| 1 | 5 | 20 | 25.0 | 1 |
| 2 | 5 | 20 | 25.0 | 1 |
| 5 | 4 | 20 | 20.0 | 3 |
| 3 | 3 | 20 | 15.0 | 4 |
| 4 | 3 | 20 | 15.0 | 4 |
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
-- 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_id | course_id | grade | course_avg |
|---|---|---|---|
| 2 128 | 1 | 100 | 79.2 |
| 2 377 | 1 | 100 | 79.2 |
| 483 | 1 | 99 | 79.2 |
| 1 459 | 1 | 99 | 79.2 |
| 142 | 1 | 98 | 79.2 |
| 1 289 | 1 | 98 | 79.2 |
| 321 | 1 | 96 | 79.2 |
| 1 695 | 1 | 96 | 79.2 |
| 96 | 1 | 95 | 79.2 |
| 210 | 1 | 95 | 79.2 |
| 194 | 1 | 93 | 79.2 |
| 2 495 | 1 | 93 | 79.2 |
| 1 769 | 1 | 92 | 79.2 |
| 369 | 1 | 91 | 79.2 |
| 1 038 | 1 | 91 | 79.2 |
… first 15 of 1826 rows
1826 rows
Result 2
| first_name | avg_grade | rank |
|---|---|---|
| Otabek | 87.3 | 1 |
| Shahnoza | 86.3 | 2 |
| Akmal | 84 | 3 |
| Zulfiya | 84 | 3 |
| Bekzod | 82.8 | 5 |
| Aziz | 82.5 | 6 |
| Shahnoza | 82.3 | 7 |
| Doston | 82.2 | 8 |
| Rustam | 81.8 | 9 |
| Gulnora | 81.6 | 10 |
| Ulug'bek | 81 | 11 |
| Nilufar | 81 | 11 |
| Aziz | 80.8 | 13 |
| Malika | 80.8 | 14 |
| Bekzod | 80.3 | 15 |
… first 15 of 300 rows
300 rows
Result 3
| course_id | student_id | grade | rn |
|---|---|---|---|
| 1 | 24 | 100 | 1 |
| 2 | 24 | 96 | 1 |
| 3 | 232 | 86 | 1 |
| 4 | 129 | 100 | 1 |
| 5 | 136 | 94 | 1 |
| 6 | 206 | 94 | 1 |
| 7 | 247 | 100 | 1 |
| 8 | 197 | 100 | 1 |
| 9 | 195 | 97 | 1 |
| 10 | 41 | 100 | 1 |
| 11 | 202 | 92 | 1 |
| 13 | 222 | 97 | 1 |
| 14 | 178 | 100 | 1 |
| 15 | 206 | 91 | 1 |
14 rows
Result 4
| student_id | enrolled_at | course_id | nth_course |
|---|---|---|---|
| 1 | 2025-09-06 | 8 | 1 |
| 1 | 2025-09-07 | 13 | 2 |
| 1 | 2025-09-09 | 3 | 3 |
| 1 | 2025-09-13 | 11 | 4 |
| 1 | 2025-09-13 | 3 | 5 |
| 1 | 2025-09-17 | 11 | 6 |
| 1 | 2025-09-26 | 10 | 7 |
| 2 | 2025-02-13 | 2 | 1 |
| 2 | 2025-02-14 | 10 | 2 |
| 2 | 2025-02-15 | 13 | 3 |
| 2 | 2025-02-19 | 11 | 4 |
| 2 | 2025-02-25 | 3 | 5 |
| 2 | 2025-02-27 | 6 | 6 |
| 2 | 2025-03-02 | 7 | 7 |
| 2 | 2025-03-09 | 11 | 8 |
… first 15 of 2500 rows
2500 rows
Result 5
| student_id | enrolled_at | prev | days_gap |
|---|---|---|---|
| 1 | 2025-09-06 | None | NaN |
| 1 | 2025-09-07 | 2025-09-06 | 1 |
| 1 | 2025-09-09 | 2025-09-07 | 2 |
| 1 | 2025-09-13 | 2025-09-09 | 4 |
| 1 | 2025-09-13 | 2025-09-13 | 0 |
| 1 | 2025-09-17 | 2025-09-13 | 4 |
| 1 | 2025-09-26 | 2025-09-17 | 9 |
| 2 | 2025-02-13 | None | NaN |
| 2 | 2025-02-14 | 2025-02-13 | 1 |
| 2 | 2025-02-15 | 2025-02-14 | 1 |
| 2 | 2025-02-19 | 2025-02-15 | 4 |
| 2 | 2025-02-25 | 2025-02-19 | 6 |
| 2 | 2025-02-27 | 2025-02-25 | 2 |
| 2 | 2025-03-02 | 2025-02-27 | 3 |
| 2 | 2025-03-09 | 2025-03-02 | 7 |
… first 15 of 2500 rows
2500 rows
Result 6
| enrolled_at | n | running_total |
|---|---|---|
| 2025-02-03 | 6 | 6 |
| 2025-02-04 | 19 | 25 |
| 2025-02-05 | 35 | 60 |
| 2025-02-06 | 30 | 90 |
| 2025-02-07 | 42 | 132 |
| 2025-02-08 | 56 | 188 |
| 2025-02-09 | 58 | 246 |
| 2025-02-10 | 51 | 297 |
| 2025-02-11 | 55 | 352 |
| 2025-02-12 | 60 | 412 |
| 2025-02-13 | 64 | 476 |
| 2025-02-14 | 59 | 535 |
| 2025-02-15 | 55 | 590 |
| 2025-02-16 | 53 | 643 |
| 2025-02-17 | 51 | 694 |
… first 15 of 94 rows
94 rows
Result 7
| enrollment_id | grade | quartile |
|---|---|---|
| 135 | 100 | 1 |
| 322 | 100 | 1 |
| 500 | 100 | 1 |
| 699 | 100 | 1 |
| 814 | 100 | 1 |
| 1 096 | 100 | 1 |
| 1 202 | 100 | 1 |
| 1 832 | 100 | 1 |
| 1 862 | 100 | 1 |
| 2 126 | 100 | 1 |
| 2 128 | 100 | 1 |
| 2 161 | 100 | 1 |
| 2 377 | 100 | 1 |
| 2 424 | 100 | 1 |
| 483 | 99 | 1 |
… first 15 of 1826 rows
1826 rows
Result 8
| title | revenue | pct |
|---|---|---|
| Financial Analysis | 283 500 000 | 9.8 |
| Project Management | 255 000 000 | 8.8 |
| Machine Learning | 242 150 000 | 8.4 |
| Linear Algebra | 240 000 000 | 8.3 |
| Probability | 235 000 000 | 8.1 |
| Python Basics | 231 600 000 | 8 |
| Java Basics | 215 050 000 | 7.4 |
| Statistics 101 | 208 800 000 | 7.2 |
| Web Development | 200 100 000 | 6.9 |
| Excel for Business | 181 500 000 | 6.3 |
| Graphic Design | 170 050 000 | 5.9 |
| Data Visualization | 150 300 000 | 5.2 |
| SQL for Analytics | 148 800 000 | 5.1 |
| UX Design | 136 850 000 | 4.7 |
14 rows
15 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 keepROW_NUMBERdeterministic. - 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 theSELECTstep. Use a CTE. LAG/LEADreach 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_RANKgives 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.