◀ Course contents Part 3 · Module 3-06

Common Table Expressions

Naming the steps, and the query that walks a tree

The last module of Part 3, and it does two jobs. Most of it is presentational: WITH is the derived table of 3-05 written top to bottom with a name on each step. Then there is the recursive form, which is the only construct in this course that can produce rows nobody stored.

Ready?

1

The Same Query, Read in the Order It Happens

A nested query is assembled inside out. The step that runs first is written deepest, and a reader has to find the innermost bracket before anything makes sense. Two levels of that is awkward; three is genuinely hard to follow.

A common table expression is the same computation with a name on it, lifted to the top.

Inside out

Derived table

SELECT t.name, t.spent
FROM (SELECT u.name, SUM(o.amount) AS spent
      FROM users u JOIN orders o …
      GROUP BY u.user_id, u.name) t
WHERE t.spent > 200;

Top to bottom

CTE

WITH spend AS (
  SELECT u.name, SUM(o.amount) AS spent
  FROM users u JOIN orders o …
  GROUP BY u.user_id, u.name
)
SELECT name, spent FROM spend WHERE spent > 200;

Identical results, identical work. What changed is that the first thing that happens is the first thing you read, and the step has a name you can say out loud.

Several CTEs go in one WITH, separated by commas, and each may use the ones before it:

WITH spend AS ( … ),
     benchmark AS (SELECT AVG(spent) AS avg_spent FROM spend)
SELECT s.name, s.spent
FROM spend s CROSS JOIN benchmark b
WHERE s.spent > b.avg_spent;

There is only one WITH, at the very top. The comma introduces each further name; writing WITH again is a syntax error. The names last for that statement only — nothing is stored and nothing survives the query.

Name the step, not the mechanism

The real argument for WITH is the next person to read the query, who is often you in six months. Compare "the subquery in the FROM clause of the left join" with active_buyers. One of those can be discussed in a meeting; the other has to be traced with a finger.

Quick check

You write WITH a AS (…) WITH b AS (…) SELECT …. What happens?

2

One Real Capability, and One Honest Caveat

Almost everything about WITH is presentation. There is exactly one thing it can do that a derived table cannot: a CTE can be referenced more than once.

WITH spend AS ( … )
SELECT (SELECT COUNT(*) FROM spend)             AS n,
       (SELECT ROUND(AVG(spent), 2) FROM spend) AS avg_spent;

One definition, two uses. As a derived table that same query would need the whole subquery pasted in twice — and then kept in step forever, which is the kind of duplication that goes wrong the first time somebody edits one copy.

Any query that compares a set against a summary of itself has this shape, and it is common: rows against their own average, this month against the trailing twelve, each region against the total.

The caveat

A CTE is not free. Historically Postgres always materialised one — ran it fully, stored the result, then used it — which meant a filter in the outer query could not be pushed down into it. Since Postgres 12 a CTE used exactly once is inlined by default and optimised as though you had nested it, so the two forms usually perform identically now.

Where it still matters you can say so explicitly, with AS MATERIALIZED or AS NOT MATERIALIZED. That is a tuning concern rather than a correctness one, and worth knowing exists rather than reaching for.

Quick check

Which of these genuinely cannot be rewritten as a derived table without repeating yourself?

3

A Query That Walks a Tree

Now the part that is genuinely new. A self join reaches one level of a hierarchy: a user and their referrer. Two self joins reach two levels. But "everyone in the referral chain, however deep it goes" cannot be written that way at all — you would need to know the depth when writing the query, and you do not.

WITH RECURSIVE has exactly two branches, joined by UNION ALL:

WITH RECURSIVE tree AS (

  SELECT user_id, name, 1 AS depth          -- ANCHOR: runs once
  FROM users
  WHERE referred_by IS NULL

  UNION ALL

  SELECT u.user_id, u.name, t.depth + 1     -- RECURSIVE TERM: re-runs
  FROM users u
  JOIN tree t ON u.referred_by = t.user_id  -- ...against the CTE itself

)
SELECT name, depth FROM tree ORDER BY depth, name;
1

The anchor runs once

It seeds the result — here, the four users who arrived on their own. It is the only branch that could run on its own.

2

The recursive term re-runs

Against only what the previous pass produced, not the whole accumulated result. Pass two finds the people the four roots referred; pass three finds the people those referred.

3

It stops when a pass returns nothing

Eventually nobody has anyone below them, the recursive term matches no rows, and the CTE is complete.

The word RECURSIVE goes once, immediately after WITH, even when only one of several CTEs actually recurses.

Generating rows that are in no table

A recursive CTE does not need a table at all. This is the standard way to build a complete list of months so a report can show a zero for a month in which nothing happened — a row that exists in no table, because nothing occurred to create it.

WITH RECURSIVE m AS (
  SELECT DATE '2023-01-01' AS mon
  UNION ALL
  SELECT (mon + INTERVAL '1 month')::date FROM m
  WHERE mon < DATE '2023-06-01'          -- the termination condition
)
SELECT mon FROM m;

The WHERE is not optional

Walking a hierarchy terminates for free — you run out of children. Generating a series does not. Leave that WHERE out and the query never finishes: Postgres will not detect the loop for you. In this dojo the worker is stopped after ten seconds and you press Run again. On a real database it is a query somebody has to go and cancel.

The ::date cast matters as well. Adding an INTERVAL to a DATE yields a TIMESTAMP, and both branches of a UNION ALL must agree on type — the cast is what keeps the column a DATE.

On Postgres specifically there is a much shorter way to do exactly this: generate_series(DATE '2023-01-01', DATE '2023-12-01', INTERVAL '1 month'), a set-returning function that goes in FROM. It is clearer, faster, and has no termination condition to get wrong — but it does not exist in most other engines, which is why the recursive form is worth meeting first.

Quick check

Your recursive tree query returns only the four root users. What is wrong?

4

Part 3, In One Table

Six modules, all answering the same underlying question — how do rows from different places end up in one answer. A rough guide to which tool the question is asking for:

JOIN

Columns from both sides, on one row

The answer needs fields from two tables together. Inner if unmatched rows should vanish, LEFT if they must survive — and once a chain goes LEFT it stays LEFT.

IN / EXISTS

A yes-or-no about the other table

No columns wanted from it. Cannot duplicate rows, which a join can. Negate with NOT EXISTS, never NOT IN on a nullable column.

UNION ALL

One list, from two sources

Stacking rather than widening. UNION only when a duplicate is genuinely noise.

WITH

A step worth naming — or reusing

Several stages, or one stage used twice. Reuse is the only hard capability a derived table lacks.

RECURSIVE

Depth unknown in advance

Hierarchies, and rows that exist in no table.

The habit that catches more errors than any of these choices is still the one from 3-03: say what the row count should be before you run it. Every failure in this part — the fan-out, the inner join that deleted rows, the mirrored pairs, the NOT IN that returned nothing — announces itself in a row count and in nothing else.

Next: Part 4

Analyst SQL. Ranking rows within their group, comparing a row to its neighbours, running totals, funnels and retention cohorts — and changing data rather than only reading it. Several things that needed a correlated subquery here get a shorter and faster spelling there.

Quick check

"For every category, the revenue it earned and the share that is of total revenue." Which shape?

0 of 9 completed

Loading the tables…

01

To do

A common table expression is a named subquery written before the statement that uses it. The keyword is WITH.

WITH spend AS (
SELECT u.user_id, u.name, SUM(o.amount) AS spent
FROM users u
JOIN orders o ON o.user_id = u.user_id
WHERE o.status = 'paid'
GROUP BY u.user_id, u.name
)
SELECT name, spent FROM spend WHERE spent > 200;

That is the derived table from the last module, unchanged in what it computes. What changed is the reading order: the step that happens first is now written first, with a name, instead of being buried in the middle of a FROM clause.

The name behaves exactly like a table for the rest of the statement. It exists only for this statement — nothing is stored, and nothing survives the query.

Your task: using WITH, return name and spent for every user whose total paid spend is over 200, ordered by spent descending.

query.sql
PostgreSQL
Hint

Inside the brackets goes the aggregate: SELECT u.name, SUM(o.amount) AS spent FROM users u JOIN orders o ON o.user_id = u.user_id WHERE o.status = 'paid' GROUP BY u.user_id, u.name. Then select from spend and filter on spent > 200.

Output

      
    02

    To do

    Several CTEs go in one WITH, separated by commas — and each may use the ones before it. That is what turns a pile of nesting into a sequence of steps.

    WITH spend AS ( … ),
    benchmark AS (SELECT AVG(spent) AS avg_spent FROM spend)
    SELECT …

    Note there is only one WITH, at the very top. The comma introduces the second name; writing WITH again is a syntax error.

    This is the two-level aggregation from 3-05 — the average of per-user totals, 210.11 — with the two levels finally readable as two named steps rather than one query inside another.

    Your task: return name and spent for every user whose paid spend beats the average paid spend per user, ordered by spent descending. Use two CTEs.

    query.sql
    PostgreSQL
    Hint

    benchmark is SELECT AVG(spent) AS avg_spent FROM spend. Then SELECT s.name, s.spent FROM spend s, benchmark b WHERE s.spent > b.avg_spent — a one-row table can be joined to freely.

    Output
    
          
      03

      To do

      Here is the one hard capability difference, rather than a matter of taste. A CTE can be referenced more than once. A derived table cannot — you would have to paste the entire subquery in again, and then keep the two copies in step forever.

      WITH spend AS ( … )
      SELECT (SELECT COUNT(*) FROM spend) AS n,
      (SELECT ROUND(AVG(spent), 2) FROM spend) AS avg_spent;

      One definition, two uses. Any query that needs to compare a set against a summary of itself has this shape, and it is where WITH stops being cosmetic.

      Your task: define a CTE of per-user paid spend, then return two columns from it in a single row: how many users have any paid spend as n, and their average spend rounded to two decimals as avg_spent.

      query.sql
      PostgreSQL
      Hint

      (SELECT COUNT(*) FROM spend) AS n and (SELECT ROUND(AVG(spent), 2) FROM spend) AS avg_spent — the same CTE named twice.

      Output
      
            
        04

        To do

        A CTE is a table for the rest of the statement, so everything from Part 3 applies to it unchanged — it can be joined, left-joined, grouped and filtered.

        This is the usual way to attach an aggregate back onto the rows it came from without a fan-out. Aggregate in the CTE, join the result, and every row keeps its own identity.

        And the outer join still matters: the two users who never ordered are absent from a spend CTE built on orders, so keeping them needs LEFT JOIN and COALESCE exactly as in 3-02.

        Your task: return every user's name, country and paid spent0 where they have none — by joining a spend CTE to users. Order by spent descending, then name.

        query.sql
        PostgreSQL
        Hint

        LEFT JOIN spend s ON s.user_id = u.user_id, and COALESCE(s.spent, 0) AS spent. A plain JOIN loses the three users with no paid orders.

        Output
        
              
          05

          To do

          Now the genuinely new capability. A self join reaches one level of a hierarchy — a user and their referrer. Two self joins reach two. But "everyone in the referral tree, however deep" cannot be written that way at all, because the depth is not known when the query is written.

          WITH RECURSIVE is the answer, and it has exactly two parts joined by UNION ALL:

          WITH RECURSIVE tree AS (
          SELECT user_id, name, 1 AS depth -- anchor: runs once
          FROM users WHERE referred_by IS NULL
          UNION ALL
          SELECT u.user_id, u.name, t.depth + 1 -- recursive: re-runs
          FROM users u JOIN tree t ON u.referred_by = t.user_id
          )
          SELECT * FROM tree;

          The anchor runs once and seeds the result — here, the four users who arrived on their own. The recursive term then runs against only what the previous pass produced, adding the people they referred, and repeats until a pass returns nothing. That empty pass is what stops it.

          The word RECURSIVE goes after WITH, once, even if only one of several CTEs is recursive.

          Your task: return name and depth for every user in the referral tree, ordered by depth then name.

          query.sql
          PostgreSQL
          Hint

          The recursive branch is SELECT u.user_id, u.name, t.depth + 1 FROM users u JOIN tree t ON u.referred_by = t.user_id — joining the table back to the CTE being defined.

          Output
          
                
            06

            To do

            A recursive CTE does not need a table at all. It can generate rows — which makes it the standard way to build a complete list of months, so a report can show a zero for a month in which nothing happened.

            WITH RECURSIVE m AS (
            SELECT DATE '2023-01-01' AS mon
            UNION ALL
            SELECT (mon + INTERVAL '1 month')::date FROM m WHERE mon < DATE '2023-06-01'
            )
            SELECT mon FROM m;

            The WHERE in the recursive branch is the termination condition, and it is not optional. It is what makes a pass eventually return no rows.

            Leave it out and the query never finishes. In this dojo that means the worker is killed and you press Run again; on a real database it means a query someone has to go and cancel. Postgres will not detect it for you.

            The ::date cast matters too — adding an INTERVAL to a DATE gives a TIMESTAMP, and without the cast the column type changes between the two branches.

            Your task: generate the first six months of 2023, one row each, as mon, ordered by mon.

            query.sql
            PostgreSQL
            Hint

            The starter has no stopping condition. Add WHERE mon < DATE '2023-06-01' to the recursive branch — the last month it generates is June.

            Output
            
                  
              07

              To do

              Recursion is the portable way to build a series. On Postgres there is a far shorter one, and it is what a Postgres analyst would actually write.

              SELECT g::date AS mon
              FROM generate_series(DATE '2023-01-01', DATE '2023-12-01', INTERVAL '1 month') g;

              generate_series is a set-returning function — it goes in FROM and produces rows. Start, stop, step; the stop value is included. It works on integers and timestamps alike.

              This is Postgres-specific. It has no equivalent in most other engines, which is exactly why the recursive form was worth learning first — but when the target is Postgres, this is clearer and faster, and there is no termination condition to get wrong.

              Your task: return all twelve months of 2023 as mon, using generate_series, ordered by mon.

              query.sql
              PostgreSQL
              Hint

              generate_series(DATE '2023-01-01', DATE '2023-12-01', INTERVAL '1 month') g, and select g::date AS mon so the column is a DATE rather than a timestamp.

              Output
              
                    
                08

                To do

                The argument for WITH is mostly about the next person to read the query, which includes you in six months. A well-named CTE says what a step is for; a nested subquery only says what it does.

                Compare "the subquery in the FROM clause of the left join" with active_buyers. The second one can be discussed in a meeting.

                Your task: a re-engagement list. Define a CTE named buyers holding the user_id of everyone with at least one paid order, then return user_id and name for every user not in it, ordered by user_id.

                Three users qualify. Watch the negation — this is the module where NOT IN bit, and a CTE does nothing to protect you from it.

                query.sql
                PostgreSQL
                Hint

                buyers is SELECT user_id FROM orders WHERE status = 'paid'. Then WHERE NOT EXISTS (SELECT 1 FROM buyers b WHERE b.user_id = u.user_id) — or NOT IN, which is safe here only because user_id is never NULL.

                Output
                
                      
                  09

                  To do

                  Everything in Part 3 has been a different way of bringing rows together. A rough guide to which:

                  JOIN

                  You need columns from both sides

                  The answer has fields from two tables on one row.

                  IN / EXISTS

                  You only need a yes or no

                  Membership or existence, with no columns wanted from the other table.

                  WITH

                  A step is worth naming, or reusing

                  Several stages, or one stage used twice. Reusing is the part a derived table genuinely cannot do.

                  RECURSIVE

                  The depth is not known in advance

                  Hierarchies, and generating rows that are in no table.

                  Your task: put the whole part together. Using CTEs, return each country, the number of users in it as users, and its total paid revenue — including countries whose users have never bought anything, which must show 0. Order by revenue descending, then country.

                  query.sql
                  PostgreSQL
                  Hint

                  revenue is SELECT user_id, SUM(amount) AS paid FROM orders WHERE status = 'paid' GROUP BY user_id. Join it per user, then COALESCE(SUM(r.paid), 0) AS revenue grouped by country.

                  Output
                  
                        

                    The referral tree

                    To do

                    Growth wants to see the referral tree with money attached: how deep each user sits in the chain that brought them in, and what they have actually spent. Two independent computations, combined — which is exactly what WITH is for.

                    Your task: one row for every user, with:

                    • name — the user's name
                    • depth — their level in the referral tree, counting from 1 for anyone who arrived on their own
                    • spent — their total paid spend, 0 if they have none

                    Order by depth, then name.

                    Two CTEs: a recursive one walking the tree from its roots, and an ordinary one totalling paid orders per user. Then join them — and remember that three users have no paid orders at all, so that join is an outer one. The tree is four levels deep and every user is on it, so a correct answer has 12 rows.

                    query.sql
                    PostgreSQL
                    Hint

                    The anchor is SELECT user_id, name, 1 AS depth FROM users WHERE referred_by IS NULL. The recursive branch is SELECT u.user_id, u.name, t.depth + 1 FROM users u JOIN tree t ON u.referred_by = t.user_id. spend is SELECT user_id, SUM(amount) AS spent FROM orders WHERE status = 'paid' GROUP BY user_id. Then COALESCE(s.spent, 0) AS spent.

                    Output
                    
                          

                      Notification