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.

Advanced120–160 minutes$merge/$out materialized-view safety labMongoDB 8.3.8 · mongosh 2.10.0Last reviewed: September 2026

Learning objectives

01

Distinguish $merge document-level upsert/update semantics from $out whole-collection replacement semantics.

02

Build an idempotent materialized-view refresh keyed by a unique business identifier.

03

Demonstrate the stale-row problem when a deterministic $merge pipeline no longer emits a previously materialized key.

04

Use a full $out rebuild only against a disposable/derived target and verify target state before switching consumers.

05

Protect source collections with naming/permission/guardrail conventions and design ETL to be restartable after partial failure.

Reproducible lab baseline

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.

Evidence, not screenshots

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

bash · isolated Chapter 09 Lesson 5 lab setup
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}))' 
javascript · seed source orders and unique materialized-view key
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()});
Ownership contract

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.

javascript · restartable merge refresh and identical rerun
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({}));
text · expected initial materialized state
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

javascript · add one paid order and refresh the affected key
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"}));
text · expected tenant-a Aug02 row
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.

javascript · create and observe a stale materialized row
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"}));
Repair choices

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.

javascript · full rebuild of the derived target
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"}));
text · expected post-rebuild invariant
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.

javascript · safe simulation of destructive $out replacement
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({}));
Guardrail pattern

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.

Transactions and write-back stages

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 $merge creates 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 $out rebuild 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.

bash · cleanup / full reset
docker rm -f atlasmart-mongo-ch09-l5docker volume rm atlasmart-mongo-ch09-l5-data

Check your understanding

  1. What is the fundamental difference between $merge and $out?
  2. Why can an idempotent $merge refresh still leave stale rows?
  3. Why is a unique index important for a custom merge on key?
  4. What happens to an existing $out target if the rebuild fails before replacement?
  5. 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.

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.