Hook: Recursive category queries are like following a breadcrumb trail down a store aisle: interviewers love this because it checks whether you can move from one row to a whole tree.
Question: How do you fetch an entire category hierarchy, not just one level, in SQL?
Answer: Use a recursive CTE. The first SELECT grabs the starting category, called the anchor member, and the second SELECT keeps joining each row to its children until no more rows are found. This is the standard way to walk parent-child structures like categories, menus, and org charts.
Interview-Ready Answer: I’d use a recursive CTE, because it lets me start from one category and repeatedly join to its children until the tree is exhausted. The anchor query gives me the root row, and the recursive part follows parent_id to collect every descendant. I’d also add an index on parent_id, and if the data might contain bad cycles, I’d protect the recursion so it cannot loop forever.
Detailed Explanation: A recursive CTE is a query that feeds its own output back into itself. For category trees, the usual table shape is an adjacency list, which simply means each row stores its own id and a parent_id pointing to its parent.
Use recursive CTEs when the depth is not known in advance: product categories, menus, permission trees, bill of materials, comment threads, and breadcrumbs. A normal self-join only works when you know the number of levels ahead of time; recursive SQL keeps going as long as there are more children.
| Approach | Best for | Weakness |
|---|---|---|
| Recursive CTE | Unknown depth | More engine work |
| Self-join | Fixed depth | Stops early |
| App loop | Custom logic | More round trips |
parent_id is the first thing to add. Without it, each recursion step may scan the whole table again.O(n), but a wide, unindexed tree can feel much slower because every level keeps searching for children.MAXRECURSION; other databases stop when no rows remain, but they can still time out or run out of memory on very deep trees.Memory hook: think anchor = first step, recursive member = keep climbing, and stop when there are no more stairs.
Real-World Story: In an e-commerce catalog service, category trees power the left-nav menu, search filters, and SEO landing pages. A developer once used a simple self-join, so only the first child level appeared; deeper categories like Laptops > Gaming Laptops were missing from the site, and users thought inventory was gone. In a worse incident, a bad admin import created a cycle between two categories, and the recursive query without a visited-path guard kept looping until the database hit a recursion limit or timeout. The symptoms were ugly: catalog API latency jumped from milliseconds to seconds, CPU on the primary database spiked, logs filled with timeout errors, and the checkout funnel lost traffic because people could not browse to products. The fix was to add a proper recursive CTE, add an index on parent_id, and guard against cycles so one bad row could not take down the whole page.
-- PostgreSQL example: recursive categories with cycle protection and a small edge case.
-- The table uses the common "adjacency list" design: each row points to its parent.
DROP TABLE IF EXISTS categories;
CREATE TABLE categories (
id INT PRIMARY KEY,
name TEXT NOT NULL,
parent_id INT NULL REFERENCES categories(id)
);
-- Helpful for the recursive join: we repeatedly search by parent_id.
CREATE INDEX idx_categories_parent_id ON categories(parent_id);
INSERT INTO categories (id, name, parent_id) VALUES
(1, 'Electronics', NULL),
(2, 'Computers', 1),
(3, 'Laptops', 2),
(4, 'Desktops', 2),
(5, 'Phones', 1),
(6, 'Smartphones', 5),
(7, 'Feature Phones', 5),
(8, 'Accessories', 1),
(9, 'Chargers', 8),
(10, 'Cases', 8);
-- 1) Descendants: start at Electronics and walk downward.
-- The anchor row is the starting point.
-- The path array is a simple visited list so bad data cannot loop forever.
WITH RECURSIVE category_tree AS (
SELECT
id,
name,
parent_id,
0 AS depth,
ARRAY[id] AS path
FROM categories
WHERE id = 1
UNION ALL
SELECT
c.id,
c.name,
c.parent_id,
ct.depth + 1 AS depth,
ct.path || c.id AS path
FROM categories c
JOIN category_tree ct
ON c.parent_id = ct.id
WHERE NOT c.id = ANY(ct.path)
)
SELECT id, name, parent_id, depth, path
FROM category_tree
ORDER BY path;
-- 2) Ancestors: start at Laptops and walk upward to the root.
-- This is the same idea in reverse: follow parent_id instead of child links.
WITH RECURSIVE ancestors AS (
SELECT
id,
name,
parent_id,
0 AS depth
FROM categories
WHERE id = 3
UNION ALL
SELECT
c.id,
c.name,
c.parent_id,
a.depth + 1 AS depth
FROM categories c
JOIN ancestors a
ON c.id = a.parent_id
)
SELECT id, name, parent_id, depth
FROM ancestors
ORDER BY depth;
-- 3) Edge case: asking for a missing root returns zero rows, not an error.
-- That is useful to know in interviews: recursion stops immediately when the anchor is empty.
WITH RECURSIVE missing_root AS (
SELECT
id,
name,
parent_id,
0 AS depth
FROM categories
WHERE id = 999
UNION ALL
SELECT
c.id,
c.name,
c.parent_id,
mr.depth + 1 AS depth
FROM categories c
JOIN missing_root mr
ON c.parent_id = mr.id
)
SELECT *
FROM missing_root;Follow-up & Tricky Questions:
depth column and filter it in the outer query, for example WHERE depth <= 2. That keeps the recursion simple and lets you control how much of the tree you expose.c.id = current.parent_id. The recursion then walks upward to the root and naturally produces a breadcrumb trail.UNION ALL here? Because recursion usually wants every row produced by each step, including rows that happen to look similar. UNION removes duplicates and adds extra sorting/dedup work, which can hide data problems and slow the query down.depth column for breadth-first style output, or a path column for a tree-like depth-first order. The order is not automatic just because the query is recursive.parent_id. The recursive step repeatedly looks up children by parent, so that index usually gives the biggest win.Common Mistakes:
UNION by default. Correction: use UNION ALL unless you have a specific deduplication reason; it is the standard pattern for recursion.parent_id. Correction: the recursive step depends on that lookup, so the index is often the difference between fast and painful.Memory Hook: Anchor plants the seed, recursion grows the tree, and the query stops when the tree has no more branches.
Cheat Sheet:
UNION ALL is the normal choice.parent_id index helps a lot.Practice Tasks: