Skip to main content

    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

    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:

    idproduct_namecategorypricestock
    1Wireless MouseElectronics24.99120
    2Coffee MugKitchen9.5300
    3Mechanical KeyboardElectronics7945
    4NotebookStationery3.25500
    5Desk LampHome3280
    6USB-C CableElectronics12.99200

    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.

    Try it Yourself »
    SQL
    -- 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_nameprice
    Wireless Mouse24.99
    USB-C Cable12.99
    Coffee Mug9.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.

    Try it Yourself »
    SQL
    -- 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_nameprice
    Wireless Mouse24.99
    USB-C Cable12.99
    Coffee Mug9.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.

    Try it Yourself »
    SQL
    -- 🎯 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.

    Try it Yourself »
    SQL
    -- 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:

    categoryitemsavg_price
    Electronics338.99
    Home132
    Kitchen19.5
    Stationery13.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.

    Try it Yourself »
    SQL
    -- 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_namecategorypriceavg_all
    Wireless MouseElectronics24.9928.62
    Coffee MugKitchen9.528.62
    Mechanical KeyboardElectronics7928.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() OVER (ORDER BY price DESC)

    Number products dearest-first.

    Try it Yourself »
    SQL
    -- 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_namepriceprice_rank
    Mechanical Keyboard791
    Desk Lamp322
    Wireless Mouse24.993
    USB-C Cable12.994
    Coffee Mug9.55
    Notebook3.256

    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.

    Try it Yourself »
    SQL
    -- 🎯 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.

    Try it Yourself »
    SQL
    -- 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_namepricernkdense
    Mechanical Keyboard7911
    Desk Lamp3222
    Wireless Mouse24.9933
    USB-C Cable12.9944
    Coffee Mug9.555
    Notebook3.2566

    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.

    Try it Yourself »
    SQL
    -- 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:

    categoryproduct_namepricerank_in_category
    ElectronicsMechanical Keyboard791
    ElectronicsWireless Mouse24.992
    ElectronicsUSB-C Cable12.993
    HomeDesk Lamp321
    KitchenCoffee Mug9.51
    StationeryNotebook3.251

    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.

    Try it Yourself »
    SQL
    -- 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_namepricestockrunning_stock
    Mechanical Keyboard794545
    Desk Lamp3280125
    Wireless Mouse24.99120245
    USB-C Cable12.99200445
    Coffee Mug9.5300745
    Notebook3.255001245

    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.

    Try it Yourself »
    SQL
    -- 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_namepricedearer_pricecheaper_price
    Mechanical Keyboard79NULL32
    Desk Lamp327924.99
    Wireless Mouse24.993212.99
    USB-C Cable12.9924.999.5
    Coffee Mug9.512.993.25
    Notebook3.259.5NULL

    Common Errors (and the fix)

    • Window function in WHERE: WHERE ROW_NUMBER() OVER (...) = 1 errors — window functions run after WHERE, 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 an OVER (...) clause, even an empty OVER ().
    • Confusing RANK and ROW_NUMBER on ties: if two rows tie, RANK repeats the number and skips the next (1, 2, 2, 4) while ROW_NUMBER always stays unique (1, 2, 3, 4). Pick ROW_NUMBER when you need exactly one row per position, RANK/DENSE_RANK when ties should share a place.
    • Expecting OVER (ORDER BY ...) to sort your output: it only orders the window's calculation. Add a top-level ORDER BY to 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

    SyntaxPurpose
    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().

    Try it Yourself »
    SQL
    -- 🎯 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_RANK differ only on ties
    • PARTITION BY restarts the window inside each group
    • SUM() OVER (ORDER BY ...) makes a running total; LAG/LEAD peek 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.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service