Lesson 14 • Expert Track
Advanced Queries
By the end of this lesson you'll write the two tools that separate SQL analysts from everyone else: CTEs that turn tangled subqueries into readable, reusable steps, and window functions that rank, total, and compare rows without throwing the detail away. Every result is hand-computed from a 6-row table so you can check yourself.
What You'll Learn
- ✓Write readable, reusable subqueries with WITH (CTEs)
- ✓Add summaries with window functions that keep every row
- ✓Explain how a window function differs from GROUP BY
- ✓Number and rank rows with ROW_NUMBER and RANK
- ✓Restart a ranking per group using PARTITION BY
- ✓Build running totals and compare rows with LAG / LEAD
SELECT, WHERE, GROUP BY, and subqueries. The in-browser editor lets you write and edit SQL; to run it, copy your query into a free playground like sqliteonline.com or db-fiddle.com. Every example below shows the expected result so you can check yourself. Our Sample Table: products
Every query in this lesson runs against this little products table — the same one from the SELECT lesson. It is only 6 rows on purpose: small enough that you can hand-check every window-function result yourself.
Result:
| id | product_name | category | price | stock |
|---|---|---|---|---|
| 1 | Wireless Mouse | Electronics | 24.99 | 120 |
| 2 | Coffee Mug | Kitchen | 9.5 | 300 |
| 3 | Mechanical Keyboard | Electronics | 79 | 45 |
| 4 | Notebook | Stationery | 3.25 | 500 |
| 5 | Desk Lamp | Home | 32 | 80 |
| 6 | USB-C Cable | Electronics | 12.99 | 200 |
1. CTEs — Name Your Subqueries with WITH
A CTE (Common Table Expression) is a temporary, named result set you define with WITH name AS (...) and then query as if it were a real table. It exists only for the single statement it sits in, and it changes nothing in the database. The point is readability: instead of burying logic inside a nested subquery you have to decode from the middle outwards, you give that step a name and read the query top to bottom.
📝 Real-world analogy
A CTE is like a labelled prep bowl in a kitchen. Rather than chopping onions inside the pan while everything else cooks, you prep "diced onions" in a bowl first, label it, then use it. The recipe reads in order, and you can use the bowl more than once.
Here is the basic shape — define a CTE, then select from it:
WITH — a named subquery
Define cheap_products, then query it.
-- A CTE names a temporary result set, then you query it.
-- Read it top-to-bottom like a recipe instead of inside-out.
WITH cheap_products AS ( -- 1) define a named subquery
SELECT product_name, price
FROM products
WHERE price < 30
)
SELECT * -- 2) now query it like a normal table
FROM cheap_products
ORDER BY price DESC;
-- "cheap_products" exists only for this one statement.
-- The real products table is never changed.Result — 3 rows — price < 30, ordered by price DESC:
| product_name | price |
|---|---|
| Wireless Mouse | 24.99 |
| USB-C Cable | 12.99 |
| Coffee Mug | 9.5 |
The same logic can be written as a nested subquery — but compare how it reads. The CTE version pulls the inner query out and names it, so you follow it step by step instead of unwrapping it from the inside.
The subquery this replaces
Same result, harder to read.
-- The SAME logic written as a nested subquery (harder to read)
SELECT *
FROM (
SELECT product_name, price
FROM products
WHERE price < 30
) AS cheap_products
ORDER BY price DESC;
-- A CTE pulls that inner query OUT and gives it a name,
-- so you read 1 -> 2 instead of decoding it from the middle.
-- You can also reuse the name several times in one query.Result — 3 rows — identical to the CTE above:
| product_name | price |
|---|---|
| Wireless Mouse | 24.99 |
| USB-C Cable | 12.99 |
| Coffee Mug | 9.5 |
Your Turn: a CTE that filters
Fill in the two blanks to build a CTE that filters to well-stocked products, then selects from it. The expected result is in the comments so you can check yourself.
🎯 Your Turn: well-stocked CTE
Replace the ___ blanks: the cut-off and the CTE name.
-- 🎯 YOUR TURN — fill in the blanks, then press "Try it Yourself"
-- Goal: use a CTE to list products that are well-stocked (stock > 150),
-- showing just product_name and stock.
WITH well_stocked AS (
SELECT product_name, stock
FROM products
WHERE stock > ___ -- 👉 the cut-off number for "well stocked"
)
SELECT *
FROM ___; -- 👉 the name of the CTE you defined above
-- ✅ Expected: 3 rows ->
-- Coffee Mug | 300 , Notebook | 500 , USB-C Cable | 20
...2. Window Functions vs GROUP BY
A window function performs a calculation across a set of related rows — the "window" — but, crucially, it keeps every row. This is the one idea to hold on to: GROUP BY collapses rows into one summary per group, while a window function adds the summary as a new column and leaves the detail rows intact.
🪟 Real-world analogy
Picture a class roster showing each student's grade and the class average printed next to every name. GROUP BY would fold everyone into a single "class average" row. A window function keeps every student visible while writing the average beside each one.
First, the familiar GROUP BY — notice the detail rows disappear:
GROUP BY collapses rows
One summary row per category.
-- GROUP BY COLLAPSES rows: one row PER group, detail is gone
SELECT category, COUNT(*) AS items, AVG(price) AS avg_price
FROM products
GROUP BY category;
-- You can no longer see individual products here —
-- they have been folded into one row per category.Result — 4 rows — individual products are gone:
| category | items | avg_price |
|---|---|---|
| Electronics | 3 | 38.99 |
| Home | 1 | 32 |
| Kitchen | 1 | 9.5 |
| Stationery | 1 | 3.25 |
Now the same kind of average as a window function. The syntax is function() OVER (...). An empty OVER () means "the window is the whole table", so every row gets the overall average — and all 6 product rows survive.
A window keeps every row
AVG(price) OVER () beside every product.
-- A window function KEEPS every row and adds a summary column.
-- OVER () means "the window is the whole result set".
SELECT
product_name,
category,
price,
AVG(price) OVER () AS avg_all -- same value on every row
FROM products;
-- Every one of the 6 products is still here,
-- now with the overall average price beside it.Result — 6 rows — avg_all is 28.621666… (same on every row):
| product_name | category | price | avg_all |
|---|---|---|---|
| Wireless Mouse | Electronics | 24.99 | 28.62 |
| Coffee Mug | Kitchen | 9.5 | 28.62 |
| Mechanical Keyboard | Electronics | 79 | 28.62 |
| … | … | … | … |
(The average of all six prices is 171.73 ÷ 6 ≈ 28.62 — shown rounded here; your playground may print more decimals.)
3. ROW_NUMBER and RANK
ROW_NUMBER() hands each row a unique position number. The OVER (ORDER BY ...) clause decides the order it counts in — it does not reorder your final output unless you also add a top-level ORDER BY. To rank products from most to least expensive, order by price DESC.
ROW_NUMBER() is always unique (1, 2, 3, …). RANK() and DENSE_RANK() only behave differently when two rows tie on the ORDER BY value — see the comparison below.ROW_NUMBER() OVER (ORDER BY price DESC)
Number products dearest-first.
-- ROW_NUMBER() gives each row a unique position.
-- OVER (ORDER BY ...) decides the order it counts in.
SELECT
product_name,
price,
ROW_NUMBER() OVER (ORDER BY price DESC) AS price_rank
FROM products;
-- It numbers 1..6 with NO ties — even if two prices were equal,
-- ROW_NUMBER would still pick a 1 and a 2 (order undefined).Result — 6 rows, dearest = 1:
| product_name | price | price_rank |
|---|---|---|
| Mechanical Keyboard | 79 | 1 |
| Desk Lamp | 32 | 2 |
| Wireless Mouse | 24.99 | 3 |
| USB-C Cable | 12.99 | 4 |
| Coffee Mug | 9.5 | 5 |
| Notebook | 3.25 | 6 |
Your Turn: rank by price
Fill in the two blanks so the dearest product is rank 1 and the cheapest is rank 6. The full expected ranking is in the comments.
🎯 Your Turn: ROW_NUMBER
Pick the function and the sort direction.
-- 🎯 YOUR TURN — fill in the blanks, then press "Try it Yourself"
-- Goal: number the products from most expensive (1) to cheapest (6).
SELECT
product_name,
price,
___() OVER (ORDER BY price ___) AS price_rank
-- 👉 first blank: the function that gives a unique 1,2,3,...
-- 👉 second blank: DESC so the dearest product is rank 1
FROM products;
-- ✅ Expected (6 rows, in this order):
-- Mechanical Keyboard | 79.00 | 1
-- Desk Lamp | 32.00 | 2
-- Wireless Mo
...3b. RANK vs ROW_NUMBER — what ties do
The difference between the three ranking functions only appears when rows tie on the value you order by. With two products both priced 24.99: ROW_NUMBER still forces a unique 1, 2, 3, 4; RANK gives the tied rows the same number and then skips (1, 2, 2, 4); DENSE_RANK shares the number but does not skip (1, 2, 2, 3).
Our six products all have distinct prices, so there are no ties — and the three functions return the identical 1…6 you saw above. That is exactly why ROW_NUMBER and RANK look the same here.
RANK() vs DENSE_RANK()
Identical here because no prices tie.
-- RANK() vs ROW_NUMBER() — the difference only shows on TIES.
-- Imagine two products both priced 24.99:
-- ROW_NUMBER -> 1, 2, 3, 4 ... (always unique, ties broken arbitrarily)
-- RANK -> 1, 2, 2, 4 ... (ties share a rank, then it SKIPS)
-- DENSE_RANK -> 1, 2, 2, 3 ... (ties share a rank, NO skip)
-- Our 6 products all have different prices, so here the three
-- functions return identical numbers 1..6:
SELECT
product_name,
price,
RANK() OVER (ORDER BY price DE
...Result — 6 rows — no ties, so rnk = dense = 1..6:
| product_name | price | rnk | dense |
|---|---|---|---|
| Mechanical Keyboard | 79 | 1 | 1 |
| Desk Lamp | 32 | 2 | 2 |
| Wireless Mouse | 24.99 | 3 | 3 |
| USB-C Cable | 12.99 | 4 | 4 |
| Coffee Mug | 9.5 | 5 | 5 |
| Notebook | 3.25 | 6 | 6 |
4. PARTITION BY — Restart the Window per Group
PARTITION BY splits the rows into groups and runs the window function independently inside each one. Think of it as "GROUP BY for windows" — same grouping idea, but it keeps every row instead of collapsing the group. Add PARTITION BY category and the ranking counter resets to 1 at the start of each category.
ROW_NUMBER() with PARTITION BY category
Rank restarts inside each category.
-- PARTITION BY restarts the window for each group.
-- Here the ranking counter resets to 1 inside every category.
SELECT
category,
product_name,
price,
ROW_NUMBER() OVER (
PARTITION BY category -- start a fresh count per category
ORDER BY price DESC -- dearest = 1 within that category
) AS rank_in_category
FROM products;
-- PARTITION BY is "GROUP BY for windows" — but it keeps every row
-- instead of collapsing the group into one.Result — 6 rows — counter resets per category:
| category | product_name | price | rank_in_category |
|---|---|---|---|
| Electronics | Mechanical Keyboard | 79 | 1 |
| Electronics | Wireless Mouse | 24.99 | 2 |
| Electronics | USB-C Cable | 12.99 | 3 |
| Home | Desk Lamp | 32 | 1 |
| Kitchen | Coffee Mug | 9.5 | 1 |
| Stationery | Notebook | 3.25 | 1 |
5. Running Totals with SUM() OVER (...)
Put an ORDER BY inside the OVER (...) of an aggregate like SUM and it becomes a running total — each row's value is its own amount plus everything that came before it in that order. This is how cumulative charts and "balance to date" columns are built.
Accumulating stock in price-DESC order: 45, then 45+80=125, then +120=245, +200=445, +300=745, +500=1245 (the grand total on the final row).
SUM(stock) OVER (ORDER BY price DESC)
A cumulative stock total.
-- A running (cumulative) total: SUM that grows row by row.
-- ORDER BY tells it which direction to accumulate in.
SELECT
product_name,
price,
stock,
SUM(stock) OVER (ORDER BY price DESC) AS running_stock
FROM products;
-- Each row's running_stock = its own stock PLUS every row above it
-- in the price-DESC order. Row 1 is just 45; the last row is the
-- grand total of all stock (1245).Result — 6 rows — running_stock accumulates downward:
| product_name | price | stock | running_stock |
|---|---|---|---|
| Mechanical Keyboard | 79 | 45 | 45 |
| Desk Lamp | 32 | 80 | 125 |
| Wireless Mouse | 24.99 | 120 | 245 |
| USB-C Cable | 12.99 | 200 | 445 |
| Coffee Mug | 9.5 | 300 | 745 |
| Notebook | 3.25 | 500 | 1245 |
6. LAG and LEAD — Peek at Neighbouring Rows
LAG(col) returns the value of col from the previous row; LEAD(col) returns it from the next row, in whatever order the OVER (ORDER BY ...) defines. They are how you write "compared to the row before" — month-over-month growth, the gap to the next-cheapest item, and so on. The first row has no previous row (so LAG is NULL) and the last row has no next row (so LEAD is NULL).
LAG and LEAD over price DESC
See each product's dearer/cheaper neighbour.
-- LAG() looks at the PREVIOUS row; LEAD() looks at the NEXT row.
-- Perfect for "compared to the row before me" calculations.
SELECT
product_name,
price,
LAG(price) OVER (ORDER BY price DESC) AS dearer_price,
LEAD(price) OVER (ORDER BY price DESC) AS cheaper_price
FROM products;
-- The first row has no previous row, so LAG is NULL.
-- The last row has no next row, so LEAD is NULL.
-- price - LAG(price) tells you the gap to the row above.Result — 6 rows — NULL at the ends:
| product_name | price | dearer_price | cheaper_price |
|---|---|---|---|
| Mechanical Keyboard | 79 | NULL | 32 |
| Desk Lamp | 32 | 79 | 24.99 |
| Wireless Mouse | 24.99 | 32 | 12.99 |
| USB-C Cable | 12.99 | 24.99 | 9.5 |
| Coffee Mug | 9.5 | 12.99 | 3.25 |
| Notebook | 3.25 | 9.5 | NULL |
Common Errors (and the fix)
- Window function in
WHERE:WHERE ROW_NUMBER() OVER (...) = 1errors — window functions run afterWHERE, so they aren't allowed there. Wrap the query in a CTE (or subquery) and filter on the column outside:WITH ranked AS (...) SELECT * FROM ranked WHERE rn = 1; - Forgetting
OVER:SELECT ROW_NUMBER() FROM products;fails — every window function needs anOVER (...)clause, even an emptyOVER (). - Confusing
RANKandROW_NUMBERon ties: if two rows tie,RANKrepeats the number and skips the next (1, 2, 2, 4) whileROW_NUMBERalways stays unique (1, 2, 3, 4). PickROW_NUMBERwhen you need exactly one row per position,RANK/DENSE_RANKwhen ties should share a place. - Expecting
OVER (ORDER BY ...)to sort your output: it only orders the window's calculation. Add a top-levelORDER BYto sort the final rows. - Referencing a CTE out of scope: a CTE only exists for the single statement that starts with its
WITH. You can't use it in a separate query that follows.
📘 Quick Reference
| Syntax | Purpose |
|---|---|
| WITH name AS (...) | Define a named, reusable subquery (CTE) |
| func() OVER (...) | Run a window function, keeping every row |
| OVER (PARTITION BY col) | Restart the window for each group |
| ROW_NUMBER() OVER (ORDER BY ...) | Unique position 1, 2, 3, … (no ties) |
| RANK() / DENSE_RANK() | Ranking that shares ties (with / without gaps) |
| SUM(x) OVER (ORDER BY ...) | Running / cumulative total |
| LAG(col) / LEAD(col) | Value from the previous / next row |
Frequently Asked Questions
Q: When should I use a CTE instead of a subquery?
Reach for a CTE when a query gets hard to read, or when you need the same intermediate result more than once. They produce the same answer — a CTE just reads top-to-bottom and can be referenced by name multiple times.
Q: What's the real difference between a window function and GROUP BY?
GROUP BY collapses each group into one row. A window function adds the summary as a new column and keeps every row, so you get the detail and the aggregate side by side.
Q: Why can't I put a window function in WHERE?
WHERE runs before window functions are calculated, so the value doesn't exist yet. Compute it in a CTE/subquery, then filter on that column in the outer query.
Q: ROW_NUMBER, RANK, or DENSE_RANK — which do I pick?
Use ROW_NUMBER when you need exactly one row per slot (e.g. "the single newest order per customer"). Use RANK or DENSE_RANK when tied values should genuinely share a position; pick DENSE_RANK if you don't want gaps after a tie.
Mini-Challenge: Rank Within Each Category
Put it all together — a brief, a blank canvas, and the expected result in the comments. Write it, then copy it into a playground to confirm.
🎯 Mini-Challenge
PARTITION BY category, ORDER BY price DESC, ROW_NUMBER().
-- 🎯 MINI-CHALLENGE
-- Rank the products WITHIN each category, dearest first.
-- * Show category, product_name, price
-- * Add a column "rank_in_cat" using ROW_NUMBER()
-- * Window: PARTITION BY category ORDER BY price DESC
--
-- ✅ Expected (6 rows; the counter restarts per category):
-- Electronics | Mechanical Keyboard | 79.00 | 1
-- Electronics | Wireless Mouse | 24.99 | 2
-- Electronics | USB-C Cable | 12.99 | 3
-- Home | Desk Lamp | 32.00 | 1
...🎉 Lesson Complete
- ✅
WITH name AS (...)names a reusable subquery so complex SQL reads top-to-bottom - ✅ Window functions add a summary column while keeping every row — unlike
GROUP BY - ✅
ROW_NUMBER()numbers rows uniquely;RANK/DENSE_RANKdiffer only on ties - ✅
PARTITION BYrestarts the window inside each group - ✅
SUM() OVER (ORDER BY ...)makes a running total;LAG/LEADpeek at neighbouring rows - ✅ Next: the Final Project — build a complete, production-ready database
Sign up for free to track which lessons you've completed and get learning reminders.