A view gives a query a name forever and for everybody. Often what you need is the opposite: to name an intermediate step here, now and only inside this query, without creating any object or asking anyone for permission. That's a common table expression —CTE— and you write it by putting WITH in front of the SELECT.

With them you'll keep two of the course's promises. The first comes from 07-04 and 07-05: readability breaks down past three levels of derived tables, and here you'll see the same query written both ways, side by side. The second comes from 03-06: a SELF JOIN walks one level of the hierarchy, and to walk a tree of unknown depth you need WITH RECURSIVE — with which you'll finally get GreenStore's full org chart with its level and its path, the three-hop referral chain that runs from Lucía to Núria, and a twelve-month series generated out of thin air.

Contents

  1. WITH: the named subquery, moved to the front
  2. Readability: three levels of derived tables versus three CTEs
  3. Several chained CTEs: the query as a sequence of steps
  4. CTE, view and derived table: which to use
  5. Materialization: what changed in PostgreSQL 12
  6. CTEs in INSERT, UPDATE and DELETE; the "move rows" pattern
  7. WITH RECURSIVE: the anatomy
  8. The three GreenStore cases
  9. Infinite loops and how to protect yourself
  10. Common Mistakes and Tips
  11. Exercises
  12. Conclusion

  1. WITH: the named subquery, moved to the front

A CTE is a subquery given a name before the main query. It lives only for the duration of that statement and is then used as if it were a table.

WITH order_totals AS (
    SELECT o.id AS order_id,
           ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS total
    FROM   orders AS o JOIN order_lines AS ol ON ol.order_id = o.id
    GROUP  BY o.id
)
SELECT COUNT(*) AS orders, ROUND(AVG(t.total), 2) AS avg_order_value,
       ROUND(MIN(t.total), 2) AS min_order_value, ROUND(MAX(t.total), 2) AS max_order_value,
       ROUND(SUM(t.total), 2) AS revenue
FROM   order_totals AS t;
orders avg_order_value min_order_value max_order_value revenue
20 36.40 22.60 66.90 727.95

It's exactly 07-04's canonical derived table, with the same figures, but read from top to bottom: first order_totals is defined, then it's used. The minimal syntax is WITH name1 AS ( SELECT ... ), name2 AS ( SELECT ... ) SELECT ... FROM name1 JOIN name2 ..., where name2 can use name1.

Four rules worth fixing from the start: every CTE needs a name (and can optionally rename its columns, WITH t (order_id, total) AS (...)); they're separated by commas, and WITH is written only once even if there are five of them; a CTE can refer to earlier ones, never to later ones (except with RECURSIVE, section 7); and a CTE can be used several times in the same query, which a derived table doesn't allow.

  1. Readability: three levels of derived tables versus three CTEs

This is where 07-04's promise gets kept. The question: of the customers who have bought, which ones beat the average revenue per customer, and by how much? That's three steps —total per order, total per customer, average of those totals— and with derived tables they end up nested:

-- ⚠️ Correct, but unreadable: three levels of nesting
SELECT c.id, c.name || ' ' || c.last_name AS customer, x.orders, x.revenue,
       ROUND(x.revenue - x.customer_avg, 2) AS diff
FROM (SELECT tc.customer_id, tc.orders, tc.revenue,
             (SELECT AVG(tc2.revenue)
              FROM (SELECT ot2.customer_id, SUM(ot2.total) AS revenue
                    FROM (SELECT o2.id, o2.customer_id,
                                 ROUND(SUM(ol2.quantity * ol2.unit_price * (1 - ol2.discount)), 2) AS total
                          FROM orders o2 JOIN order_lines ol2 ON ol2.order_id = o2.id
                          GROUP BY o2.id, o2.customer_id) AS ot2
                    GROUP BY ot2.customer_id) AS tc2) AS customer_avg
      FROM (SELECT ot.customer_id, COUNT(*) AS orders, SUM(ot.total) AS revenue
            FROM (/* … and here, in full again, the very same ot2 computation … */) AS ot
            GROUP BY ot.customer_id) AS tc) AS x
JOIN customers AS c ON c.id = x.customer_id
WHERE x.revenue > x.customer_avg ORDER BY x.revenue DESC;

Count the parentheses, and notice that the same per-order totals computation appears twice, copied out, because a derived table can't be reused. Now the same thing with CTEs:

-- ✅ The same query, step by step
WITH order_totals AS (
    SELECT o.id AS order_id, o.customer_id,
           ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS total
    FROM   orders AS o JOIN order_lines AS ol ON ol.order_id = o.id
    GROUP  BY o.id, o.customer_id
),
customer_totals AS (
    SELECT customer_id, COUNT(*) AS orders, SUM(total) AS revenue
    FROM   order_totals GROUP BY customer_id
),
average AS (
    SELECT ROUND(AVG(revenue), 2) AS customer_avg FROM customer_totals
)
SELECT c.id, c.name || ' ' || c.last_name AS customer,
       ct.orders, ct.revenue, a.customer_avg,
       ROUND(ct.revenue - a.customer_avg, 2) AS diff
FROM   customer_totals AS ct
JOIN   customers       AS c ON c.id = ct.customer_id
CROSS JOIN average     AS a
WHERE  ct.revenue > a.customer_avg ORDER BY ct.revenue DESC;
id customer orders revenue customer_avg diff
7 Sofia Moreira Costa 2 111.88 60.66 51.22
1 Lucía Martínez Soler 3 107.60 60.66 46.94
9 Camille Dubois 2 70.87 60.66 10.21
10 Julien Moreau 1 66.90 60.66 6.24
4 Javier Ortega Ruiz 2 62.93 60.66 2.27

Five of the twelve customers with purchases beat the average of €60.66 (€727.95 across 12). The result is identical to the previous version; what's changed is that now it can be read, reviewed in a pull request and debugged step by step: just swap the final SELECT for SELECT * FROM customer_totals to see the intermediate result without touching anything else.

And there's a gain you can't see: order_totals is written once and used twice. In the nested version it was duplicated, with everything that means the day the amount formula changes.

  1. Several chained CTEs: the query as a sequence of steps

That's the natural way to write complex analysis: you don't think in terms of one query, but of a sequence of transformations.

flowchart LR
    A["order_lines<br/>47 rows"] --> B["order_totals<br/>20 rows"] --> C["customer_totals<br/>12 rows"] --> D["average<br/>1 row"] --> E["final<br/>5 rows"]
    C --> E

Each CTE reduces or transforms the granularity and has a name that says what it holds. If the final result doesn't add up, the diagnosis is mechanical: run SELECT COUNT(*) FROM order_totals (you should get 20), then customer_totals (12), and the level where the number isn't what you expected is where the bug is. It's 07-04's advice —"count the rows at each level"— now with the levels named. And name them after what they hold, not c1, c2, c3: a query with WITH sales_2025 AS ..., returns_2025 AS ..., net AS ... can be understood without reading the SQL.

  1. CTE, view and derived table: which to use

Derived table CTE View
Where it's written In the FROM Up front, with WITH In the schema, with CREATE VIEW
How long it lives / can other sessions see it? That query / no That query / no Until the DROP / yes
Reusable within the query No: you have to copy it Yes, as many times as you like Yes
Readable with 3+ levels / recursion Badly / no Well / yes Well / only with WITH RECURSIVE inside
Needs permissions to create No No Yes (CREATE on the schema)

The decision rule fits in three sentences: one level and throwaway → derived table; two or more steps, or the same step used twice → CTE; something other queries, other people or the BI team will use → view. And they're not mutually exclusive: a view can be defined with a WITH inside it.

  1. Materialization: what changed in PostgreSQL 12

This section corrects what almost every old tutorial says. Up to PostgreSQL 11, a CTE was an optimization barrier: the engine ran it whole, kept its result in memory and only then carried on, so a filter in the outer query couldn't be pushed inside. Writing WITH t AS (SELECT * FROM order_lines) SELECT * FROM t WHERE product_id = 15 computed all 47 rows only to throw nearly all of them away.

Since PostgreSQL 12 that's no longer the case. A CTE used only once and with no side effects is inlined into the main query, exactly like a view or a derived table, and the planner can push filters inwards. The penalty for writing readable queries is gone. The two keywords that force each behaviour:

Clause What it does When to use it
(nothing) Inlined if used once; materialized if used twice or more 95 % of cases
AS MATERIALIZED Forces it to be computed once and the result stored The CTE is expensive and used several times; or you want a deliberate barrier (for example, so a volatile function is evaluated only once)
AS NOT MATERIALIZED Forces inlining even if it's used several times The CTE is trivial and materializing it gets in the way of the indexes

Written as WITH order_totals AS MATERIALIZED ( ... ), the step is computed exactly once no matter how many times the main statement queries it — which is what you want when that step walks 47 lines and you use it in two different subqueries.

The way to know what's happening is to look, not to assume: if a CTE Scan node over a CTE name shows up in the EXPLAIN, it's been materialized; if it appears nowhere and you see the table scans directly, it's been inlined. The technique is 08-05's.

The inherited mistake. For years people were taught "use a CTE to force the execution order" and "don't use CTEs, they're slow". Both statements expired in PostgreSQL 12. If you need the barrier, ask for it explicitly with MATERIALIZED; if you don't, write the CTE because it reads better and pay nothing for it. In MySQL 8 and SQL Server CTEs are always inlined and the keyword doesn't exist; in Oracle there are equivalent hints (/*+ MATERIALIZE */).

  1. CTEs in INSERT, UPDATE and DELETE; the "move rows" pattern

A WITH can precede any statement, not just a SELECT. And there's more: a CTE can itself be an INSERT, UPDATE or DELETE with RETURNING, and its result can feed the next step. That's the pattern that solves something 05-04 made impossible in a single statement: moving rows from one table to another.

GreenStore wants to archive cancelled orders —today, only number 6— in a historical table:

CREATE TABLE orders_archive (LIKE orders INCLUDING DEFAULTS);   -- ⚠️ NOT canonical
WITH deleted AS (
    DELETE FROM orders WHERE status = 'cancelled' RETURNING *
)
INSERT INTO orders_archive SELECT * FROM deleted;          -- INSERT 0 1

One statement, one transaction, no window in which the row doesn't exist anywhere. The three properties that make it possible: every sub-statement sees the same snapshot of the database, the one from the start of the statement: the deleted CTE doesn't see the INSERT's effect, and the INSERT doesn't see the DELETE's effect on orders. The execution order isn't guaranteed, so writing to the same table from two branches of the same statement gives unpredictable results. And a CTE that writes always runs, even if the main query doesn't use it: it's the only exception to section 5's inlining.

A second, more everyday use: compute with WITH and update with the result. Bringing the catalogue price in line with the average price actually sold:

WITH actual_price AS (
    SELECT ol.product_id, ROUND(AVG(ol.unit_price), 2) AS avg_price
    FROM   order_lines AS ol GROUP BY ol.product_id
)
UPDATE products AS p SET price = ap.avg_price
FROM   actual_price AS ap WHERE ap.product_id = p.id;      -- UPDATE 17

Seventeen products, the ones that have been sold at least once. And notice the advantage over 07-04's version: there you needed a WHERE EXISTS so the three never-sold products didn't get NULL; here the implicit JOIN with the CTE already leaves them out. (Reload the script after trying it.)

  1. WITH RECURSIVE: the anatomy

This is where 03-06's promise gets kept. A recursive CTE is a CTE that references itself, and it always has the same shape:

WITH RECURSIVE name AS (
    SELECT ...                    -- BASE TERM: where you start from. Doesn't reference itself
    UNION ALL
    SELECT ... FROM table JOIN name ON ...   -- RECURSIVE TERM: uses the previous result
)
SELECT * FROM name;

How it runs, which is the only thing you really need to understand:

flowchart TD
    A["<b>Base term</b><br/>Rosa (level 1)"] --> B["working table: 1 row"] --> C{"working table<br/>empty?"}
    C -->|no| D["<b>Recursive term</b>: children of<br/>the working table's rows"]
    D --> E["they pile up in the result and become<br/>the new working table"] --> C
    C -->|yes| F["<b>done</b>: everything<br/>accumulated is returned"]

Iteration by iteration, with GreenStore's org chart: the base produces Rosa; the first iteration looks for Rosa's children and produces Andrés, Beatriz and Daniel; the second looks for those three's children and produces Óscar, Laia, Marc and Irene; the third looks for those four's children, finds none, the working table comes out empty and the process ends. Total: 1 + 3 + 4 = 8 rows, the eight employees.

Three syntax details with traps in them: RECURSIVE goes immediately after WITH, once only, even if there are several CTEs and only one is recursive; UNION ALL doesn't remove duplicates and is the normal choice, whereas plain UNION removes them at every step —crude protection against cycles, but more expensive—; and the recursive term can reference the CTE only once, with no aggregates, no ORDER BY and no LIMIT inside.

  1. The three GreenStore cases

8.1. The full employee hierarchy

WITH RECURSIVE tree AS (
    -- Base: the root, whoever has no manager
    SELECT e.id, e.name || ' ' || e.last_name AS employee, e.job_title,
           1 AS level, e.name AS path
    FROM   employees AS e WHERE e.manager_id IS NULL
    UNION ALL
    -- Recursive: the subordinates of whoever is already in the tree
    SELECT e.id, e.name || ' ' || e.last_name, e.job_title,
           t.level + 1, t.path || ' > ' || e.name
    FROM   employees AS e JOIN tree AS t ON e.manager_id = t.id
)
SELECT level, id, employee, job_title, path FROM tree ORDER BY path;
level id employee job_title path
1 1 Rosa Alcázar Vives General manager Rosa
2 2 Andrés Company Talens Sales manager Rosa > Andrés
3 5 Laia Puig Sanchis Sales rep Rosa > Andrés > Laia
3 6 Marc Estévez Roig Customer support Rosa > Andrés > Marc
3 4 Óscar Peris Blasco Sales rep Rosa > Andrés > Óscar
2 3 Beatriz Nadal Ripoll Logistics manager Rosa > Beatriz
3 7 Irene Salvador Mira Warehouse operator Rosa > Beatriz > Irene
2 8 Daniel Vercher Lluch Data analyst Rosa > Daniel

The eight employees, with their depth and their chain of command, laid out as a tree thanks to ORDER BY path. Compare it with 03-06: there you needed two chained LEFT JOINs, the number of levels was hard-coded into the query and even so you only got as far as the "grandparent". Here the query doesn't know how many levels there are, and it would work just the same with fifteen.

Two one-line variants that are worth a lot: one person's subtree —change the base term's WHERE e.manager_id IS NULL to WHERE e.id = 2 and you get Andrés and his three subordinates, 4 rows—; and a stable ordering, because a path made of names depends on how the language sorts accents: accumulate a second column with padded ids, t.path_id || '.' || lpad(e.id::text, 5, '0'), and sort by that.

8.2. The referral chain, upwards

Recursion also works in the opposite direction: instead of going down from parents to children, going up from child to parent. Marketing's question is "who does Núria Bosch Ferrer ultimately come from?".

WITH RECURSIVE chain AS (
    SELECT c.id, c.name || ' ' || c.last_name AS customer, c.referred_by_id,
           0 AS hop, c.name AS path
    FROM   customers AS c WHERE c.id = 13
    UNION ALL
    SELECT r.id, r.name || ' ' || r.last_name, r.referred_by_id,
           ch.hop + 1, r.name || ' > ' || ch.path
    FROM   customers AS r JOIN chain AS ch ON ch.referred_by_id = r.id
)
SELECT hop, id, customer, path FROM chain ORDER BY hop;
hop id customer path
0 13 Núria Bosch Ferrer Núria
1 5 Ana Belmonte Roca Ana > Núria
2 2 Carlos Ferrer Ibáñez Carlos > Ana > Núria
3 1 Lucía Martínez Soler Lucía > Carlos > Ana > Núria

The three-hop chain 03-06 couldn't walk: 1 → 2 → 5 → 13. The row with hop = 3 is the origin of the branch, and you spot it because its referred_by_id is NULL. Flipping the JOIN around —ON ch.referred_by_id = d.id— and starting from customer 1, you get the opposite: all of Lucía's descendants, of which there are 5 (Carlos, Marta and Inés at level 1; Ana at 2; Núria at 3).

8.3. Generating a series without generate_series

Recursion isn't only for trees: it also serves to produce rows out of nothing — the report's twelve months, with no table involved:

WITH RECURSIVE months AS (
    SELECT DATE '2025-03-01' AS month
    UNION ALL
    SELECT (month + INTERVAL '1 month')::date FROM months WHERE month < DATE '2026-02-01'
)
SELECT to_char(month, 'YYYY-MM') AS month FROM months;

It returns twelve rows, from 2025-03 to 2026-02: the exact skeleton of the monthly report. In PostgreSQL this is written far better with generate_series (03-06), but generate_series doesn't exist in MySQL or SQLite, and this is the portable way to get the same thing. Notice where the stopping condition lives: inside the recursive term, in its own WHERE. Take it out and the query never ends.

  1. Infinite loops and how to protect yourself

The danger of recursion is a cycle in the data: if a data-entry error made customer 1 appear as referred by 13, query 8.2 would go round forever generating rows until it exhausted the temporary disk. Four defences, from the most handmade to the cleanest:

1. The path column with = ANY(...). You accumulate the path travelled in an array and reject anyone already in it:

WITH RECURSIVE chain AS (
    SELECT c.id, c.referred_by_id, ARRAY[c.id] AS path
    FROM   customers AS c WHERE c.id = 13
    UNION ALL
    SELECT r.id, r.referred_by_id, ch.path || r.id
    FROM   customers AS r JOIN chain AS ch ON ch.referred_by_id = r.id
    WHERE  NOT r.id = ANY(ch.path)          -- ← the defence
)
SELECT id, path FROM chain;

2. CYCLE, from PostgreSQL 14 on, which is the same thing written by the engine. It goes after the CTE's closing parenthesis:

) CYCLE id SET is_cycle USING cycle_path
SELECT id, is_cycle, cycle_path FROM chain;

It means: watch the id column, mark TRUE in is_cycle on the row where repetition is detected and stop expanding down that branch; cycle_path holds the route travelled. It's shorter, faster and harder to get wrong.

3. A depth counter, handy when you also want to cap the level: WHERE t.level < 10 in the recursive term. 4. The emergency LIMIT: SELECT * FROM chain LIMIT 1000 stops execution once a thousand rows are reached, because PostgreSQL evaluates recursion lazily. It's a safety net for experimenting, not a solution: use it while developing a recursive query over data you don't know.

Dialect note:

Engine Keyword Detail
PostgreSQL WITH RECURSIVE mandatory CYCLE and SEARCH from 14 on. Without RECURSIVE, a "relation does not exist" error
SQL Server plain WITH RECURSIVE doesn't exist; MAXRECURSION caps it at 100 levels by default
MySQL 8 / MariaDB WITH RECURSIVE Limited by cte_max_recursion_depth (1000 by default)
SQLite WITH RECURSIVE (the word is optional) Full support, including UNION
Oracle WITH RECURSIVE, or the classic CONNECT BY CONNECT BY PRIOR ... START WITH ... predates the standard and is still very much alive, with LEVEL, SYS_CONNECT_BY_PATH and NOCYCLE

Common Mistakes and Tips

  • Forgetting RECURSIVE. Without it, the CTE can't refer to itself: ERROR: relation "tree" does not exist. And it goes after WITH, not in front of the recursive CTE's name.
  • Repeating WITH on every CTE (it's written once; the rest are separated by commas) or referring to a CTE defined further down (only the earlier ones are visible, apart from RECURSIVE's self-reference).
  • Writing a recursive term with no stopping condition. The query never ends. In a tree, the stop is implicit (you run out of children); in a generated series, you have to write it yourself in the WHERE.
  • Ignoring cycles in the data. A hierarchy with a loop hangs the query. CYCLE (PG 14+) or the path column with = ANY(...).
  • Believing a CTE is always an optimization barrier. It was, up to PostgreSQL 11. From 12 on it's inlined if used once; if you want the barrier, ask for it with MATERIALIZED.
  • Using UNION instead of UNION ALL "just in case". It removes duplicates at every step and costs quite a bit more. And writing to the same table from two branches of the same statement: the order isn't guaranteed and the result is unpredictable.
  • Tip: write the query step by step and run it step by step. Replace the final SELECT with SELECT * FROM intermediate_step and check the row counts at each level: 47 → 20 → 12 → 1.
  • Tip: in a recursive query, always start with the base term alone, check that it returns exactly the roots you expect and only then add the UNION ALL. And always accumulate level and path: they cost nothing and they're half the diagnosis when something goes wrong.

Exercises

Exercise 1

Rewrite 07-04's "two aggregates at different granularities" report using CTEs: per customer, number of orders, product revenue, shipping and total. It has to add up to €727.95 + €118.25 = €846.20. (1) Write it with two CTEs (per_order and per_customer) instead of two derived tables. (2) Add a third one computing the grand totals and show them alongside each customer's. (3) Will the CTEs be inlined or materialized? How would you check?

Exercise 2

HR wants, for each employee, how many people they have below them in total (direct and indirect). (1) Write a recursive CTE returning every (manager, subordinate at any depth) pair. (2) Aggregate it to get the count per manager, including with 0 those who have nobody. (3) Check that Rosa comes out with 7 and Andrés with 3.

Exercise 3

A colleague has written this to archive the reviews of discontinued products and doesn't understand the result:

WITH moved AS (
    DELETE FROM reviews AS r USING products AS p
    WHERE p.id = r.product_id AND p.active = FALSE RETURNING r.*
)
SELECT COUNT(*) FROM reviews;
  1. What does the COUNT return, 12 or some other number? Why?
  2. Has anything actually been deleted? How many rows?
  3. Rewrite it so it really archives into a reviews_archive table and returns how many rows it moved.

Solutions

Solution 1

1 and 2:

WITH per_order AS (        -- 20 rows: an order, its products and its shipping
    SELECT o.id AS order_id, o.customer_id, o.shipping_cost,
           ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS products
    FROM   orders AS o JOIN order_lines AS ol ON ol.order_id = o.id
    GROUP  BY o.id, o.customer_id, o.shipping_cost
),
per_customer AS (          -- 12 rows
    SELECT customer_id, COUNT(*) AS orders,
           SUM(products) AS products, SUM(shipping_cost) AS shipping
    FROM   per_order GROUP BY customer_id
),
grand_total AS (           -- 1 row
    SELECT SUM(products) AS products_tot, SUM(shipping) AS shipping_tot FROM per_customer)
SELECT c.name || ' ' || c.last_name AS customer, pc.orders, pc.products, pc.shipping,
       ROUND(pc.products + pc.shipping, 2) AS total, gt.products_tot, gt.shipping_tot
FROM   per_customer AS pc JOIN customers AS c ON c.id = pc.customer_id
CROSS JOIN grand_total AS gt ORDER BY total DESC;
customer orders products shipping total products_tot shipping_tot
Sofia Moreira Costa 2 111.88 19.80 131.68 727.95 118.25
Lucía Martínez Soler 3 107.60 4.95 112.55 727.95 118.25

(First 2 of 12 rows.) The columns add up: 727.95 + 118.25 = €846.20. What solves the problem is the same as in 07-04 —aggregate each thing at its own granularity before joining— but now the steps have names and per_order is defined once and used twice (from per_customer and, through it, from grand_total).

3. per_order is used only once (from per_customer), so it will be inlined; per_customer is used twice, so PostgreSQL will materialize it — and here that's what you want, because computing it twice would mean walking 47 lines twice. You check it with EXPLAIN (ANALYZE, COSTS OFF): if a CTE Scan on per_customer node shows up, it's been materialized.

Solution 2

WITH RECURSIVE descendants AS (
    SELECT e.id AS manager_id, e.id AS emp_id FROM employees AS e
    UNION ALL
    SELECT d.manager_id, e.id
    FROM   employees AS e JOIN descendants AS d ON e.manager_id = d.emp_id
)
SELECT e.id, e.name || ' ' || e.last_name AS employee, e.job_title,
       COUNT(*) - 1 AS total_subordinates
FROM   descendants AS d JOIN employees AS e ON e.id = d.manager_id
GROUP  BY e.id, e.name, e.last_name, e.job_title
ORDER  BY total_subordinates DESC, e.id;
id employee job_title total_subordinates
1 Rosa Alcázar Vives General manager 7
2 Andrés Company Talens Sales manager 3
3 Beatriz Nadal Ripoll Logistics manager 1
4 Óscar Peris Blasco Sales rep 0

(First 4 of 8 rows; employees 5, 6, 7 and 8 also close with 0.) Rosa with 7 and Andrés with 3, as the exercise asked. Two ideas make it work: the base term starts from every employee at once, not just from the root, so each one builds their own subtree; and the COUNT(*) - 1 discounts the (x, x) row in which each employee counts themselves — which is precisely what lets the four with no subordinates show up with 0 instead of disappearing.

Solution 3

1. It returns 12, i.e. the count from before the delete. It's property 1 from section 6: every part of the statement sees the same snapshot, the one from the initial instant. The main SELECT doesn't see the effect of the CTE's DELETE.

2. Something was deleted, but zero rows. The only product with active = FALSE is 20 (Spirulina capsules) and it has no reviews, so the CTE returns the empty set. If the discontinued product were number 1, its 2 reviews would have been deleted and the COUNT would still have said 12 — which is where the exercise's real trap lies. 3. The correct version:

CREATE TABLE reviews_archive (LIKE reviews);   -- ⚠️ NOT canonical
WITH moved AS (
    DELETE FROM reviews AS r USING products AS p
    WHERE  p.id = r.product_id AND p.active = FALSE RETURNING r.*
),
archived AS (
    INSERT INTO reviews_archive SELECT * FROM moved RETURNING id
)
SELECT COUNT(*) AS reviews_archived FROM archived;

Now it works: the DELETE feeds the INSERT, the INSERT returns what it inserted and the final SELECT counts the result of the operation, not the state of a table. It's the complete "move rows" pattern, in one statement and one transaction.

Conclusion

WITH is the tool that turns SQL into something you can read:

  • A CTE is a named subquery placed up front. It lives only for the statement, can be reused within it —which a derived table doesn't allow— and needs no permissions and leaves no trace. Against three levels of nested derived tables, three chained CTEs say the same thing with half the parentheses and are debugged step by step: 47 lines → 20 orders → 12 customers → 1 average, with 5 customers above the €60.66 average.
  • CTE, view or derived table: a throwaway step, derived table; two or more steps or reuse, CTE; something other queries and other people will use, view.
  • Since PostgreSQL 12 a CTE is no longer an optimization barrier: it's inlined if used once and materialized if used several times. AS MATERIALIZED and AS NOT MATERIALIZED force each behaviour, and the EXPLAIN tells you which is happening. Whatever pre-2019 tutorials say about this no longer holds.
  • A WITH can precede an INSERT, UPDATE or DELETE, and a CTE can itself be a write with RETURNING: hence the "move rows" pattern, which deletes from one table and inserts what was deleted into another in a single atomic statement. Every branch sees the same snapshot.
  • WITH RECURSIVE = base term UNION ALL recursive term, iterating until no new rows come out. With it: the full org chart with level and path (8 employees, 3 levels), the three-hop referral chain 1 → 2 → 5 → 13, and a 12-month series generated without generate_series. And cycles in the data, which hang it, are avoided with a path column and = ANY(...), with CYCLE ... SET ... USING ... from PostgreSQL 14 on, with a depth limit or, while you're experimenting, with an emergency LIMIT.

With CTEs you now know how to decompose a query and how to walk a structure. What's left is the gap 04-05 left wide open: aggregating without collapsing the rows. When you want the order total next to each of its lines, the percentage each product represents of overall revenue, each customer's position in a ranking, how much a month changed against the previous one or a quarter's moving average, a GROUP BY is no use: it collapses exactly what you want to keep. In the next lesson, window functions, you'll see the OVER clause that solves all of that at once, the anatomy of PARTITION BY / ORDER BY / frame, why you can't filter by a window function in the WHERE —the most frequent mistake of all— and GreenStore's rankings, running totals and moving averages computed without losing a single one of the 47 lines.

SQL Course

Module 1: Introduction to SQL

Module 2: Basic SQL queries

Module 3: Working with multiple tables

Module 4: Advanced data filtering

Module 5: Data manipulation

Module 6: Advanced SQL functions

Module 7: Subqueries and nested queries

Module 8: Indexes and performance tuning

Module 9: Transactions and concurrency

Module 10: Advanced topics

Module 11: SQL in practice

Module 12: Final project

© Copyright 2026. All rights reserved