mongodb

MongoDB 6.0, and the change stream that can show you the before

MongoDB 6.0, and the change stream that can show you the before

MongoDB 6.0 shipped in July. The headline feature was Queryable Encryption, available in preview, which lets a server run equality queries against data it cannot read. That is genuinely novel and I have no use for it today, so this is about the smaller thing in the release that I do have a use for: change streams can now hand you the document as it was before the change.

What a change stream would and would not tell you

A change stream is a tailable view of the oplog with a sensible API on top. You watch a collection and receive events as documents are inserted, updated, replaced or deleted.

const stream = db.collection('members').watch()

for await (const event of stream) {
  console.log(event.operationType, event.documentKey)
}

For an update, the event carries updateDescription — the fields that were set and the fields that were removed — and, if you ask for it, fullDocument, the document as it now stands.

What it never carried was the document as it stood a moment earlier. And a surprising amount of what people want to do with a change stream needs exactly that.

Auditing needs it: "who changed what" is not useful without the previous value. Any integration that fires on a transition rather than a state needs it — send the welcome email when a membership becomes active, and you must know it was not active before, or the first unrelated update to an active member sends it again. Anything feeding a downstream system that wants before-and-after rows needs it.

The workarounds are all bad in the same way. You either keep a copy of every document in the consumer so you can diff against it, which means a second store that can drift and a cold-start problem. Or you reconstruct the previous state by inverting updateDescription against fullDocument, which works until an update touches an array or a nested path. Or you write the previous values into the document itself, which turns every schema into an audit schema.

I have written the first and the second. Neither is code I was proud of.

What 6.0 added

Pre-images and post-images, enabled per collection:

db.createCollection('members', { changeStreamPreAndPostImages: { enabled: true } })
// or on an existing one
db.runCommand({ collMod: 'members', changeStreamPreAndPostImages: { enabled: true } })

Then ask for them when you open the stream:

const stream = db.collection('members').watch([], {
  fullDocument: 'whenAvailable',
  fullDocumentBeforeChange: 'whenAvailable',
})

for await (const event of stream) {
  const before = event.fullDocumentBeforeChange
  const after = event.fullDocument

  if (before?.status !== 'active' && after?.status === 'active') {
    await onActivated(after)
  }
}

That conditional is the whole point. It is a transition rather than a state, it is expressed in one place, and nothing in the consumer has to remember anything between events. The consumer becomes stateless, which means it can be restarted, scaled out, or replaced without a warm-up.

The parts to be careful about

whenAvailable is doing real work in that snippet, and I would not swap it for required without thinking.

Pre-images are stored, which means they take space and they expire. If the image for an event has been removed before your consumer gets to it — because the consumer was down longer than the retention window — then required makes the stream error, and whenAvailable gives you an event with the field missing. Both are legitimate; they just push the decision to different places. What you must not do is write before?.status !== 'active' and quietly treat a missing image as "it was not active", which is what the optional chaining above will do if you are not paying attention. For anything where a duplicate action is expensive, the missing-image case needs its own branch.

It is also opt-in per collection and it is not retroactive. Turning it on today does not give you pre-images for yesterday.

Why I think this is the interesting one

Queryable Encryption is the more impressive engineering by a distance. This is the one that deletes code.

The pattern it removes — a consumer keeping its own shadow copy of state purely to detect transitions — is one of those designs that looks reasonable when you write it and becomes a liability slowly. It has a cold start. It drifts. It has to be reasoned about during every incident because nobody is sure whether its copy is current. And all of it exists because the event was missing one field that the database already had.

The rest of the release is the usual: time series collections got faster and learned more index types, and the aggregation pipeline picked up operators that mean fewer round trips. Useful, but this is the one that changed a design I would otherwise have written again.

Deyan Peev

Written by

Deyan Peev

Founding Engineer · Sofia, Bulgaria

Deyan Peev

Founding Engineer in Sofia, Bulgaria. Currently at 1club.

Elsewhere

© 2026 Deyan Peev