RadhaPublication
HomeInterviewLearning PathsRole TracksMockBooksPricing
Login / Sign UpLog in

© 2026 Radha Publication. All rights reserved.

Shop on AmazonDonateContactPrivacyTerms
SQL questions
MediumSQL#1146 min readJul 11, 2026

Recursive Categories

practice
learning
Practice modeTest yourself instead of reading straight through

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.

🧠 Memory Map
Memory map — visual summary of this topic

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.

How it works under the hood

  1. The anchor member runs first and returns the starting rows, such as a root category or a chosen node.
  2. The database stores those rows in an intermediate working set.
  3. The recursive member runs next, joining the current working rows to the base table to find the next level of children.
  4. Each new batch is added to the final result and also becomes the next input batch.
  5. This repeats until the recursive member returns zero rows. That stopping point is called the fixpoint, meaning the result no longer changes.
  6. If bad data could create a loop, you add a visited-path check so the same node is not visited forever.

When and why to use it

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.

ApproachBest forWeakness
Recursive CTEUnknown depthMore engine work
Self-joinFixed depthStops early
App loopCustom logicMore round trips

Performance and edge cases

  • An index on parent_id is the first thing to add. Without it, each recursion step may scan the whole table again.
  • For a subtree with 1,000 rows, the logical work is close to O(n), but a wide, unindexed tree can feel much slower because every level keeps searching for children.
  • Some engines impose recursion limits. For example, SQL Server defaults to 100 levels unless you change MAXRECURSION; other databases stop when no rows remain, but they can still time out or run out of memory on very deep trees.
  • Cycle protection is important. One bad parent pointer can turn a safe tree walk into an infinite loop or a timeout storm.

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.

SQL
-- 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:

  • How do you return only descendants at a certain depth? Add a 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.
  • How do you find the ancestors of a category instead of its children? Reverse the join: start from the leaf node and join the table where c.id = current.parent_id. The recursion then walks upward to the root and naturally produces a breadcrumb trail.
  • Why do interviewers like 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.
  • How do you control ordering? Use a 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.
  • What index matters most? parent_id. The recursive step repeatedly looks up children by parent, so that index usually gives the biggest win.
  • Can I join other tables inside the recursive member? Yes, as long as you keep the recursion logic clear. For example, you can join category metadata or product counts, but heavy joins inside every recursion step can get expensive fast.
  • Gotcha: does a recursive CTE always prevent infinite loops? No. A recursive CTE is just a mechanism; if your data contains a cycle and you do not guard against it, the query can keep producing rows until the engine stops it.
  • Gotcha: is a recursive CTE always faster than application code? Not always, but it usually wins when the hierarchy is in the database because the engine can optimize the set-based work and avoid many round trips.

Common Mistakes:

  • Using a plain self-join for unknown depth. Correction: self-joins are only good when the number of levels is fixed; use a recursive CTE when the tree can grow.
  • Forgetting the anchor member. Correction: every recursive CTE needs a starting query, or the recursion has nothing to build from.
  • Using UNION by default. Correction: use UNION ALL unless you have a specific deduplication reason; it is the standard pattern for recursion.
  • Not indexing parent_id. Correction: the recursive step depends on that lookup, so the index is often the difference between fast and painful.
  • Ignoring cycles and bad data. Correction: add a visited-path guard or another cycle check if the hierarchy is not perfectly trusted.

Memory Hook: Anchor plants the seed, recursion grows the tree, and the query stops when the tree has no more branches.

Cheat Sheet:

  • Recursive CTE = a CTE that references itself.
  • Anchor member = starting rows.
  • Recursive member = next wave of rows.
  • UNION ALL is the normal choice.
  • parent_id index helps a lot.
  • Cycles need protection; empty anchor means empty result.

Practice Tasks:

  • Write a recursive CTE that lists all descendants of a chosen category.
  • Rewrite it to return ancestors and build a breadcrumb path.
  • Add a cycle guard and then test what happens when the root category does not exist.
Previous
Back to Questions←→to navigate
Next

Recommended Resources

Recommended Book

Cracking the Coding Interview

189 Programming Questions and Solutions

Recommended Book

Cracking the Coding Interview

189 Programming Questions and Solutions

-- 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;