MongoDB $merge Stage

Beginner
6 min read
Updated: Oct 2026
4 examples
Live preview

What Is $merge?

$merge is an aggregation output stage that writes pipeline results into a collection with upsert-like behavior— insert new rows, update matched rows, or skip them—without wiping the target.

Remember
$merge = "upsert pipeline results into a collection"

{
  $merge: {
    into: "daily_sales",
    on: "date",
    whenMatched: "merge",
    whenNotMatched: "insert"
  }
}

into            → target collection
on              → match key (default _id)
whenMatched     → merge | replace | keepExisting | fail
whenNotMatched  → insert | discard | fail

MUST be the LAST stage
vs $out → $out replaces the whole collection
UsePrefer
Incremental ETL / upsert summaries$merge
Replace entire collection snapshot$out
Filter before writing$match then $merge

How to Use $merge in MongoDB

Build your result documents, then end the pipeline with $merge.

  1. Shape results with $match, $group, $project.
  2. End with { $merge: { into: "…" } } (must be last).
  3. Set on if you match on a business key instead of _id.
  4. Pick whenMatched / whenNotMatched for insert vs update vs skip.
OptionRole
intoTarget collection (string or { db, coll })
onMatch field(s); default _id
whenMatchedWhat to do if a target doc matches
whenNotMatchedWhat to do if no target doc matches

Pseudocode

Pseudocode
assert: $merge is the last stage

for each pipeline document:
  existing = find in "into" where on-fields match

  if existing found:
    apply whenMatched
      merge         → combine fields (new wins on clash)
      replace       → replace whole document
      keepExisting  → leave target unchanged
      fail          → abort
  else:
    apply whenNotMatched
      insert  → insert pipeline document
      discard → skip
      fail    → abort

Cheat Sheet

Keep these facts handy for ETL and summary tables.

TopicDetail
TypeTerminal aggregation output stage
PositionMust be last
Default matchon: "_id"
Default matchedwhenMatched: "merge"
Default unmatchedwhenNotMatched: "insert"
vs $outUpsert rows vs replace whole collection
Index tipUnique index on on fields

Live Preview

Simulate merging daily totals into a summary collection (teaching model—not a live MongoDB write).

Target starts with 2026-10-01. Pipeline brings 2026-10-01 (update) and 2026-10-02 (new).

Target after merge
2026-10-01  total: 150  note: kept+merged
2026-10-02  total: 80   note: inserted

Worked Walkthrough — Daily Sales Upsert

Goal: group orders by date, then upsert into daily_sales on date.

Calculation
target daily_sales:
  { date: "2026-10-01", total: 100, source: "east" }

pipeline result:
  { date: "2026-10-01", total: 150 }   ← matches on date
  { date: "2026-10-02", total: 80 }    ← no match

whenMatched: "merge"
  → { date: "2026-10-01", total: 150, region: "east" }

whenNotMatched: "insert"
  → insert { date: "2026-10-02", total: 80 }

target keeps unrelated rows; no full collection drop

MongoDB Examples

We use orders line items and a summary collection daily_sales. Seed them once in Example 1, then run each $merge pipeline. Copy each block into mongosh.

Example 1 — Sample Data

Imagine order rows for two dates plus one existing summary row for 2026-10-01 with an extra region field.

mongosh
db.orders.drop()
db.daily_sales.drop()

db.daily_sales.insertOne({
  date: "2026-10-01",
  total: 100,
  region: "east"
})

db.orders.insertMany([
  { date: "2026-10-01", amount: 50 },
  { date: "2026-10-01", amount: 100 },
  { date: "2026-10-02", amount: 80 }
])

How the Data Is Stored in MongoDB

After the inserts, MongoDB keeps source orders and a target summary row. Run find() on each collection:

mongosh
db.orders.find().sort({ date: 1 })
db.daily_sales.find().sort({ date: 1 })

Read the merge key and amounts like this:

Collection / fieldExamplePlain English
orders.date"2026-10-01"Group key on the source side
orders.amount50, 100, 80Line amounts summed in the pipeline
daily_sales.date"2026-10-01"Match key for $merge (on: "date")
daily_sales.region"east"Extra target field kept when whenMatched: "merge"
Picture
Pipeline will produce (before $merge):

  { date: "2026-10-01", total: 150 }   ← matches target row
  { date: "2026-10-02", total: 80 }    ← new date

Target already has 2026-10-01 with region: "east"

How It Works

  1. Reset collections. drop() clears old tutorial runs.
  2. Seed the target. One summary row shows how merge updates existing keys.
  3. Seed orders. Three line items across two dates for grouping.
  4. find() confirms shape. Ready for $merge in Examples 2–4.

Example 2 — Default Upsert

Goal: group sales from Example 1 by date and merge into daily_sales (insert new dates, merge fields on existing dates).

mongosh
db.orders.aggregate([
  {
    $group: {
      _id: "$date",
      total: { $sum: "$amount" }
    }
  },
  {
    $project: {
      _id: 0,
      date: "$_id",
      total: 1
    }
  },
  {
    $merge: {
      into: "daily_sales",
      on: "date",
      whenMatched: "merge",
      whenNotMatched: "insert"
    }
  }
])

db.daily_sales.find().sort({ date: 1 })

How It Works

  1. Group. Sum amount values from Example 1 orders per date.
  2. Project. Rename _id → date so on: "date" works.
  3. Match key. Existing 2026-10-01 merges; total updates.
  4. Insert new. 2026-10-02 has no target row → insert.

Example 3 — Replace on Key

Goal: overwrite the whole matched document (drop fields not in the pipeline result). Re-runs the same grouping on orders from Example 1.

mongosh
db.orders.aggregate([
  {
    $group: {
      _id: "$date",
      total: { $sum: "$amount" }
    }
  },
  {
    $project: {
      _id: 0,
      date: "$_id",
      total: 1
    }
  },
  {
    $merge: {
      into: "daily_sales",
      on: "date",
      whenMatched: "replace",
      whenNotMatched: "insert"
    }
  }
])

db.daily_sales.find({ date: "2026-10-01" })
// region field is gone — replace swapped the whole doc

How It Works

  1. Same pipeline. Group and project as before.
  2. replace. Matched target row is fully swapped.
  3. Fields drop. Extra target fields (like region) disappear.
  4. Still incremental. Unrelated target dates stay; unlike $out.

Example 4 — Update-Only Discard

Goal: update existing summary rows only—never insert new dates. Orders still come from Example 1; this demo resets the merge target first.

mongosh
// Reset target for this demo only (orders unchanged from Example 1)
db.daily_sales.drop()
db.daily_sales.insertOne({ date: "2026-10-01", total: 0 })

db.orders.aggregate([
  {
    $group: {
      _id: "$date",
      total: { $sum: "$amount" }
    }
  },
  {
    $project: {
      _id: 0,
      date: "$_id",
      total: 1
    }
  },
  {
    $merge: {
      into: "daily_sales",
      on: "date",
      whenMatched: "merge",
      whenNotMatched: "discard"
    }
  }
])

db.daily_sales.find().sort({ date: 1 })
// 2026-10-01 updated; 2026-10-02 discarded (not inserted)

How It Works

  1. Target has one key. Only 2026-10-01 exists.
  2. Matched. That date merges the new total.
  3. discard. 2026-10-02 has no match → skipped.
  4. Use case. Refresh known keys without growing the target.

Edge Cases & Pitfalls

Check these before writing production ETL pipelines.

last

Must be the last stage

Nothing may follow $merge. Put filters and projections before it.

on

Match key must exist on both sides

Pipeline docs need the on fields. Project them explicitly if $group put the key in _id.

unique

Prefer a unique index on on

Duplicate match keys make merge behavior unpredictable. Create a unique index on business keys you merge on.

out

Do not confuse with $out

$out replaces the whole collection. $merge keeps existing rows and upserts.

Performance Notes

ApproachCostBest for
$match before $mergeFewer writesIncremental windows
Unique index on onFaster lookupsBusiness-key merges
Large full refresh via $mergeMany upsertsPrefer $out for full replace
Custom whenMatched pipelineExtra per-doc workComplex field merges only

Write less: filter early, merge on indexed keys, and use $out when you truly want a full snapshot.

Key Takeaways

  • Role: upsert aggregation results into a collection.
  • Rules: must be last; match with on.
  • Modes: whenMatched / whenNotMatched control insert vs update.
  • vs $out: incremental upsert vs full collection replace.

One line: $merge answers “how do I write pipeline results into a collection without wiping it?”

Frequently Asked Questions

$merge writes aggregation results into a target collection with upsert-like behavior. It can insert new documents when no match exists and update matched documents using whenMatched (merge, replace, keepExisting, fail, or a custom pipeline).
$out replaces the entire target collection. $merge upserts row-by-row based on on fields and whenMatched / whenNotMatched. Use $merge for incremental ETL; use $out for full snapshot exports.
whenMatched runs when a pipeline document matches a target doc: merge (default), replace, keepExisting, fail, or a custom pipeline with $$new. whenNotMatched runs when there is no match: insert (default), discard, or fail.
Yes. $merge is a terminal output stage—nothing can follow it. It writes to the target collection and does not pass documents to a next stage.
on is the match key in the target collection—default _id. For business keys use on: "sku" or on: ["date", "region"]. Those fields should be unique (or uniquely indexed) for predictable merges.
Your user needs permission to insert and/or update documents in the target collection, depending on whenMatched and whenNotMatched.

Next: $out

Write the full pipeline result as a new collection snapshot (replace semantics).

$out tutorial →

About the author

Mari Selvan M P
Mari Selvan M P 🔗

Developer, cloud engineer, and technical writer

  • Experience 12 years building web and cloud systems
  • Focus Full Stack Development, AWS, and Developer Education

I write practical tutorials so students and working developers can learn by doing—from databases and APIs to deployment on AWS.

10 people found this page helpful