Postgres CTEs: how I query tree structures without touching application code
← Back
April 4, 2026Database7 min read

Postgres CTEs: how I query tree structures without touching application code

Published April 4, 20267 min read

I had a category tree for an e-commerce site — categories nested up to six levels deep. To fetch a category's full path (Home > Electronics > Computers > Laptops), I was making up to six database queries in a loop, or loading the whole tree into memory. Then I discovered recursive CTEs and replaced all of it with a single SQL query. Here is how it works.

The schema

sql
CREATE TABLE categories (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL,
    parent_id INTEGER REFERENCES categories(id),
    slug TEXT NOT NULL
);

Non-recursive CTEs: named subqueries

Before the recursive case, regular CTEs clean up complex queries by naming intermediate results:

sql
-- Non-recursive CTE: readable multi-step query
WITH
recent_orders AS (
    SELECT user_id, COUNT(*) AS order_count, SUM(amount_cents) AS total
    FROM orders
    WHERE created_at > NOW() - INTERVAL '90 days'
    GROUP BY user_id
),
high_value_users AS (
    SELECT user_id
    FROM recent_orders
    WHERE total > 100000  -- > $1000
),
churned_users AS (
    SELECT id AS user_id
    FROM users
    WHERE last_login < NOW() - INTERVAL '60 days'
)
SELECT u.email, ro.order_count, ro.total
FROM users u
JOIN recent_orders ro ON ro.user_id = u.id
WHERE u.id IN (SELECT user_id FROM high_value_users)
  AND u.id NOT IN (SELECT user_id FROM churned_users);

Recursive CTEs: traversing trees

sql
-- Get the full path from a leaf category to the root
-- E.g., Laptops -> Computers -> Electronics -> Home

WITH RECURSIVE category_path AS (
    -- Base case: start at the leaf category
    SELECT 
        id,
        name,
        parent_id,
        1 AS depth,
        ARRAY[name] AS path_names,
        ARRAY[id] AS path_ids
    FROM categories
    WHERE id = $1  -- Starting category ID
    
    UNION ALL
    
    -- Recursive case: join to parent
    SELECT
        c.id,
        c.name,
        c.parent_id,
        cp.depth + 1,
        ARRAY[c.name] || cp.path_names,
        ARRAY[c.id] || cp.path_ids
    FROM categories c
    JOIN category_path cp ON cp.parent_id = c.id
)
SELECT path_names, path_ids, depth
FROM category_path
ORDER BY depth DESC
LIMIT 1;  -- Get the full path (deepest row has the complete path)

Get all descendants of a category

sql
-- Get all subcategories under "Electronics" (any depth)
WITH RECURSIVE subcategories AS (
    -- Base: the root category
    SELECT id, name, parent_id, 0 AS depth
    FROM categories
    WHERE id = $1  -- Electronics ID
    
    UNION ALL
    
    -- Recursive: all children
    SELECT c.id, c.name, c.parent_id, s.depth + 1
    FROM categories c
    JOIN subcategories s ON s.id = c.parent_id
)
SELECT id, name, depth,
       REPEAT('  ', depth) || name AS indented_name  -- Visual indentation
FROM subcategories
ORDER BY depth, name;

Organizational hierarchy: employees under a manager

sql
CREATE TABLE employees (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL,
    manager_id INTEGER REFERENCES employees(id),
    department TEXT,
    salary INTEGER
);

-- Get all direct and indirect reports under a manager
-- Plus aggregated stats per level
WITH RECURSIVE org_chart AS (
    SELECT id, name, manager_id, department, salary, 0 AS level
    FROM employees
    WHERE id = $1  -- Manager ID
    
    UNION ALL
    
    SELECT e.id, e.name, e.manager_id, e.department, e.salary, oc.level + 1
    FROM employees e
    JOIN org_chart oc ON oc.id = e.manager_id
    WHERE oc.level < 10  -- Safety: prevent infinite loops on bad data
)
SELECT
    level,
    COUNT(*) AS headcount,
    ROUND(AVG(salary)) AS avg_salary,
    SUM(salary) AS total_salary_cost
FROM org_chart
WHERE level > 0  -- Exclude the manager themselves
GROUP BY level
ORDER BY level;

Finding the shortest path in a graph

sql
CREATE TABLE connections (
    from_user INTEGER,
    to_user INTEGER,
    PRIMARY KEY (from_user, to_user)
);

-- Find shortest connection path between two users (like LinkedIn degrees)
WITH RECURSIVE paths AS (
    SELECT 
        from_user AS start_user,
        to_user AS current_user,
        ARRAY[from_user, to_user] AS path,
        1 AS hops
    FROM connections
    WHERE from_user = $1  -- Starting user
    
    UNION ALL
    
    SELECT
        p.start_user,
        c.to_user,
        p.path || c.to_user,
        p.hops + 1
    FROM connections c
    JOIN paths p ON p.current_user = c.from_user
    WHERE c.to_user != ALL(p.path)  -- Avoid cycles
      AND p.hops < 6               -- Max 6 degrees of separation
)
SELECT path, hops
FROM paths
WHERE current_user = $2  -- Target user
ORDER BY hops
LIMIT 1;

Performance tips

  • Add WHERE depth < N or WHERE hops < N as a safety guard against infinite recursion on bad data
  • Index parent_id (or manager_id, from_user) — every recursive step is a join on this column
  • For very deep trees (1000+ levels), consider the ltree extension instead
  • Use EXPLAIN ANALYZE on your recursive queries — they can hide expensive scans

Before recursive CTEs, I maintained a redundant path column (like 1/5/23/) to enable ancestor queries. That worked but required updating the path on every hierarchy change. Recursive CTEs eliminated that maintenance burden entirely.

Share this
← All Posts7 min read