mongodb
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.
Written by
Deyan Peev
Founding Engineer · Sofia, Bulgaria


