MongoDB $changeStream Stage

Beginner
⏱️ 11 min read
📚 Updated: Jul 2026
🎯 5 Examples
Aggregation

What You’ll Learn

The $changeStream stage turns an aggregation pipeline into a live feed of database changes—inserts, updates, deletes, and more—so apps can react instantly instead of polling.

01

Real-time events

Watch collection changes.

02

First stage only

Pipeline must start here.

03

operationType

insert, update, delete…

04

fullDocument

Whole doc on updates.

05

Resume tokens

Survive reconnects.

06

watch() helper

Preferred in mongosh.

Definition and Usage

$changeStream is an aggregation stage that returns a tailable change stream cursor instead of a one-shot result set. Each event describes what changed, when, and on which namespace (ns.db, ns.coll).

💡
Beginner tip

Change streams need a replica set or sharded cluster—they do not work on a default standalone mongod. For local dev, convert to a single-member replica set. The cursor blocks until a change occurs (or you close it).

Use $changeStream for live notifications, cache invalidation, ETL triggers, and sync to search indexes. In mongosh, db.collection.watch() is usually easier—it builds a pipeline starting with $changeStream automatically.

📝 Syntax

$changeStream accepts an options object (all fields optional):

mongosh
{
  $changeStream: {
    fullDocument: "default" | "updateLookup" | "whenAvailable" | "required",
    fullDocumentBeforeChange: "off" | "whenAvailable" | "required",
    resumeAfter: <resume token document>,
    startAfter: <resume token document>,
    startAtOperationTime: <timestamp>,
    showExpandedEvents: <boolean>,
    allChangesForCluster: <boolean>
  }
}

Syntax Rules

  • First stage$changeStream must be stage 1 in the pipeline (or use watch()).
  • Cursor semantics — call .hasNext() / .next() in a loop; the stream stays open until closed.
  • Event _id — resume token; save it to continue after disconnect with resumeAfter.
  • fullDocumentupdateLookup fetches the post-update document for update events.
  • Resume optionsresumeAfter, startAfter, and startAtOperationTime are mutually exclusive.
  • Later stages$match can filter events; avoid $project that drops _id if you need resume.
mongosh
const cursor = db.orders.watch([
  { $changeStream: {} }
]);

// Block until next change, then print event
printjson(cursor.next());

⚡ Quick Reference

QuestionAnswer
Stage positionMust be first in the pipeline
DeploymentReplica set or sharded cluster required
Common helperdb.coll.watch([ … ])
Key event fieldsoperationType, fullDocument, documentKey, ns
Update full docfullDocument: "updateLookup"
ResumePass last event _id as resumeAfter
Open stream
db.orders.watch([
  { $changeStream: {} }
])

All changes

Inserts only
db.orders.watch([
  { $changeStream: {} },
  { $match: {
    operationType: "insert"
  }}
])

Filter events

Full doc on update
{ $changeStream: {
  fullDocument: "updateLookup"
}}

Post-update snapshot

Resume
{ $changeStream: {
  resumeAfter: token
}}

After disconnect

🧰 Parameters

Options inside the $changeStream object customize the stream:

fullDocument Optional

Controls whether update events include the full document. default omits it; updateLookup loads the current document.

fullDocument: "updateLookup"
resumeAfter Optional

Resume token (_id from a prior event) to continue where you left off. Cannot resume after an invalidate event.

resumeAfter: lastEvent._id
startAfter Optional

Like resumeAfter but can open a new stream after invalidation (e.g. collection recreated).

startAfter: savedToken
startAtOperationTime Optional

Start at a cluster time (BSON timestamp). Useful when you know when to begin listening.

startAtOperationTime: Timestamp(1, 0)

Examples Gallery

Sample orders collection—open a stream, read change events, filter inserts, fetch full documents on updates, and resume after a token.

📚 Getting Started

Replica set requirement and sample collection.

Example 1 — Prerequisites and sample data

mongosh
// Change streams require replica set or sharded cluster.
// Verify:
rs.status()

db.orders.drop()
db.orders.insertOne({
  product: "Keyboard",
  status: "pending",
  amount: 45
})

How It Works

Run rs.status() to confirm replica set mode. Insert seed data, then open a change stream in another shell session to observe new writes.

Example 2 — Basic change stream with watch()

mongosh
// Session A — open the stream
const stream = db.orders.watch([
  { $changeStream: {} }
])

// Session B — make a change
db.orders.insertOne({
  product: "Mouse",
  status: "pending",
  amount: 25
})

// Session A — read one event
const event = stream.next()
printjson(event)

/* Sample insert event:
{
  _id: { _data: "8262…" },          // resume token
  operationType: "insert",
  fullDocument: { product: "Mouse", status: "pending", amount: 25, _id: … },
  ns: { db: "test", coll: "orders" },
  documentKey: { _id: … },
  clusterTime: Timestamp(…),
  wallTime: ISODate("…")
} */

How It Works

watch() wraps an aggregation whose first stage is $changeStream. The cursor waits until MongoDB commits a change, then returns one event document.

📈 Practical Patterns

Filter events, load full documents, and resume streams.

Example 3 — Watch inserts only with $match

mongosh
const stream = db.orders.watch([
  { $changeStream: {} },
  {
    $match: {
      operationType: { $in: ["insert"] }
    }
  }
])

// Updates and deletes are filtered out server-side
db.orders.updateOne(
  { product: "Mouse" },
  { $set: { status: "shipped" } }
)
// → no event on this stream

db.orders.insertOne({ product: "Monitor", amount: 180 })
// → stream.next() returns the insert event

How It Works

Stages after $changeStream process each event like a normal pipeline. $match on operationType is the most common filter for ETL jobs that only care about new rows.

Example 4 — Full document on updates (updateLookup)

mongosh
const stream = db.orders.watch([
  {
    $changeStream: {
      fullDocument: "updateLookup"
    }
  }
])

db.orders.updateOne(
  { product: "Keyboard" },
  { $set: { status: "shipped", amount: 42 } }
)

const event = stream.next()
// event.operationType === "update"
// event.fullDocument → entire document AFTER update
// event.updateDescription → { updatedFields: { status, amount }, … }

printjson({
  op: event.operationType,
  status: event.fullDocument.status,
  amount: event.fullDocument.amount
})

How It Works

By default, updates may only include updateDescription (changed fields). updateLookup performs a lookup so consumers get the full current document—handy for search sync and cache refresh.

Example 5 — Save resume token and reconnect

mongosh
// First connection
let stream = db.orders.watch([{ $changeStream: {} }])
let lastToken = null

while (stream.hasNext()) {
  const event = stream.next()
  lastToken = event._id          // save resume token
  processEvent(event)            // your app logic
  if (shouldStop) break
}

// Later — reconnect without missing events
stream = db.orders.watch([
  {
    $changeStream: {
      resumeAfter: lastToken
    }
  }
])

// Alternative: aggregate() with $changeStream (same idea)
// db.orders.aggregate([
//   { $changeStream: { resumeAfter: lastToken } }
// ])

How It Works

Every change event includes a resume token in _id. Persist it (database, Redis, file) so workers survive restarts. Use startAfter when the old stream was invalidated.

🧠 How $changeStream Works

1

Open stream cursor

Client runs watch() or aggregate([{ $changeStream }]). MongoDB registers interest in the namespace.

Connect
2

Change committed

A write hits the collection. The oplog (replica set) records the operation.

Write
3

Event emitted

MongoDB builds a change event with operationType, optional fullDocument, and resume token _id.

Notify
=

App reacts in real time

Your loop processes the event—update cache, push to WebSocket, enqueue a job.

📝 Notes

  • Replica set required—standalone mongod returns an error when opening a change stream.
  • operationType values include insert, update, replace, delete, invalidate, and (with showExpandedEvents) DDL events.
  • Delete events include documentKey but usually no fullDocument.
  • An invalidate event closes the stream (e.g. collection dropped)—use startAfter to reopen.
  • Change streams see majority-committed changes—read concern affects what you observe.
  • Previous topic: $bucketAuto. Next: $collStats.

Conclusion

$changeStream is MongoDB’s built-in way to subscribe to data changes. Open with watch(), filter with $match, enable updateLookup when you need full documents, and persist resume tokens for reliability.

Remember the replica set requirement before debugging “change stream not supported” errors. Next in the series: $collStats for collection statistics.

💡 Best Practices

✅ Do

  • Prefer db.collection.watch() over raw aggregate() for resume support
  • Persist resume tokens durably before acknowledging event processing
  • Use $match early to reduce network traffic to your app
  • Set fullDocument: "updateLookup" when downstream needs the whole doc
  • Handle invalidate and reconnect with startAfter

❌ Don’t

  • Assume change streams work on standalone dev servers without replica set setup
  • Drop _id in $project if you rely on automatic resume
  • Process events without idempotency—retries may redeliver after reconnect
  • Leave streams open indefinitely without heartbeat/error handling in production
  • Poll with find() when push-based $changeStream fits the use case

Key Takeaways

Knowledge Unlocked

Five things to remember about $changeStream

Use these points when building reactive MongoDB apps.

5
Core concepts
📚 02

First stage

Pipeline rule.

Syntax
🔄 03

Replica set

Required deploy.

Setup
📄 04

updateLookup

Full doc on update.

Option
🛠 05

Resume token

event._id saved.

Reliability

❓ Frequently Asked Questions

$changeStream opens a change stream on the collection (or database/cluster when configured). It must be the first stage in an aggregation pipeline and emits one event document per insert, update, replace, delete, or other supported change—ideal for live dashboards and sync jobs.
Always first: db.orders.aggregate([{ $changeStream: {} }, …]). You may add later stages such as $match to filter event types, but stages that remove the resume token (_id) can break automatic resume behavior.
No. Change streams require a replica set or sharded cluster so MongoDB can track the oplog. Local standalone installs need a single-node replica set for testing.
db.collection.watch([{ $match: … }]) is the recommended helper—it wraps an aggregation starting with $changeStream and handles resume tokens. Using aggregate([{ $changeStream: {} }]) directly is equivalent but you manage the cursor yourself.
For update operations, MongoDB includes the current majority-committed document in the fullDocument field (or null if deleted). Without it, updates often only show updateDescription with changed fields—not the whole document.
Save the _id resume token from the last event and pass resumeAfter: token on restart. Use startAfter when the stream was invalidated (e.g. collection drop). resumeAfter, startAfter, and startAtOperationTime are mutually exclusive.
Did you know?

Change streams were introduced in MongoDB 3.6 and largely replaced manual oplog tailing. You can also open a database-level stream with db.watch() or cluster-wide on admin with allChangesForCluster. See the official $changeStream docs.

Continue the Stages Series

Subscribe to live updates with $changeStream, then inspect collections with $collStats.

Next: $collStats →

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.

5 people found this page helpful