Build restartable materialized-view refreshes with $merge and $out while exposing stale keys, partial writes, whole-target replacement, source protection, and rollback strategy.
$merge and $out for Materialized Views, ETL, Rebuilds, and Operational Safety
Batch heterogeneous writes safely, interpret partial success, compare ordered and unordered execution, and use modern cross-namespace bulk APIs without assuming all-or-nothing behavior.
Learning objectives
Distinguish $merge document-level upsert/update semantics from $out whole-collection replacement semantics.
Build an idempotent materialized-view refresh keyed by a unique business identifier.
Demonstrate the stale-row problem when a deterministic $merge pipeline no longer emits a previously materialized key.
Use a full $out rebuild only against a disposable/derived target and verify target state before switching consumers.
Protect source collections with naming/permission/guardrail conventions and design ETL to be restartable after partial failure.
This lesson pins MongoDB Community Server
8.3.8 with
mongodb/mongodb-community-server:8.3.8-ubuntu2204-slim
and mongosh 2.10.0. The server is a disposable
standalone published only on loopback
127.0.0.1:27061. Authentication and TLS are
disabled only for this isolated lab. Feature Compatibility
Version (FCV) and allowDiskUseByDefault are
observed but never changed. Default read/write concern and
primary read preference apply. Atlas, Search, KMS, Enterprise
Advanced, and paid services are not required. Runtime output
shown as “expected” is documentation-derived because this
generation environment has no Docker/mongod/mongosh runtime.
The exact optimizer tree, execution counters, spill fields, and stage-specific explain shape can vary with patch version, FCV, indexes, data distribution, and topology. The lesson therefore names the invariant to verify—matched documents, traversal set/depth, facet counts, window values, indexes used, disk-use evidence, and target collection state—instead of requiring byte-for-byte explain output.
1. Write-back changes the failure model
Previous aggregation lessons were read-only: a bad pipeline
wasted resources or returned the wrong answer, but did not
rewrite collections. $merge and
$out are write-back stages and must be last in
their pipeline. $merge matches output documents to
target documents and can insert, merge, replace, keep, fail, or
run a restricted update pipeline depending on its options.
$out builds a new result collection and, if the
target already exists, atomically replaces that target
collection upon successful completion.
| Stage | Primary behavior |
|---|---|
| $merge |
Incrementally writes documents into a target according to
on, whenMatched, and
whenNotMatched.
|
| $out | Replaces the entire target collection with the pipeline result after a successful build; existing indexes are copied to the replacement. |
| Materialized view | Persisted derived collection rebuilt or incrementally refreshed from source data. |
| Restartable ETL | A job that can be rerun after interruption without duplicating or corrupting logical results. |
| Stale row | A target document that remains even though the current source pipeline no longer emits its key. |
| Source protection | Permissions, naming, guard clauses, and deployment separation preventing a write-back stage from targeting authoritative source data. |
2. Seed authoritative orders and a derived target
docker rm -f atlasmart-mongo-ch09-l5 2>/dev/null || truedocker volume rm atlasmart-mongo-ch09-l5-data 2>/dev/null || truedocker run -d --name atlasmart-mongo-ch09-l5 \ -p 127.0.0.1:27061:27017 \ -v atlasmart-mongo-ch09-l5-data:/data/db \ mongodb/mongodb-community-server:8.3.8-ubuntu2204-slimmongosh "mongodb://127.0.0.1:27061/atlasmart?directConnection=true" --quiet --eval \'printjson({server:db.version(),hello:db.hello().isWritablePrimary}); printjson(db.getSiblingDB("admin").runCommand({getParameter:1,featureCompatibilityVersion:1,allowDiskUseByDefault:1}))'
const src=db.orders_ch09_l5;const mv=db.daily_sales_mv_ch09_l5;src.drop(); mv.drop(); db.orders_ch09_l5_victim.drop();src.insertMany([ {_id:"o1",tenantId:"tenant-a",status:"paid",createdAt:ISODate("2026-08-01T10:00:00Z"),totalCents:1000}, {_id:"o2",tenantId:"tenant-a",status:"paid",createdAt:ISODate("2026-08-01T12:00:00Z"),totalCents:1500}, {_id:"o3",tenantId:"tenant-a",status:"paid",createdAt:ISODate("2026-08-02T10:00:00Z"),totalCents:2000}, {_id:"o4",tenantId:"tenant-a",status:"cancelled",createdAt:ISODate("2026-08-02T11:00:00Z"),totalCents:9000}, {_id:"o5",tenantId:"tenant-b",status:"paid",createdAt:ISODate("2026-08-01T09:00:00Z"),totalCents:700}, {_id:"o6",tenantId:"tenant-b",status:"paid",createdAt:ISODate("2026-08-03T09:00:00Z"),totalCents:800}]);mv.createIndex({tenantId:1,day:1},{unique:true});printjson({source:src.countDocuments({}),targetIndexes:mv.getIndexes()});
orders_ch09_l5 is authoritative source data.
daily_sales_mv_ch09_l5 is disposable derived
state. A safe operating procedure must make that difference
obvious in names, roles/permissions, backups, and code review.
3. $merge: deterministic upsert by tenant and day
The pipeline groups paid orders and uses
[tenantId, day] as the materialized-view identity.
The target has a unique index on those exact fields, which is
required for this non-_id on key.
whenMatched:"replace" makes the emitted aggregate
document authoritative for that key;
whenNotMatched:"insert" creates new days.
function refreshWithMerge(){ return src.aggregate([ {$match:{status:"paid"}}, {$group:{_id:{tenantId:"$tenantId",day:{$dateToString:{format:"%Y-%m-%d",date:"$createdAt",timezone:"UTC"}}},orderCount:{$sum:1},revenueCents:{$sum:"$totalCents"}}}, {$project:{_id:0,tenantId:"$_id.tenantId",day:"$_id.day",orderCount:1,revenueCents:1}}, {$merge:{into:"daily_sales_mv_ch09_l5",on:["tenantId","day"],whenMatched:"replace",whenNotMatched:"insert"}} ]).toArray();}refreshWithMerge();printjson(mv.find({}).sort({tenantId:1,day:1}).toArray());refreshWithMerge();print("after identical rerun",mv.countDocuments({}));
tenant-a 2026-08-01 -> orderCount=2 revenueCents=2500tenant-a 2026-08-02 -> orderCount=1 revenueCents=2000tenant-b 2026-08-01 -> orderCount=1 revenueCents=700tenant-b 2026-08-03 -> orderCount=1 revenueCents=800A second identical refresh keeps the same four logical keys.
4. Incremental recomputation updates emitted keys
src.insertOne({_id:"o7",tenantId:"tenant-a",status:"paid",createdAt:ISODate("2026-08-02T15:00:00Z"),totalCents:500});refreshWithMerge();printjson(mv.findOne({tenantId:"tenant-a",day:"2026-08-02"}));
tenant-a / 2026-08-02 -> orderCount=2, revenueCents=2500
This is restartable because the output for a given key is
deterministic and replace overwrites that key with
the current aggregate. It is not “exactly once” magic; the
target’s unique key and deterministic recomputation make retries
converge for emitted keys.
5. Edge case: $merge does not delete keys that disappear
Delete the only source order for tenant-b on August 3, then
rerun the same merge. The aggregation emits no tenant-b/Aug03
document, so $merge has nothing to match and the
old materialized row remains stale.
src.deleteMany({tenantId:"tenant-b",createdAt:{$gte:ISODate("2026-08-03T00:00:00Z"),$lt:ISODate("2026-08-04T00:00:00Z")}});refreshWithMerge();print("source tenant-b Aug03",src.countDocuments({tenantId:"tenant-b",createdAt:{$gte:ISODate("2026-08-03T00:00:00Z"),$lt:ISODate("2026-08-04T00:00:00Z")}}));print("materialized row still exists?",mv.countDocuments({tenantId:"tenant-b",day:"2026-08-03"}));
A streaming/incremental system needs explicit deletion/tombstone logic or a source-of-truth reconciliation process. For a small rebuildable view, a full replacement may be simpler and safer.
6. $out full rebuild removes stale keys
Because the target is disposable derived state, a full rebuild
can replace it from the complete source query. When an existing
target is replaced, MongoDB builds a temporary collection,
copies the existing indexes, inserts the new results, and then
renames the temporary collection over the target. If the build
fails, the pre-existing target remains unchanged.
$out cannot target a sharded output collection; use
$merge for sharded targets.
src.aggregate([ {$match:{status:"paid"}}, {$group:{_id:{tenantId:"$tenantId",day:{$dateToString:{format:"%Y-%m-%d",date:"$createdAt",timezone:"UTC"}}},orderCount:{$sum:1},revenueCents:{$sum:"$totalCents"}}}, {$project:{_id:0,tenantId:"$_id.tenantId",day:"$_id.day",orderCount:1,revenueCents:1}}, {$out:"daily_sales_mv_ch09_l5"}]);printjson(mv.find({}).sort({tenantId:1,day:1}).toArray());print("stale tenant-b Aug03 rows",mv.countDocuments({tenantId:"tenant-b",day:"2026-08-03"}));
tenant-b / 2026-08-03 no longer exists in the materialized view.The unique {tenantId,day} index remains on the replaced target.
7. Controlled destructive demonstration on a disposable victim
Never demonstrate source destruction on the real source.
Instead, make a disposable victim copy and show what collection
replacement means: the second $out replaces the
victim with only paid rows. This is exactly why a mistaken
target name is dangerous.
src.aggregate([{$out:"orders_ch09_l5_victim"}]);const victim=db.orders_ch09_l5_victim;print("victim before",victim.countDocuments({}));src.aggregate([{$match:{status:"paid"}},{$out:"orders_ch09_l5_victim"}]);print("victim after replacement with only paid rows",victim.countDocuments({}));print("source unchanged",src.countDocuments({}));
Production ETL should allow-list target namespaces, use credentials that cannot write source collections, reject target==source in orchestration code, record row/key counts before and after, and keep a tested rollback/restore path. A comment saying “do not point this at production” is not a control.
8. $merge failure and partial-success semantics
$merge is not one giant transaction over every
output document. Current documentation warns that if an error
occurs after some writes have completed, those writes are not
automatically rolled back. Unique-index or validation failures
therefore demand restartable/idempotent design and target-state
verification. A safer rebuild strategy often writes to a staging
target, validates counts/checksums/invariants, then performs a
controlled promotion rather than incrementally mutating the only
serving copy.
Aggregation pipelines containing $out or
$merge cannot be used inside transactions. Do not
plan to “wrap the whole rebuild in a transaction” as the
rollback strategy.
9. Operational checklist for materialized views
| Area | Required evidence/control |
|---|---|
| Source boundary | Write credentials cannot modify authoritative source namespaces. |
| Determinism | Same source snapshot/state produces the same target key/value set. |
| Identity |
Unique index exactly matches the
$merge on fields when not using default
_id.
|
| Freshness | Timestamp/watermark and lag metric show how stale the materialized view may be. |
| Reconciliation | Full rebuild or comparison job detects stale/missing/incorrect target rows. |
| Failure recovery | Retry behavior is idempotent; partial writes are expected and verified rather than assumed rolled back. |
| Promotion | If staging is used, serving cutover has a rollback target and validation gate. |
| Topology |
Sharded target requirements are checked;
$out is not used for a sharded target.
|
| Search indexes |
If a target has Atlas Search indexes,
$out has additional operational consequences;
prefer a design that explicitly manages them.
|
| Observability | Input/output counts, duration, spill/temp I/O, write errors, validation errors, and target invariants are recorded per run. |
10. Verification, cleanup, and production judgment
Verification checklist
-
The initial
$mergecreates four expected tenant/day keys. - An identical rerun does not create duplicate logical keys.
- Adding one order updates the existing tenant-a/Aug02 aggregate.
- Deleting the only tenant-b/Aug03 source row exposes a stale-row limitation of merge-only refresh.
-
A full
$outrebuild removes the stale row while preserving target indexes. - The destructive replacement demonstration targets only a disposable victim collection; the authoritative source count remains unchanged.
Production judgment. $merge is
appropriate for incremental or keyed materialized-view updates
when the identity is unique, recomputation is deterministic,
stale-key deletion is handled, and retries are safe.
$out is appropriate for full replacement of a
disposable derived collection when whole-target rebuild cost is
acceptable and the output is not sharded. Neither provides
business freshness automatically. Measure rebuild duration,
source lag, partial-write errors, validation failures, target
cardinality/checksums, and serving-query correctness. Separate
source and target privileges, test restore/promotion, and price
temporary I/O plus duplicate storage during rebuilds.
Chapter 10 moves to index fundamentals. The advanced pipelines in this chapter make the motivation concrete: join, sort, and selective-prefix performance depend on indexes whose field order, uniqueness, and multikey behavior must be designed deliberately.
docker rm -f atlasmart-mongo-ch09-l5docker volume rm atlasmart-mongo-ch09-l5-data
Check your understanding
- What is the fundamental difference between $merge and $out?
- Why can an idempotent $merge refresh still leave stale rows?
- Why is a unique index important for a custom merge on key?
- What happens to an existing $out target if the rebuild fails before replacement?
- Why is “put it in a transaction” not a valid rollback plan for $merge/$out?
Review the answers
$merge writes matching/nonmatching documents according to keyed policies; $out replaces the entire target collection with the pipeline result after successful completion.
If a key disappears from the source result, no output document reaches $merge for that key, so an old target document can remain unless deletion/reconciliation logic handles it.
MongoDB requires a unique index matching non-_id on fields so each output document maps to at most one target identity.
MongoDB leaves the pre-existing target collection unchanged; replacement occurs only after the new result collection has been built successfully.
Aggregation pipelines containing $merge or $out cannot be used inside transactions, and merge writes completed before an error are not automatically rolled back.
Authoritative references
- MongoDB 8.3 release notes — Current 8.3 behavior and version-sensitive aggregation changes; re-check before reproducing.
- MongoDB aggregation pipeline — Ordered-stage execution model used throughout the chapter.
- Aggregation pipeline limits — Memory, disk-spill, stage-count, and 16 MiB output-document constraints.
- mongosh release notes — mongosh version used for the chapter commands.
- $merge stage — Materialized-view updates, on/whenMatched/whenNotMatched semantics, unique-index requirements, partial-write behavior, sharded targets, and restrictions.
- $out stage — Whole-collection replacement process, index copying, failure behavior, target restrictions, and transaction restrictions.
- On-demand materialized views — Materialized-view concepts and refresh patterns.
- Schema validation — Validation interactions for write-back targets.