In 04-05 we left a sentence hanging: the top N per group and "any computation that has to aggregate without collapsing the rows" need window functions. This is the problem. SUM(amount) over GreenStore's 47 lines returns one row with €727.95; but what you almost always want is the 47 lines, each with its amount and, next to it, the total — so you can work out the percentage it represents, its position in a ranking or how much you've accumulated so far.
A GROUP BY can't do it: it collapses exactly what you want to keep. The OVER clause can, and it's the difference between knowing SQL and knowing how to use it. In this lesson you'll see the anatomy of OVER (PARTITION BY ... ORDER BY ... frame), where a window function runs and why that makes it impossible to filter on it in the WHERE —the single most frequent mistake there is—, the three families with their reference table, the frame and the classic LAST_VALUE trap, and six real GreenStore cases: monthly running total, moving average, change against the previous month, top N per category, customer ranking and each product against its category's average.
Contents
- The core idea: aggregate without collapsing
- The anatomy of
OVER - Where it runs: the
WHEREmistake - Ranking:
ROW_NUMBER,RANK,DENSE_RANK,NTILE,PERCENT_RANK - Offset:
LAG,LEAD,FIRST_VALUE,LAST_VALUE,NTH_VALUE - Aggregates as windows, and the frame:
ROWSversusRANGE WINDOW: giving a window a name- The GreenStore cases
- Performance and dialect
- Common Mistakes and Tips
- Exercises
- Conclusion
- The core idea: aggregate without collapsing
A window function computes a value for each row from a set of rows related to it —its window— without reducing the number of rows in the result.
The smallest possible form is OVER (), the empty window: "every row in the result".
-- We use the v_sales_detail view from 10-01, which already carries the `amount` column
SELECT line_id, product, amount,
SUM(amount) OVER () AS grand_total,
ROUND(100 * amount / SUM(amount) OVER (), 2) AS pct
FROM v_sales_detail
ORDER BY line_id;| line_id | product | amount | grand_total | pct |
|---|---|---|---|---|
| 1 | Extra virgin olive oil 500 ml | 23.90 | 727.95 | 3.28 |
| 2 | Organic brown rice 1 kg | 11.70 | 727.95 | 1.61 |
| 3 | Organic chamomile tea 20 bags | 6.50 | 727.95 | 0.89 |
(First 3 of 47 rows.) The 47 lines are still there, and each one carries the €727.95 total and its percentage stuck to it. With GROUP BY this is impossible: either you have the detail, or you have the total. Here you have both.
And that's the comparison to fix in your head before going on:
GROUP BY |
Window function | |
|---|---|---|
| Rows in the result / the detail | One per group / lost | All the input ones / kept |
| Can you filter by the aggregate / what it's for | Yes, with HAVING / summarizing |
Not directly (section 3) / enriching each row |
- The anatomy of
OVER
OVERThe general form is function(args) OVER (PARTITION BY expr ORDER BY expr ROWS/RANGE ...), with three pieces: PARTITION BY says which groups it's split into (without it, everything is one group), ORDER BY in what order each group is walked, and the frame which slice of the group goes into the computation.
flowchart LR
A["47 lines"] --> B["<b>PARTITION BY</b><br/>splits into groups"] --> C["<b>ORDER BY</b><br/>sorts each group"]
C --> D["<b>frame</b><br/>which rows of the group count<br/>for the current row"] --> E["one value for each<br/>of the 47 rows"]
The three parts are optional and independent, and each combination means something different:
| Written | Means |
|---|---|
OVER () |
Every row, with no ordering and no frame: the grand total |
OVER (PARTITION BY category_id) / OVER (ORDER BY date) |
The total for its category / the running total up to the current row |
The detail that surprises everybody: adding ORDER BY to a window changes the result of a SUM, because it switches on a default frame running from the beginning to the current row. Without ORDER BY, SUM gives the group total; with it, it gives the running total. It isn't a bug: it's the gateway to section 6's frame.
- Where it runs: the
WHERE mistake
WHERE mistakeWindow functions are evaluated almost last: FROM → WHERE → GROUP BY → HAVING → window functions → SELECT → DISTINCT → ORDER BY → LIMIT. Two important facts follow from that. The first: a window function only sees the rows that survived the WHERE. If you filter by country = 'France', the SUM(...) OVER () will be France's total, not the shop's. The second is the number-one mistake in this lesson:
-- ⚠️ INCORRECT: filtering by a window function in the WHERE
SELECT product, SUM(quantity) AS units FROM v_sales_detail
WHERE ROW_NUMBER() OVER (ORDER BY SUM(quantity) DESC) <= 3
GROUP BY product;ERROR: window functions are not allowed in WHERE
LINE 2: WHERE ROW_NUMBER() OVER (ORDER BY SUM(quantity) DESC) <= 3
^And it isn't an arbitrary limitation: when the WHERE is evaluated, the window function hasn't been computed yet, because it needs to know which rows pass the filter. It would be circular. The solution is always the same —compute at one level and filter at the next— and with CTEs (10-02) it reads perfectly:
-- ✅ CORRECT
WITH sales AS (
SELECT product_id, product, SUM(quantity) AS units
FROM v_sales_detail GROUP BY product_id, product),
ranked AS (
SELECT product, units, ROW_NUMBER() OVER (ORDER BY units DESC, product_id) AS rank_
FROM sales)
SELECT * FROM ranked WHERE rank_ <= 3;| product | units | rank_ |
|---|---|---|
| Organic brown rice 1 kg | 14 | 1 |
| Organic crushed tomato 400 g | 14 | 2 |
| Ginger kombucha 750 ml | 12 | 3 |
Remember it like this: you don't filter a window function, you wrap it. The same goes for the HAVING and for JOINs.
- Ranking:
ROW_NUMBER, RANK, DENSE_RANK, NTILE, PERCENT_RANK
ROW_NUMBER, RANK, DENSE_RANK, NTILE, PERCENT_RANK| Function | What it returns | With ties | Does it leave gaps? |
|---|---|---|---|
ROW_NUMBER() |
A running number 1, 2, 3… | Breaks them arbitrarily | No |
RANK() / DENSE_RANK() |
Sporting position / position with no gaps | Same number for tied rows | Yes after two firsts (3rd) / no (2nd) |
NTILE(n) / PERCENT_RANK() / CUME_DIST() |
n buckets of the same size / relative position between 0 and 1 |
Splits by position / same as RANK |
— |
GreenStore has a perfect tie to see it with: the rice and the tomato, with 14 units sold each.
SELECT product, SUM(quantity) AS units,
ROW_NUMBER() OVER (ORDER BY SUM(quantity) DESC, product_id) AS row_number,
RANK() OVER (ORDER BY SUM(quantity) DESC) AS rank,
DENSE_RANK() OVER (ORDER BY SUM(quantity) DESC) AS dense_rank
FROM v_sales_detail GROUP BY product_id, product ORDER BY units DESC, product_id;| product | units | row_number | rank | dense_rank |
|---|---|---|---|---|
| Organic brown rice 1 kg | 14 | 1 | 1 | 1 |
| Organic crushed tomato 400 g | 14 | 2 | 1 | 1 |
| Ginger kombucha 750 ml | 12 | 3 | 3 | 2 |
| Extra virgin olive oil 500 ml | 9 | 4 | 4 | 3 |
| Organic chamomile tea 20 bags | 9 | 5 | 4 | 3 |
(First 5 of 17 rows; the sixth, the bamboo toothbrush with 9 units, gets 6, 4 and 3; the seventh, Spelt pasta with 7 units, gets 7, 7 and 4: the 17 products that have been sold at least once.) Read it column by column and you won't forget it. ROW_NUMBER numbers 1, 2, 3, 4, 5, 6, 7: it never repeats, and for that you need an explicit tie-breaker (, p.id) or the choice is arbitrary and can change between runs. RANK gives 1, 1, 3, 4, 4, 4, 7: tied rows share a position and the next one jumps as many places as there were ties; it's the sporting podium. DENSE_RANK gives 1, 1, 2, 3, 3, 3, 4: they share a position with no gaps, so it's "the second best value", not "the second".
Which to use: ROW_NUMBER to pick one row per group (deduplicating, top 1); RANK for a publishable ranking; DENSE_RANK when what you're numbering is distinct values, not rows.
- Offset:
LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUE
LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUEThese functions look at another row in the same window. Over GreenStore's monthly series:
SELECT month, revenue, LAG(revenue) OVER w AS prev_month,
LAG(revenue, 2, 0) OVER w AS two_months_ago, LEAD(revenue) OVER w AS next_month
FROM mv_monthly_sales WINDOW w AS (ORDER BY month) ORDER BY month;| month | revenue | prev_month | two_months_ago | next_month |
|---|---|---|---|---|
| 2025-03 | 68.80 | (null) | 0.00 | 61.28 |
| 2025-04 | 61.28 | 68.80 | 0.00 | 58.85 |
| 2025-05 | 58.85 | 61.28 | 68.80 | 95.48 |
(First 3 of 12 rows.) The three arguments to LAG and LEAD are (column, offset, default_value): by default the offset is 1 and the default value is NULL —hence the *(null)* in the first month—, whereas LAG(revenue, 2, 0) looks two rows back and returns 0 when there's no such row. That third argument saves you a COALESCE and, more importantly, stops a subtraction turning into NULL.
FIRST_VALUE, LAST_VALUE and NTH_VALUE(expr, n) return the value from the first, the last and the nth row of the frame. Over this same series and with the full frame, FIRST_VALUE gives €68.80 (March 2025), LAST_VALUE gives €49.33 (February 2026) and NTH_VALUE(revenue, 3) gives €58.85 on all twelve rows. That "with the full frame" isn't a detail: the end of section 6 explains why without it the result is different.
- Aggregates as windows, and the frame
Every aggregate function from module 4 accepts OVER: SUM, AVG, COUNT, MIN, MAX, STRING_AGG, and also the ones that carry a FILTER. The syntax is identical; the only thing that changes is that the result is stuck onto each row instead of collapsing it.
SELECT e.name || ' ' || e.last_name AS employee, e.salary,
ROUND(AVG(e.salary) OVER (), 2) AS company_avg,
ROUND(e.salary - AVG(e.salary) OVER (), 2) AS diff_avg,
SUM(e.salary) OVER (ORDER BY e.salary DESC, e.id) AS running_payroll
FROM employees AS e ORDER BY e.salary DESC;| employee | salary | company_avg | diff_avg | running_payroll |
|---|---|---|---|---|
| Rosa Alcázar Vives | 62000.00 | 35037.50 | 26962.50 | 62000.00 |
| Andrés Company Talens | 41000.00 | 35037.50 | 5962.50 | 103000.00 |
| Daniel Vercher Lluch | 35000.00 | 35037.50 | -37.50 | 177500.00 |
(3 of the 8 employees —Beatriz, €39,500, is missing from 3rd place—; the running column closes at €280,300.00, the total payroll.) The course's canonical figures —sum €280,300, average €35,037.50— show up here next to each employee, and the business reading is immediate: Daniel earns €37.50 less than the company's exact average, and the top three salaries eat up more than half the payroll.
The frame: ROWS versus RANGE
The frame defines which rows of the group go into the computation for each specific row. You write it ROWS BETWEEN start AND end or RANGE BETWEEN start AND end, with these endpoints:
| Endpoint | Means |
|---|---|
UNBOUNDED PRECEDING / UNBOUNDED FOLLOWING |
From the group's first row / to the last one |
n PRECEDING / n FOLLOWING |
n rows before / after the current one |
CURRENT ROW |
The current one (with RANGE: the current one and all the rows tied with it) |
And the difference between the two keywords is exactly this: ROWS counts physical rows —2 PRECEDING is the two rows above, whatever they're called— whereas RANGE counts ORDER BY values: every row with the same sort value as the current one is a peer and they come in or go out together.
SELECT product, SUM(quantity) AS units,
SUM(SUM(quantity)) OVER (ORDER BY SUM(quantity) DESC) AS running_range,
SUM(SUM(quantity)) OVER (ORDER BY SUM(quantity) DESC, product_id
ROWS UNBOUNDED PRECEDING) AS running_rows
FROM v_sales_detail GROUP BY product_id, product ORDER BY units DESC, product_id;| product | units | running_range | running_rows |
|---|---|---|---|
| Organic brown rice 1 kg | 14 | 28 | 14 |
| Organic crushed tomato 400 g | 14 | 28 | 28 |
| Ginger kombucha 750 ml | 12 | 40 | 40 |
| Extra virgin olive oil 500 ml | 9 | 67 | 49 |
(First 4 of 17 rows.) RANGE gives 28 to both tied rows —it adds them together, because they're worth the same— and ROWS gives 14 and 28, advancing row by row. The three products on 9 units repeat the pattern: 67, 67, 67 with RANGE; 49, 58, 67 with ROWS.
The LAST_VALUE trap
The default frame, when there's an ORDER BY and you write no frame, is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. That is: the window ends at the current row. And there lies the most famous trap in all of SQL:
-- ⚠️ INCORRECT: it doesn't return the last month
SELECT month, revenue,
FIRST_VALUE(revenue) OVER (ORDER BY month) AS first_val,
LAST_VALUE(revenue) OVER (ORDER BY month) AS last_val
FROM mv_monthly_sales ORDER BY month;| month | revenue | first_val | last_val |
|---|---|---|---|
| 2025-03 | 68.80 | 68.80 | 68.80 |
| 2025-04 | 61.28 | 68.80 | 61.28 |
(First 2 of 12 rows.) last_val is always the row itself, because the frame ends there: the "last row of the window" is the current one. FIRST_VALUE works by accident, because the frame's first row really is the first of all. The fix is to write the whole frame: LAST_VALUE(revenue) OVER (ORDER BY month ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING).
And then last_val is 49.33 on all twelve rows: February 2026's revenue. Rule: the moment you use LAST_VALUE or NTH_VALUE, write the frame explicitly. And careful, it isn't true either that "without ORDER BY there's no frame": without ORDER BY, the default frame is the whole group, which is exactly what makes OVER () give the grand total.
WINDOW: giving a window a name
WINDOW: giving a window a nameWhen the same window shows up three times, you name it once at the end of the query —between the HAVING and the ORDER BY— and reference it by name, as in section 5: SELECT month, SUM(revenue) OVER w, AVG(revenue) OVER w FROM mv_monthly_sales WINDOW w AS (ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW);
You can declare several separated by commas, and one can inherit from another: WINDOW w AS (PARTITION BY category_id), w2 AS (w ORDER BY price DESC). It's pure syntactic sugar, but it eliminates the dullest source of bugs there is: changing the ORDER BY in two of the three copies and forgetting the third.
- The GreenStore cases
8.1. Running total, moving average and monthly change
The three computations every dashboard asks for, in a single query over the twelve-month series:
SELECT month, revenue,
SUM(revenue) OVER w AS running_total,
ROUND(AVG(revenue) OVER (ORDER BY month
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS moving_avg_3,
LAG(revenue) OVER w AS prev_month,
ROUND(revenue - LAG(revenue) OVER w, 2) AS change,
ROUND(100 * (revenue - LAG(revenue) OVER w)
/ LAG(revenue) OVER w, 2) AS change_pct
FROM mv_monthly_sales
WINDOW w AS (ORDER BY month) ORDER BY month;| month | revenue | running_total | moving_avg_3 | prev_month | change | change_pct |
|---|---|---|---|---|---|---|
| 2025-03 | 68.80 | 68.80 | 68.80 | (null) | (null) | (null) |
| 2025-04 | 61.28 | 130.08 | 65.04 | 68.80 | -7.52 | -10.93 |
| 2025-05 | 58.85 | 188.93 | 62.98 | 61.28 | -2.43 | -3.97 |
| 2025-06 | 95.48 | 284.41 | 71.87 | 58.85 | 36.63 | 62.24 |
| 2025-07 | 44.60 | 329.01 | 66.31 | 95.48 | -50.88 | -53.29 |
| 2025-09 | 32.76 | 410.04 | 41.88 | 48.27 | -15.51 | -32.13 |
| 2025-10 | 97.20 | 507.24 | 59.41 | 32.76 | 64.44 | 196.70 |
| 2025-11 | 31.70 | 538.94 | 53.89 | 97.20 | -65.50 | -67.39 |
| 2025-12 | 64.58 | 603.52 | 64.49 | 31.70 | 32.88 | 103.72 |
| 2026-01 | 75.10 | 678.62 | 57.13 | 64.58 | 10.52 | 16.29 |
| 2026-02 | 49.33 | 727.95 | 63.00 | 75.10 | -25.77 | -34.31 |
(The 2025-08 row is missing: €48.27 of revenue, €377.28 running total, €62.78 moving average and +€3.67 / +8.23 % against July.) Three readings and one warning. The running total closes at €727.95 and passes through €603.52 in December 2025: those are the course's two canonical figures, and they confirm the series is sound. The 3-month moving average smooths the noise: actual revenue jumps between €31.70 and €97.20, while the moving average moves in a much narrower band, between €41.88 and €71.87. And the percentage change is spectacular but misleading: that +196.70 % in October isn't a commercial boom, it's that September had a single order. With small volumes, percentages lie — and that's a lesson in analysis, not in SQL.
The warning: the first three rows of moving_avg_3 aren't three-month averages, but averages of one and of two, because the 2 PRECEDING frame has nothing to draw on. If the report must show only complete averages, you have to null them out with CASE WHEN COUNT(*) OVER (ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) = 3 THEN ... END.
With
PARTITION BY, the running total restarts.SUM(revenue) OVER (PARTITION BY left(month, 4) ORDER BY month)gives the year-to-date total: January 2026 starts over at €75.10 and February closes at €124.43, 2026's revenue.
8.2. Top N per category, and the three ways to do it
Two promises get kept here: 02-04's DISTINCT ON and 07-04's LATERAL. The canonical pattern is ROW_NUMBER inside a CTE, filtered outside:
WITH sales AS (
SELECT sd.category_id, cat.name AS category, sd.product_id, sd.product,
ROUND(SUM(sd.amount), 2) AS revenue
FROM v_sales_detail AS sd JOIN categories AS cat ON cat.id = sd.category_id
GROUP BY sd.category_id, cat.name, sd.product_id, sd.product)
SELECT category, product, revenue, rank_ FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY category_id
ORDER BY revenue DESC, product_id) AS rank_
FROM sales) AS r
WHERE rank_ <= 2 ORDER BY category, rank_;| category | product | revenue | rank_ |
|---|---|---|---|
| Drinks | Ceremonial matcha green tea 30 g | 88.00 | 1 |
| Drinks | Ginger kombucha 750 ml | 56.43 | 2 |
| Food | Extra virgin olive oil 500 ml | 109.53 | 1 |
| Food | Organic brown rice 1 kg | 54.60 | 2 |
| Natural cosmetics | Aloe vera face cream 50 ml | 70.42 | 1 |
| Natural cosmetics | Calendula lip balm 15 ml | 32.20 | 2 |
| Personal hygiene | Bamboo toothbrush | 31.50 | 1 |
(6 of 9 rows; Sustainable home closes with the reusable bags, €39.60, and the detergent, €32.48.) 9 rows in total: four categories contribute two products and Personal hygiene only one, because the deodorant was never sold. Supplements doesn't show up: it has sold nothing.
And the comparison with the other two approaches, using 02-04's example —the most expensive product in each category—, which all three solve with the same 6 rows:
DISTINCT ON (02-04) |
LATERAL (07-04) |
ROW_NUMBER (here) |
|
|---|---|---|---|
| How it's written | DISTINCT ON (category_id) ... ORDER BY category_id, price DESC |
CROSS JOIN LATERAL (... LIMIT n) |
ROW_NUMBER() OVER (PARTITION BY ...) filtered outside |
| Portable? | No: PostgreSQL only | Yes (CROSS APPLY in SQL Server) |
Yes: SQL standard, in every modern engine |
| Top 1 / top N with N > 1 | The shortest / can't do it | Verbose / yes | Verbose / yes |
| Shows the position / empty groups | No / they don't appear | No / yes with LEFT JOIN LATERAL ... ON TRUE |
Yes, a column / they don't appear |
| Performance with a per-group index | Good | The best with many groups and a low LIMIT |
Good; it walks the whole group |
The criterion: top 1 in PostgreSQL and with no need for portability, DISTINCT ON; top N with a huge number of groups and an index that supports it, LATERAL; in any other case, ROW_NUMBER, which throws in the position column for free.
8.3. Customer ranking
WITH sales AS (
SELECT customer_id, customer, country, ROUND(SUM(amount), 2) AS revenue
FROM v_sales_detail GROUP BY customer_id, customer, country)
SELECT customer, country, revenue,
RANK() OVER (ORDER BY revenue DESC) AS rank_,
NTILE(4) OVER (ORDER BY revenue DESC) AS quartile,
ROUND(100 * revenue / SUM(revenue) OVER (), 2) AS pct_total,
ROUND(100 * SUM(revenue) OVER (ORDER BY revenue DESC)
/ SUM(revenue) OVER (), 2) AS pct_cumulative
FROM sales ORDER BY revenue DESC;| customer | country | revenue | rank_ | quartile | pct_total | pct_cumulative |
|---|---|---|---|---|---|---|
| Sofia Moreira Costa | Portugal | 111.88 | 1 | 1 | 15.37 | 15.37 |
| Lucía Martínez Soler | Spain | 107.60 | 2 | 1 | 14.78 | 30.15 |
| Camille Dubois | France | 70.87 | 3 | 1 | 9.74 | 39.89 |
| Carlos Ferrer Ibáñez | Spain | 59.46 | 6 | 2 | 8.17 | 65.89 |
(4 of the 12 customers with purchases — missing are Julien Moreau, 4th with €66.90, and Javier Ortega Ruiz, 5th with €62.93; Diego closes with €31.70, Elena with €30.30 and Marta with €29.53.) The pct_cumulative column is a Pareto analysis done with a window function: the top 6 customers account for 65.89 % of revenue. And notice that NTILE(4) splits the 12 customers into four quartiles of exactly 3, without looking at the amounts: it splits by position, not by value.
8.4. Each product against its category's average
In 07-02 this was solved with a correlated subquery that walked products once per row. With PARTITION BY it's a single pass:
SELECT p.id, p.name AS product, cat.name AS category, p.price,
ROUND(AVG(p.price) OVER w, 2) AS category_avg,
ROUND(p.price - AVG(p.price) OVER w, 2) AS difference,
ROUND(100 * p.price / AVG(p.price) OVER w, 2) AS pct_of_avg
FROM products AS p JOIN categories AS cat ON cat.id = p.category_id
WINDOW w AS (PARTITION BY p.category_id) ORDER BY cat.id, p.price DESC;| id | product | category | price | category_avg | difference | pct_of_avg |
|---|---|---|---|---|---|---|
| 1 | Extra virgin olive oil 500 ml | Food | 12.50 | 6.18 | 6.32 | 202.27 |
| 3 | Raw orange blossom honey 500 g | Food | 9.75 | 6.18 | 3.57 | 157.77 |
| 5 | Organic crushed tomato 400 g | Food | 1.95 | 6.18 | -4.23 | 31.55 |
| 6 | Aloe vera face cream 50 ml | Natural cosmetics | 18.90 | 11.54 | 7.36 | 163.81 |
| 15 | Ceremonial matcha green tea 30 g | Drinks | 22.00 | 8.90 | 13.10 | 247.19 |
(5 of the 20 rows.) The 20 products are still there, each with its category's average alongside. The matcha, at €22.00, costs almost two and a half times the Drinks average (€8.90); the olive oil, a little over twice the Food average (€6.18). No subquery, no repetition, a single read of products.
- Performance and dialect
A window function forces the engine to sort by PARTITION BY + ORDER BY before computing; in the EXPLAIN you'll see a WindowAgg node almost always preceded by a Sort. Three practical consequences: an index on (partition_column, sort_column) can eliminate that Sort; several functions sharing the same window are computed in a single WindowAgg, so reusing the window (with WINDOW) is also faster; and filter earlier with WHERE whenever you can, because the window will work over fewer rows. Even so, a window function almost always beats the alternative: where a correlated subquery makes N passes, the window makes one.
Dialect note: window functions are SQL:2003 standard and today they're everywhere: PostgreSQL (since 8.4, with the most complete set), MySQL 8.0, MariaDB 10.2, SQLite 3.25, SQL Server 2012 and Oracle. Differences worth knowing: SQL Server doesn't allow
RANGEwithn PRECEDING(onlyROWS) and didn't getIGNORE NULLSinLAG/LEADuntil 2022; MySQL 8 has noFILTERclause; Oracle calls them analytic functions and addsKEEP (DENSE_RANK FIRST/LAST); andGROUPSas a third frame mode (besidesROWSandRANGE), together withEXCLUDE, exists in PostgreSQL 11+ and is missing from most others.
Common Mistakes and Tips
- Filtering by a window function in the
WHEREor theHAVING.window functions are not allowed in WHERE. They're computed afterwards. Wrap it in a CTE or a derived table and filter outside. - Expecting
LAST_VALUEto return the last value. With the default frame it returns the current row. WriteROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING. And the other way round: addingORDER BYto aSUM() OVERwhen you don't want a running total turns it into one; if you want the group total, don't put anORDER BY. - Using
ROW_NUMBERwithout a tie-breaker. With repeated values, which row gets the 1 is arbitrary and can change between runs. Always add a unique column to the window'sORDER BY. - Confusing
RANKwithDENSE_RANK—the first leaves gaps after a tie (1, 1, 3) and the second doesn't (1, 1, 2)— or thinkingRANGEandROWSare synonyms: they only are without ties; with ties,RANGElumps them all together (28 instead of 14). - Forgetting that the window only sees what got past the
WHERE: a "percentage of the total" in a filtered query is the percentage of the filtered total. And putting a window function inside an aggregate:SUM(ROW_NUMBER() OVER ())isn't valid; the other way round is,SUM(SUM(x)) OVER (...)is correct and common — the aggregate is computed first, the window afterwards. - Tip: always start with
OVER ()and build up. First the grand total, thenPARTITION BY, thenORDER BY, and only at the end the frame. Run it after every step and watch how the column changes. - Tip:
ROWSby default, since it holds no surprises with ties;RANGEonly when the peer behaviour is deliberate. And name the window withWINDOWas soon as it repeats twice: you avoid changing theORDER BYin one copy and not the other, and the engine computes it only once.
Exercises
Exercise 1
Over the 47 order lines, write a query showing, for each line: the order, the product, its amount, its order's total, the percentage it represents within the order and its position within the order by amount. No GROUP BY, no subqueries: just OVER. Check on order 1 that the percentages add up to 100 and the total is €42.10.
Exercise 2
Management wants the three highest-billing customers in each country, with their position. (1) Write it with ROW_NUMBER and a CTE. (2) How many rows does it return and why isn't it 9? (3) If instead of "the top three" they asked for "everyone tied in third place", which function would you use?
Exercise 3
A colleague wants to flag the months in which revenue beat the twelve-month average and has written SELECT month, revenue FROM mv_monthly_sales WHERE revenue > AVG(revenue) OVER ();. (1) What error does it give and why? (2) Fix it with a CTE, also showing the average and the difference. (3) How many months beat the average and what is that average?
Solutions
Solution 1
SELECT order_id, product, amount,
SUM(amount) OVER w AS order_total,
ROUND(100 * amount / SUM(amount) OVER w, 2) AS pct_of_order,
ROW_NUMBER() OVER (PARTITION BY order_id
ORDER BY amount DESC, line_id) AS rank_in_order
FROM v_sales_detail
WINDOW w AS (PARTITION BY order_id) ORDER BY order_id, rank_in_order;| order_id | product | amount | order_total | pct_of_order | rank_in_order |
|---|---|---|---|---|---|
| 1 | Extra virgin olive oil 500 ml | 23.90 | 42.10 | 56.77 | 1 |
| 1 | Organic brown rice 1 kg | 11.70 | 42.10 | 27.79 | 2 |
| 1 | Organic chamomile tea 20 bags | 6.50 | 42.10 | 15.44 | 3 |
(First 3 of 47 rows.) 56.77 + 27.79 + 15.44 = 100.00 and order 1's total is €42.10, as in 07-04. The 47 rows are preserved: PARTITION BY order_id computes the total per order and sticks it onto each of its lines. Here's where you see the gain: with GROUP BY there would be 20 rows and no detail at all.
Solution 2 — 1.
WITH sales AS (
SELECT country, customer, ROUND(SUM(amount), 2) AS revenue
FROM v_sales_detail GROUP BY customer_id, country, customer)
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY country ORDER BY revenue DESC) AS rank_
FROM sales) AS r
WHERE rank_ <= 3 ORDER BY country, rank_;| country | customer | revenue | rank_ |
|---|---|---|---|
| France | Camille Dubois | 70.87 | 1 |
| France | Julien Moreau | 66.90 | 2 |
| Portugal | Sofia Moreira Costa | 111.88 | 1 |
| Spain | Lucía Martínez Soler | 107.60 | 1 |
| Spain | Javier Ortega Ruiz | 62.93 | 2 |
| Spain | Carlos Ferrer Ibáñez | 59.46 | 3 |
(6 of 7 rows; the one missing is Tiago Almeida Nunes, Portugal's 2nd with €44.60.) 2. Seven rows, not nine, because France and Portugal only have two customers with purchases each. ROW_NUMBER doesn't invent rows: if a group has fewer than N, it contributes what it has. (Spain has 8 customers with purchases and contributes 3.)
3. RANK(), not DENSE_RANK or ROW_NUMBER. With RANK, everyone tied on the third value gets a 3 and WHERE rank_ <= 3 returns all of them. ROW_NUMBER would have cut off arbitrarily at one; DENSE_RANK would have returned too many, because its "3" is the third distinct value, not the third position.
Solution 3 — 1. It gives ERROR: window functions are not allowed in WHERE. The engine evaluates the WHERE before computing the window functions, so at that moment AVG(revenue) OVER () doesn't exist yet; and it couldn't, because the window needs to know which rows pass the filter and the filter needs the window's value. 2 and 3: with WITH monthly AS (SELECT month, revenue, ROUND(AVG(revenue) OVER (), 2) AS annual_avg FROM mv_monthly_sales) and then SELECT month, revenue, annual_avg, ROUND(revenue - annual_avg, 2) AS difference FROM monthly WHERE revenue > annual_avg ORDER BY revenue DESC, six of the twelve months come out above an average of €60.66 (€727.95 across 12): 2025-10 with +€36.54, 2025-06 with +€34.82, 2026-01 with +€14.44, 2025-03 with +€8.14, 2025-12 with +€3.92 and 2025-04 with +€0.62. The average is computed only once in the CTE and stays available as an ordinary column: that's why you can filter by it and subtract it in the same step.
Conclusion
Window functions are the tool that's been missing since module 4:
- A window function computes over a group of rows without collapsing them: GreenStore's 47 lines come out intact, each with its total, its percentage or its position alongside.
OVER ()is the empty window —the grand total— and it's where you should start. The anatomy isOVER (PARTITION BY ... ORDER BY ... frame), with all three parts optional, and addingORDER BYchanges an aggregate's result: it switches on the "up to the current row" frame and turns the total into a running total. - They're evaluated after
WHERE,GROUP BYandHAVING, and that's why you can't filter by them in theWHERE(window functions are not allowed in WHERE). The universal solution: compute in a CTE and filter outside. You don't filter it, you wrap it. - Ranking:
ROW_NUMBERnumbers without repeating (1, 2, 3),RANKties and leaves a gap (1, 1, 3),DENSE_RANKties without a gap (1, 1, 2) — demonstrated with the real tie between the rice and the tomato at 14 units. PlusNTILEfor quartiles andPERCENT_RANKfor relative positions. Offset:LAGandLEADwith their(column, n, default)arguments, andFIRST_VALUE/LAST_VALUE/NTH_VALUE, which demand an explicit frame. - The frame:
ROWScounts physical rows,RANGEgroups rows with the same sort value (28 instead of 14 in the rice tie). The default frame withORDER BYisRANGE UNBOUNDED PRECEDING AND CURRENT ROW—henceLAST_VALUEreturning the current row and not the last one—; withoutORDER BY, the whole group. - The GreenStore cases, all verified: a monthly running total closing at €727.95 by way of €603.52 in December; a 3-month moving average between €41.88 and €71.87; monthly change with October's misleading
+196.70 %; top 2 per category withROW_NUMBER(9 rows) and the table setting it againstDISTINCT ONandLATERAL; a customer ranking with the Pareto that gives 65.89 % cumulative in the top six; and each product against its category's average in a single pass, where 07-02 used a correlated subquery.
With this there's no analytical question about GreenStore you can't write. But everything you've done so far lives in one statement: you write it, you run it and you forget it. What comes next is storing logic, not just queries. In the next lesson, stored procedures, you'll see code that lives inside the database: the real difference between a function and a procedure in PostgreSQL, just enough PL/pgSQL to write something useful —variables, IF, loops, RAISE, exception handling— and three examples in order of difficulty ending in sp_confirm_order, the procedure where the transactional logic you built by hand in 09-03 finally lives. And, above all, the honest discussion about what's worth putting in there and what isn't.
SQL Course
Module 1: Introduction to SQL
- What is SQL?
- Setting up your SQL environment
- Basic SQL syntax
- Understanding databases and tables
- The relational model: primary and foreign keys
- The course database: GreenStore
Module 2: Basic SQL queries
- The SELECT statement
- Aliases, expressions and calculated columns
- Filtering data with WHERE
- DISTINCT and removing duplicates
- Sorting data with ORDER BY
- Limiting results with LIMIT
Module 3: Working with multiple tables
- JOIN operations
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN and CROSS JOIN
- Set operations: UNION, INTERSECT and EXCEPT
Module 4: Advanced data filtering
- Using LIKE for pattern matching
- The IN and BETWEEN operators
- NULL values and IS NULL
- Aggregate functions: COUNT, SUM, AVG, MIN and MAX
- Aggregating data with GROUP BY
- The HAVING clause
Module 5: Data manipulation
- Creating tables and constraints with CREATE TABLE
- The INSERT statement
- The UPDATE statement
- The DELETE statement
- The UPSERT (MERGE) statement
- Changing the schema: ALTER TABLE and safe migrations
Module 6: Advanced SQL functions
- String functions
- Numeric functions
- Date and time functions
- Type conversion and handling NULL: CAST and COALESCE
- Conditional expressions
Module 7: Subqueries and nested queries
- Introduction to subqueries
- Correlated subqueries
- EXISTS and NOT EXISTS
- Using subqueries in SELECT, FROM and WHERE
- Subquery or JOIN: which one to choose
Module 8: Indexes and performance tuning
- Understanding indexes
- Creating and managing indexes
- Index types and when not to index
- Query optimization techniques
- Analyzing query performance
Module 9: Transactions and concurrency
- Introduction to transactions
- ACID properties
- Transaction control statements
- Isolation levels and concurrency anomalies
- Handling concurrency: locks and deadlocks
Module 10: Advanced topics
- Views
- Common table expressions (CTEs)
- Window functions
- Stored procedures
- Triggers
- JSON and semi-structured data
Module 11: SQL in practice
- Real-world use cases
- Best practices
- Security: SQL injection, permissions and roles
- SQL for data analysis
- SQL in web development
