skip to content

For a MongoDB category tree, how do array-of-ancestors and materialized-path documents differ when querying a subtree?

level: seniorimportance: should knowfreq 36%

answer

  1. one stores a chain, one stores an array
  2. subtree without recursion in both
  3. equality on an array field, multikey index
  4. only a left-anchored regex uses the index
  5. moving a node rewrites every descendant

basics

~20 s

An array of ancestors makes a subtree an equality match on an indexed array — find({ ancestors: "books" }). A materialized path stores the chain as one string and needs an anchored prefix regex. Both rewrite every descendant when a node moves.

solid answer

~50 s

With the **array of ancestors**, each node stores every ancestor id in an array, so the whole subtree under a node is `db.categories.find({ ancestors: "books" })` — a plain equality predicate served by a multikey index on `ancestors`, with no regex and no string parsing. With a **materialized path**, each node stores the chain as a delimited string like `",books,programming,"`, and the subtree is a **left-anchored** regex, `{ path: /^,books,programming,/ }`, which is the only regex form that can use an index prefix; the bonus is that sorting by `path` yields depth-first order for free. Both denormalize the whole chain, so moving a node means rewriting the `ancestors` array or `path` string of every descendant — an `updateMany`, and with paths you can do the rewrite in one aggregation-pipeline update using `$replaceOne` on MongoDB 5.0+. Parent references avoid that cost entirely (a move touches one field on one document) but make subtree reads recursive.

code

javascript · 7 lines
javascript
// array of ancestors: subtree is a plain equality match
db.categories.createIndex({ ancestors: 1 })
db.categories.find({ ancestors: "books" })

// materialized path: subtree needs a left-anchored regex
db.categories.createIndex({ path: 1 })
db.categories.find({ path: /^,root,books,/ }).sort({ path: 1 })

go deeper

for a junior

Recall the two shapes: an array holding every ancestor id, or one delimited string holding the chain, both stored on each node so a subtree can be found without walking level by level.

for a middle

Write both subtree queries correctly — equality on the multikey ancestors array, a left-anchored regex on the path — and explain why the anchor is what lets the index help.

for a senior

Reason about the write side: a move rewrites every descendant, the rewrite is not atomic across documents, and the ordering and delimiter properties of paths trade against the query simplicity of ancestors.

for a principal

Decide the tree representation from the read/edit ratio and the tree's growth, own the move procedure and its consistency check, and know when parent references plus recursive reads beat any denormalized chain.

## The four document shapes MongoDB's guidance names several ways to store a tree, and each puts the cost in a different place. **Parent reference**: `{ _id: "programming", parent: "books" }`. Direct children of a node are `{ parent: "books" }`. A whole subtree requires walking level by level, or a recursive traversal stage. **Child references**: `{ _id: "books", children: ["programming", "fiction"] }`. Direct children are free, upward traversal is not, and the array grows with fan-out. **Array of ancestors**: `{ _id: "programming", parent: "books", ancestors: ["root", "books"] }`. The full chain to the root is denormalized into an array. **Materialized path**: `{ _id: "programming", path: ",root,books," }`. The same chain, flattened into one delimited string. The last two are the ones asked about together, because both make a subtree query a single indexed predicate — but by different mechanisms with different secondary properties. ## Subtree queries compared With ancestors, the query is an equality match on an array field: ```javascript db.categories.createIndex({ ancestors: 1 }) db.categories.find({ ancestors: "books" }) ``` Because `ancestors` is an array, the index is multikey — one entry per element — and equality on an array field matches if *any* element equals the value. This is the cleanest possible form: no regex, no string escaping, no delimiter conventions, and it composes with other predicates in a compound index, e.g. `{ ancestors: 1, isActive: 1 }`. With a materialized path, the query is a prefix match: ```javascript db.categories.createIndex({ path: 1 }) db.categories.find({ path: /^,root,books,/ }) ``` A **left-anchored, case-sensitive** regex can use an index to seek to the matching range; an unanchored pattern like `/books/` or a case-insensitive one cannot exploit an ordinary index in the same way and degrades toward a scan. This is the detail interviewers probe, because candidates often write the unanchored form and assume the index still helps. The compensation is ordering: because the path string encodes the whole lineage, `sort({ path: 1 })` returns a subtree already in depth-first order, which is exactly what a rendered navigation tree needs. Ancestors give you no such ordering; you sort by an explicit `depth` or `order` field, or assemble the tree in application code from a flat result set. ## Delimiters and correctness Materialized paths need discipline. Wrap the path in the delimiter at both ends (`,books,`), otherwise a prefix match for `,book` also matches `,bookstore,`. Choose a delimiter that cannot occur in an id, and if ids are user-supplied, escape them or use surrogate keys. None of this applies to ancestors, where element boundaries are structural rather than lexical — a genuine robustness argument in the pattern's favour. ## The cost of moving a node Both patterns denormalize the chain, so relocating a subtree rewrites every descendant. With ancestors: ```javascript db.categories.updateMany( { ancestors: "books" }, [ { $set: { ancestors: { $concatArrays: [ ["root", "media"], … ] } } } ] ) ``` which in practice is easier to do by re-deriving each descendant's chain. With paths, one update pipeline can rewrite the prefix of every descendant directly, using the `$replaceOne` string expression available from MongoDB 5.0: ```javascript db.categories.updateMany( { path: /^,root,books,/ }, [ { $set: { path: { $replaceOne: { input: "$path", find: ",root,books,", replacement: ",root,media,books," } } } } ] ) ``` That is a real advantage of the path form: the move is expressible as one server-side statement. Either way the write volume is proportional to subtree size, and neither is atomic across documents without a transaction, so a move interrupted halfway leaves a partially rewritten tree — which is why moves are usually queued, single-writer, and followed by a consistency check. Parent references invert this completely: a move is `$set: { parent: newParent }` on one document, and nothing else changes. If your tree is edited constantly and read rarely, that is the right shape, and recursive reads are the price. ## Choosing Ask how the tree is used. Read-dominated with rare edits — a product category tree — favours a denormalized chain, and between the two, ancestors for query simplicity and safety, paths when you want free depth-first ordering or single-statement prefix rewrites. Edit-heavy trees favour parent references. Many production schemas carry `parent` *alongside* `ancestors` or `path`, because direct-children queries and breadcrumbs are both common and the extra field is cheap. ## How to answer it Give both queries verbatim, name the anchored-regex requirement, note the free depth-first ordering paths give you, then state the shared cost — a move rewrites every descendant — and the parent-reference alternative that avoids it. That sequence covers the read side, the write side and the judgment call.

  • Why must the materialized-path regex be left-anchored?
    Because an index on a string field is ordered lexically, so a left-anchored, case-sensitive pattern like /^,root,books,/ becomes a bounded range seek. An unanchored pattern such as /books/ or a case-insensitive one cannot be turned into a range and degrades toward scanning the index or the collection, which erases the reason for choosing the pattern.
  • Why keep a parent field when you already store ancestors?
    Because the two answer different questions cheaply. `{ parent: "books" }` returns exactly the direct children for rendering one level of a menu, while `{ ancestors: "books" }` returns the entire subtree. The parent field also makes a move simpler to reason about and gives you a source of truth from which the denormalized chain can be rebuilt if it drifts.
  • What does it cost to move a subtree, and how do you make that safe?
    Every descendant's `ancestors` array or `path` string must be rewritten, so write volume is proportional to subtree size and the operation is not atomic across documents outside a transaction. Make moves single-writer or queued, do the rewrite in one `updateMany` where possible, and follow with a verification pass that re-derives chains from the `parent` links.

saying these in an interview costs you the question

  • Writes an unanchored regex and expects index use
  • Forgets delimiters, so ,book matches ,bookstore
  • Thinks MongoDB updates descendants automatically on a move
  • Claims a parent-reference move also rewrites descendants
  • Uses ancestors for an edit-heavy tree with rare reads

context