skip to content

Compare the four common ways to store a tree in a relational database — parent pointers, a materialized path string, nested sets with left/right numbering, and an ancestor-descendant pairs table — and explain how you would choose between them for a category tree that is read constantly and edited occasionally.

level: seniorimportance: should knowfreq 42%

answer

  1. axis = duplicate structure to speed reads
  2. parent_id: O(1) write, recursive read
  3. path: prefix scan, subtree rewrite on move
  4. nested sets: range scan, renumber half the table
  5. closure: both directions, n×depth storage

basics

~20 s

Parent pointers: cheapest writes, traversal needs recursion. Materialized path: stores the ancestor chain in a string, fast downward prefix reads, rewrites the whole subtree on a move. Nested sets: left/right numbers make subtree reads one range scan but almost any insert renumbers much of the table. Pairs table: fast both directions, expensive writes and O(n×depth) storage. Read-heavy, edit-rarely favors path or pairs.

solid answer

~60 s

They trade read cost against write cost. - **Parent pointers (adjacency list)** — one edge per row. O(1) insert, delete and move; every non-trivial read needs a recursive query. Normalized, always correct. - **Materialized path** — each row stores its ancestor chain, e.g. `/1/7/23/`. Subtree reads are one indexable prefix match; ancestors come free by parsing the string. A move rewrites the path of every descendant, and the string is denormalized data you must keep in sync. - **Nested sets** — preorder left/right numbers make "descendants of X" the range `left BETWEEN x.left AND x.right`, and subtree size is arithmetic. But any insert or move renumbers a large fraction of rows and serializes concurrent writers. Good for near-static trees. - **Closure / pairs table** — a row per ancestor-descendant pair. Both directions indexed, no recursion; writes are O(ancestors × subtree) and storage is O(n × depth). For read-heavy, rarely-edited categories I'd keep `parent_id` as the source of truth and derive either a path column or a closure table, maintained transactionally. Path if queries are downward-only and depth is bounded; closure if I need indexed upward queries or containment checks in joins.

code

sql · 13 lines
sql
-- adjacency list
WITH RECURSIVE t AS (
  SELECT id, parent_id FROM node WHERE id = :x
  UNION ALL
  SELECT n.id, n.parent_id FROM node n JOIN t ON n.parent_id = t.id
) SELECT * FROM t;

-- materialized path (node :x has path '/1/7/')
SELECT * FROM node WHERE path LIKE '/1/7/%' ORDER BY path;

-- nested sets
SELECT c.* FROM node p JOIN node c ON c.lft BETWEEN p.lft AND p.rgt
WHERE p.id = :x;

go deeper

for a junior

Name the four patterns and the one-line read/write trade for each; you are not expected to choose between them under pressure.

for a middle

Give concrete costs — which operation touches how many rows in each pattern — and pick sensibly for a stated read/write mix.

for a senior

Drive the answer from access patterns and edit frequency, propose keeping parent_id as truth with a derived structure maintained transactionally, and name the concurrency and drift risks.

for a principal

Frame it as a reversible-decision question: derived structures can be rebuilt, so choose the cheapest correct model first, measure, and only then buy read speed with a maintenance obligation — including the caching alternative that avoids the schema change entirely.

## The single axis Every hierarchy pattern answers one question: how much structural information do I duplicate so reads get cheaper? Parent pointers duplicate nothing and pay on every read. The other three duplicate progressively more and pay on writes. Choosing is not about elegance, it is about which side of that trade your workload sits on. ## Parent pointers (adjacency list) A nullable self-referencing `parent_id`. One row, one edge. Insert, delete and re-parent are single-row operations; a subtree move is literally one UPDATE, because descendants keep pointing at their unchanged parents. Reads of arbitrary depth require a recursive query, costing one index lookup per level. Storage is minimal and the model is fully normalized, so it cannot be internally inconsistent — the only structural risk is a cycle, which the FK does not prevent. Best when: writes are frequent, subtrees move, depth is modest, and "children of X" is the dominant read. ## Materialized path Each row carries the chain of ancestor keys as a delimited string or an array — `/1/7/23/` or `{1,7,23}`. "Everything under node 7" becomes `WHERE path LIKE '/1/7/%'`, an index range scan if the index supports prefix matching and the collation cooperates (a leading wildcard would not be indexable, which is why the delimiter and the anchored prefix matter). Ancestors are already in the string, so rendering breadcrumbs needs no extra query. Sorting siblings in tree order is a plain `ORDER BY path`. Depth is `length(path) - length(replace(path,'/',''))` or simply a stored column. Costs: the path is denormalized — the same edge is encoded in every descendant, so a move rewrites the path of the entire moved subtree (an indexed `UPDATE ... WHERE path LIKE 'old%'` with string replacement, plus index maintenance on every touched row). Path length bounds depth in practice. Deleting or renaming a key that appears inside paths is a bulk rewrite. And unless you enforce it, the path can disagree with `parent_id`. Best when: reads are overwhelmingly downward, depth is bounded, sibling ordering matters, and moves are rare. ## Nested sets Walk the tree in preorder, stamping each node with a `lft` number on the way down and an `rgt` number on the way back up. A node's descendants are exactly the rows whose `lft` falls strictly between the node's `lft` and `rgt`, so a subtree read is one index range scan with no join and no recursion. The number of descendants is `(rgt - lft - 1) / 2` — pure arithmetic. Leaves are rows where `rgt = lft + 1`. The cost is brutal on writes: inserting one node in the middle requires shifting the `lft`/`rgt` values of every node to its right, which on average is half the table. Two concurrent inserts both renumber overlapping ranges, so writers serialize and the pattern is prone to corruption if any write path forgets the renumbering. Getting the direct parent is also awkward — you need the ancestor with the largest `lft`, or a redundant `parent_id` column, which most real implementations end up keeping anyway. Best when: the tree is effectively read-only or rebuilt in bulk (a published taxonomy, a BOM snapshot), and subtree aggregate reads dominate. ## Closure / ancestor-descendant pairs table A side table of `(ancestor_id, descendant_id, depth)` covering every transitive pair plus self-rows. Downward and upward queries are both single indexed lookups because both columns are indexed; containment is one probe, so hierarchy predicates compose naturally into larger joins. Insert of a leaf costs depth + 2 rows; a subtree move costs roughly (new ancestor count × subtree size) rows deleted and inserted. Storage is O(n × average depth) — fine for balanced trees, quadratic for degenerate chains. Best when: reads dominate, *both* directions are queried (permission inheritance, org reporting), and depth is bounded. ## Choosing for a read-heavy, occasionally-edited category tree Start from the access patterns, not the pattern names: 1. **Which reads are hot?** Rendering the tree and "all products in this category and below" are downward. Breadcrumbs are upward but tiny and often cached. Permission/containment checks joined into other queries argue for closure. 2. **How often and how big are the edits?** "Occasionally" for a merchandiser dragging a category is still an interactive operation — a nested-set renumber of half the table under a lock is a bad user experience even once a day. That mostly rules out nested sets. 3. **Is depth bounded?** Category trees usually are (5–7 levels). Both path and closure are comfortable there. 4. **Can I keep one source of truth?** Yes: keep `parent_id`, and derive the read structure. Derived data can be rebuilt, verified and changed later without a data migration of the truth. The defensible answer: `parent_id` as truth, plus a materialized path column if the read set is downward-only and you want cheap tree-ordered rendering; plus a closure table instead if you need indexed upward queries or want hierarchy predicates to compose into joins the optimizer can cost. Maintain the derived structure in the same transaction as the edit — trigger or single service method — and add a reconciliation job that recomputes it from `parent_id` and alerts on drift. And the honest first move before any of this: measure whether a recursive query over an indexed `parent_id`, possibly with an application-level cache of the rendered tree, already meets the latency budget. A category tree is small, changes rarely, and caching the whole thing in memory often beats every schema trick.

  • Why do nested sets behave so badly under concurrent writes?
    Inserting or moving a node shifts the lft/rgt numbers of every node to its right in the preorder sequence, which on average is half the table. Two concurrent writers therefore update overlapping row sets and must serialize, and a writer that fails partway leaves the numbering inconsistent in a way no constraint detects. That is why nested sets suit near-static trees that are rebuilt in bulk rather than edited interactively.
  • If you add a materialized path column next to parent_id, how do you stop the two from disagreeing?
    Treat parent_id as the source of truth and the path as derived: compute the path in a trigger or in the single service method that performs structural writes, inside the same transaction as the parent_id change, so a partial update cannot commit. On a move, rewrite the path of the whole moved subtree with one prefix-replacement UPDATE rather than row-by-row. Back it with a periodic job that recomputes paths from parent_id and reports mismatches, since derived data drifts the moment any write path bypasses the intended one.
  • When would you not adopt any of the read-optimized patterns?
    When measurement shows the recursive query over an indexed parent_id already meets the latency budget, or when the tree is small enough to cache whole in the application and invalidate on edit. Category trees are typically a few thousand nodes changing a few times a day, which is a caching problem rather than a schema problem. Adding a derived structure buys read speed at the price of a permanent consistency obligation, so it should follow evidence, not anticipation.

saying these in an interview costs you the question

  • Recommending nested sets for a tree that is edited interactively
  • Believing a materialized path makes moves cheap because "you just update the string"
  • Treating the derived structure as the source of truth instead of parent_id
  • Claiming recursive queries are always too slow without measuring on the actual tree size
  • Assuming a path prefix match is indexable regardless of leading wildcards or collation

context