For a MongoDB category tree, how do array-of-ancestors and materialized-path documents differ when querying a subtree?
answer
- one stores a chain, one stores an array
- subtree without recursion in both
- equality on an array field, multikey index
- only a left-anchored regex uses the index
- moving a node rewrites every descendant
basics
~20 sAn 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 sWith 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// 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
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.
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.
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.
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