Module 8: Recursive CTEs + Final Project

Closure table: the alternative when a recursive CTE isn't enough

A recursive CTE is elegant but does work on every query: it traverses the tree each time. For hierarchies that change rarely and are queried a lot, you can pre-compute all the ancestor-descendant relationships in a closure table. Each query is then a simple SELECT (not recursive), with the trade-off: you have to maintain the closure table when the hierarchy changes.

This capsule gives you the complete pattern, a decision matrix vs the recursive CTE, and maintenance via triggers.


The concept

Schema with a recursive CTE:

categories
├── id
├── name
└── parent_id  -- FK to self (adjacency list)

Every subtree or ancestors query does a recursive traversal.

Schema with a closure table:

categories (same)
├── id
├── name
└── parent_id

category_closure  -- pre-calculated relationships
├── ancestor_id    (an ancestor)
├── descendant_id  (a descendant)
└── depth          (how many levels between them)

Every ancestor-descendant relationship is stored. For a 4-level tree, Electronics → Laptop:

ancestor_id | descendant_id | depth
-----------+---------------+-------
Electronics| Electronics   |  0     (self)
Electronics| Computers     |  1
Electronics| Laptops       |  2
Computers  | Computers     |  0
Computers  | Laptops       |  1
Laptops    | Laptops       |  0

Each node has a "self" row (depth 0). Each relationship is stored explicitly.

Queries are simple:

-- Subtree of Electronics: SELECT from the closure table
SELECT cat.* FROM categories cat
JOIN category_closure cc ON cat.id = cc.descendant_id
WHERE cc.ancestor_id = 1  -- Electronics
ORDER BY cc.depth;

-- Ancestors of Laptops
SELECT cat.* FROM categories cat
JOIN category_closure cc ON cat.id = cc.ancestor_id
WHERE cc.descendant_id = 3  -- Laptops
ORDER BY cc.depth DESC;

No WITH RECURSIVE. Just a SELECT. A typical query plan.


Trade-off

AspectRecursive CTEClosure table
StorageOnly categories+ closure table (can be large)
Query speedRecursive each timeSimple SELECT (fast)
Insert/updateTrivial (only categories)Triggers or app-managed
Hierarchy changesTrivialMaintenance overhead
Code complexityVerbose recursive queryMore complex schema, simple queries

The closure table wins when:

  • The hierarchy changes rarely (e.g. blog categories edited occasionally).
  • Hierarchy queries are very frequent.
  • Performance is critical (latency-sensitive).

The recursive CTE wins when:

  • The hierarchy changes frequently.
  • Queries are occasional.
  • You want schema simplicity.

Complete setup

-- Categories (adjacency list, same as before)
CREATE TABLE categories (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    parent_id INTEGER REFERENCES categories(id)
);

-- Closure table
CREATE TABLE category_closure (
    ancestor_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
    descendant_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
    depth INTEGER NOT NULL,
    PRIMARY KEY (ancestor_id, descendant_id)
);

-- Indexes for fast queries
CREATE INDEX idx_closure_ancestor ON category_closure(ancestor_id);
CREATE INDEX idx_closure_descendant ON category_closure(descendant_id);

Initialize the closure table from existing data

If you already have categories populated and want to add the closure:

-- Self-rows (every node is its own ancestor, depth 0)
INSERT INTO category_closure (ancestor_id, descendant_id, depth)
SELECT id, id, 0 FROM categories;

-- Direct parents (depth 1)
INSERT INTO category_closure (ancestor_id, descendant_id, depth)
SELECT parent_id, id, 1 FROM categories WHERE parent_id IS NOT NULL;

-- Transitive ancestors (depth 2+)
-- Using a recursive CTE! Ironic — use a recursive CTE once to populate the closure
WITH RECURSIVE all_ancestors AS (
    SELECT ancestor_id, descendant_id, depth FROM category_closure
    UNION
    SELECT cc.ancestor_id, ccc.descendant_id, cc.depth + ccc.depth
    FROM category_closure cc
    JOIN category_closure ccc ON cc.descendant_id = ccc.ancestor_id
    WHERE cc.depth + ccc.depth NOT IN (
        SELECT depth FROM category_closure
        WHERE ancestor_id = cc.ancestor_id AND descendant_id = ccc.descendant_id
    )
)
INSERT INTO category_closure
SELECT ancestor_id, descendant_id, depth FROM all_ancestors
ON CONFLICT DO NOTHING;

Simpler: repopulate completely with a CTE:

TRUNCATE category_closure;

WITH RECURSIVE pairs AS (
    SELECT id AS ancestor_id, id AS descendant_id, 0 AS depth FROM categories
    UNION ALL
    SELECT p.ancestor_id, c.id AS descendant_id, p.depth + 1
    FROM pairs p
    JOIN categories c ON c.parent_id = p.descendant_id
)
INSERT INTO category_closure (ancestor_id, descendant_id, depth)
SELECT ancestor_id, descendant_id, depth FROM pairs;

A full repopulate is simpler and more robust.


Queries with the closure table

Subtree (descendants)

-- All descendants of Electronics, including self
SELECT cat.id, cat.name, cc.depth
FROM categories cat
JOIN category_closure cc ON cat.id = cc.descendant_id
WHERE cc.ancestor_id = 1  -- Electronics
ORDER BY cc.depth, cat.name;

Output similar to a recursive CTE but with a trivial query plan:

Hash Join
  ->  Index Scan using idx_closure_ancestor on category_closure
        Index Cond: (ancestor_id = 1)
  ->  Hash on categories

Much faster than recursive — just one indexed join.

Ancestors

-- All ancestors of Laptops, ordered from root to node
SELECT cat.id, cat.name, cc.depth
FROM categories cat
JOIN category_closure cc ON cat.id = cc.ancestor_id
WHERE cc.descendant_id = 3  -- Laptops
ORDER BY cc.depth DESC;

Path as a string

-- Generate the full path
SELECT string_agg(cat.name, ' > ' ORDER BY cc.depth DESC) AS path
FROM categories cat
JOIN category_closure cc ON cat.id = cc.ancestor_id
WHERE cc.descendant_id = 3;

Direct children only (depth 1)

SELECT cat.* FROM categories cat
JOIN category_closure cc ON cat.id = cc.descendant_id
WHERE cc.ancestor_id = 1 AND cc.depth = 1;  -- only direct children

Maintenance: triggers

When you insert/delete a row in categories, you have to update category_closure. Triggers automate this.

INSERT trigger

When a new node is inserted, add all its ancestor relationships:

CREATE OR REPLACE FUNCTION insert_closure() RETURNS TRIGGER AS $$
BEGIN
    -- Self row (depth 0)
    INSERT INTO category_closure (ancestor_id, descendant_id, depth)
    VALUES (NEW.id, NEW.id, 0);

    -- If it has a parent, add all relationships from the parent's ancestors
    IF NEW.parent_id IS NOT NULL THEN
        INSERT INTO category_closure (ancestor_id, descendant_id, depth)
        SELECT cc.ancestor_id, NEW.id, cc.depth + 1
        FROM category_closure cc
        WHERE cc.descendant_id = NEW.parent_id;
    END IF;

    RETURN NEW;
END;
$$ LANGUAGE plpgsql;


CREATE TRIGGER trigger_insert_closure
AFTER INSERT ON categories
FOR EACH ROW EXECUTE FUNCTION insert_closure();

DELETE trigger

ON DELETE CASCADE on the FK already deletes closure rows when you delete a category. But there's also something to consider:

  • If you delete an INTERMEDIATE node, the rows connecting ancestors via that node are also deleted (cascade handles it correctly).
  • If you want to "re-parent" (move children to the grandparent), that's more complex logic (typically app-managed).

UPDATE trigger (parent change)

If parent_id changes, you have to update the closure:

CREATE OR REPLACE FUNCTION update_closure() RETURNS TRIGGER AS $$
BEGIN
    IF OLD.parent_id IS DISTINCT FROM NEW.parent_id THEN
        -- Delete the old relationships
        DELETE FROM category_closure
        WHERE descendant_id IN (
            SELECT descendant_id FROM category_closure WHERE ancestor_id = NEW.id
        )
        AND ancestor_id IN (
            SELECT ancestor_id FROM category_closure
            WHERE descendant_id = NEW.id AND ancestor_id != NEW.id
        );

        -- Repopulate (simpler)
        WITH RECURSIVE new_pairs AS (
            SELECT NEW.id AS ancestor_id, NEW.id AS descendant_id, 0 AS depth
            UNION ALL
            -- ... recursive logic ...
        )
        INSERT INTO category_closure ...;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

UPDATE triggers are MORE COMPLEX. Frequently simpler: repopulate the entire closure when the hierarchy changes:

async def re_parent_category(session, cat_id: int, new_parent_id: int):
    cat = await session.get(Category, cat_id)
    cat.parent_id = new_parent_id
    await session.commit()

    # Repopulate the closure (simple, robust)
    await session.execute(text("TRUNCATE category_closure"))
    await session.execute(text("""
        INSERT INTO category_closure (ancestor_id, descendant_id, depth)
        WITH RECURSIVE pairs AS (
            SELECT id AS aid, id AS did, 0 AS d FROM categories
            UNION ALL
            SELECT p.aid, c.id, p.d + 1
            FROM pairs p JOIN categories c ON c.parent_id = p.did
        )
        SELECT aid, did, d FROM pairs
    """))
    await session.commit()

Trade-off: a full repopulate is slow for large trees (millions of nodes). For typical trees (hundreds to thousands), trivial.


Detailed decision matrix

Use a recursive CTE if:

  • The hierarchy is relatively small (<10k nodes).
  • The hierarchy changes frequently.
  • Hierarchy queries are occasional (not critical path).
  • You want a simple schema.

Use a closure table if:

  • The hierarchy is large (>10k nodes).
  • The hierarchy changes rarely (taxonomies, categories).
  • Hierarchy queries are very frequent (on the critical path).
  • Performance is critical.

Typical cases:

  • Blog/e-commerce categories: closure table — stable taxonomy, frequent queries.
  • Comment threads: recursive CTE — comments change constantly.
  • Org chart: depends — closure table for frequent "who reports to X" queries; recursive CTE if reorgs are frequent.
  • Filesystem-like (folders): closure table — folders rarely moved, "what's in this folder" queries very frequent.

Materialized view as a compromise

A third option: a materialized view over a recursive CTE. It combines the advantages:

CREATE MATERIALIZED VIEW category_paths AS
WITH RECURSIVE paths AS (
    SELECT id, name, parent_id, name::TEXT AS path, 0 AS depth
    FROM categories WHERE parent_id IS NULL
    UNION ALL
    SELECT c.id, c.name, c.parent_id, p.path || ' > ' || c.name, p.depth + 1
    FROM categories c JOIN paths p ON c.parent_id = p.id
)
SELECT * FROM paths;

-- Indexes
CREATE UNIQUE INDEX idx_paths_id ON category_paths(id);
CREATE INDEX idx_paths_path_trgm ON category_paths USING gin (path gin_trgm_ops);

-- Refresh when the hierarchy changes
REFRESH MATERIALIZED VIEW CONCURRENTLY category_paths;

Trade-off:

  • Simpler than a closure table (fewer triggers).
  • Refresh takes time (O(n) in categories).
  • Allows periodic manual refresh, not automatic per-change.

For apps with a low frequency of changes, an MV is a good compromise.


Common traps and mistakes

1. Closure table without self-rows.

-- ❌ Forgetting self-rows (depth 0)
-- Result: subtree queries don't include the node itself

Self-rows are MANDATORY. Triggers must create them.

2. Complex triggers for a parent_id UPDATE.

UPDATE triggers are fragile. More robust: repopulate the entire closure when a parent changes (if the tree isn't huge).

3. Closure table without foreign keys.

-- ❌ Without an FK, the closure can become inconsistent
ancestor_id INTEGER NOT NULL,
descendant_id INTEGER NOT NULL
-- ✅ FK with CASCADE
ancestor_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE

4. Closure table without indexes.

-- Without indexes, subtree queries are a seq scan
CREATE INDEX idx_closure_ancestor ON category_closure(ancestor_id);
CREATE INDEX idx_closure_descendant ON category_closure(descendant_id);

Both indexes are mandatory.

5. Migrating from adjacency list to closure without testing.

A closure table is a more complex schema. Isolation tests are critical to catch inconsistencies between categories and category_closure.

6. Closure table on a hierarchy that changes a lot.

If the hierarchy changes 1000 times/day, closure maintenance is overhead that a recursive CTE doesn't have. Re-evaluate the approach.

7. Storage assumption.

A closure table of a balanced 4-level tree with 10k nodes: ~50k rows in the closure. For 1M nodes: ~5M rows. Verify the storage requirements.

8. Performance assumption.

Closure table queries are fast but NOT instant. For very frequent queries with large datasets, consider additional caching (Redis).


Exercise: implement a closure table

Setup:

-- Categories and closure table
CREATE TABLE categories (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    parent_id INTEGER REFERENCES categories(id)
);

CREATE TABLE category_closure (
    ancestor_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
    descendant_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
    depth INTEGER NOT NULL,
    PRIMARY KEY (ancestor_id, descendant_id)
);

CREATE INDEX idx_closure_ancestor ON category_closure(ancestor_id);
CREATE INDEX idx_closure_descendant ON category_closure(descendant_id);

-- Data
INSERT INTO categories (id, name, parent_id) VALUES
    (1, 'Electronics', NULL),
    (2, 'Computers', 1),
    (3, 'Laptops', 2),
    (4, 'Smartphones', 1);

Step 1: populate the closure table.

WITH RECURSIVE pairs AS (
    SELECT id AS aid, id AS did, 0 AS d FROM categories
    UNION ALL
    SELECT p.aid, c.id, p.d + 1
    FROM pairs p JOIN categories c ON c.parent_id = p.did
)
INSERT INTO category_closure SELECT aid, did, d FROM pairs;

-- Verify
SELECT * FROM category_closure ORDER BY ancestor_id, depth, descendant_id;

Step 2: simple queries vs a recursive CTE.

-- Subtree of Electronics
-- A. With the closure table
SELECT cat.name, cc.depth
FROM categories cat
JOIN category_closure cc ON cat.id = cc.descendant_id
WHERE cc.ancestor_id = 1
ORDER BY cc.depth;

-- B. With a recursive CTE
WITH RECURSIVE x AS (
    SELECT id, name, 0 AS depth FROM categories WHERE id = 1
    UNION ALL
    SELECT c.id, c.name, x.depth + 1
    FROM categories c JOIN x ON c.parent_id = x.id
)
SELECT name, depth FROM x ORDER BY depth;

Compare the plans with EXPLAIN ANALYZE. Closure is the simpler plan.

Step 3: trigger for INSERT.

CREATE OR REPLACE FUNCTION insert_closure() RETURNS TRIGGER AS $$
BEGIN
    INSERT INTO category_closure (ancestor_id, descendant_id, depth)
    VALUES (NEW.id, NEW.id, 0);

    IF NEW.parent_id IS NOT NULL THEN
        INSERT INTO category_closure (ancestor_id, descendant_id, depth)
        SELECT cc.ancestor_id, NEW.id, cc.depth + 1
        FROM category_closure cc
        WHERE cc.descendant_id = NEW.parent_id;
    END IF;

    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trigger_insert_closure
AFTER INSERT ON categories
FOR EACH ROW EXECUTE FUNCTION insert_closure();

Step 4: insert a new category and verify the closure auto-updated.

INSERT INTO categories (name, parent_id) VALUES ('Tablets', 1);

-- Verify the closure
SELECT * FROM category_closure
WHERE descendant_id = (SELECT id FROM categories WHERE name = 'Tablets');

Step 5: compare performance.

For 10k categories:

  • Closure subtree query: ~5ms
  • Recursive CTE: ~20-50ms

A significant difference for very frequent queries.

See discussion

Step 1: the populate generates all pairs. Verify:

  • Self-rows: 4 (one per category).
  • Direct relationships: 3 (Computers→Laptops, Electronics→Computers, etc.).
  • Transitive: 1 (Electronics→Laptops, depth 2). Total: 8 rows in the closure.

Step 2: both return the same result. Plans:

  • Closure: simple Hash Join + Index Scan.
  • Recursive CTE: Recursive Union + iterated Index Scans.

Step 3-4: the trigger keeps the closure automatic. Inserting Tablets adds 2 rows: (Tablets, Tablets, 0) and (Electronics, Tablets, 1).

Step 5: closure is ~5x faster for subtree queries on large datasets.

Key lessons:

  1. Closure table = pre-compute the relationships.
  2. Simple queries vs recursive.
  3. Trade-off: more complex schema, maintenance via triggers.
  4. Useful for stable hierarchies with frequent queries.

Summary and next step

What you learned:

  • Closure table: pre-calculated ancestor-descendant relationships.
  • Schema: ancestor_id, descendant_id, depth + indexes on both.
  • Self-rows mandatory (depth 0).
  • Simple queries vs recursive — faster but more maintenance.
  • Triggers for INSERT (DELETE via CASCADE; UPDATE via repopulate).
  • Decision matrix: stable + frequent queries → closure. Changes a lot + occasional queries → CTE.
  • Third option: a materialized view over a recursive CTE.

In the next capsule we start the final integrating project: a refactor of the Blog API. Capsule 06 covers phase 1 (setup, baseline, 3 components: JSONB, FTS, MV).


Resources

  1. Bill Karwin — SQL Antipatterns book — chapter dedicated to hierarchies.
  2. PostgreSQL Wiki — Hierarchical patterns — discussion of approaches.
  3. Closure Table on Wikipedia — general reference.
  4. Markus Winand — Hierarchical Queries — technical analysis.

Capsule 05 of 08 — Module 8 — Advanced PostgreSQL for Backend Guide