Article
But Does MongoDB Support Transactions?
Views, joins, stored procedures, ACID transactions — every relational capability maps to the document model. The real difference isn't the feature list. It's that there's more than one right way to model your data.
01
The questions everyone asks
I was in a room with a handful of engineers from Oracle and PostgreSQL backgrounds — sharp people, decades of production databases between them — and the questions came in a burst:
- “But does it even have transactions?”
- “Can you do a join?”
- “Where do stored procedures live?”
- “Is there such a thing as a view?”
- “Can one server hold more than one database?”
- “And isn’t embedding just denormalisation with extra steps?”
Every one of those is a fair question. Every one of them also has the same shape of answer: yes, and it has for years. Multi-document ACID transactions shipped in 2018. Joins in 2015. Views in 2016. Schema validation in 2017. Multiple databases per deployment since the first release. The features an experienced SQL engineer reaches for are almost all present, most of them older than the last major version of Postgres they upgraded through.
So the “does it even have X?” questions collapse fast. The last one is different — it’s the only question worth keeping. “Isn’t embedding just bad denormalisation?” isn’t a feature question. It’s a modelling question, and it exposes the actual gap between the relational world and the document world. That gap isn’t a missing feature. It’s a degree of freedom.
Two claims carry this whole piece:
- Capability parity, and then some. For nearly every relational capability you depend on, MongoDB has a real equivalent — not a workaround, a first-class feature with a version number and a ship date. A few capabilities go the other way: native change data capture, recursive graph traversal, and vector search have no clean single-database relational analogue.
- Modelling is a design space, not a normal form. Relational normalisation converges on essentially one correct model. The document model gives you several valid models for the same data, chosen by how your application reads and writes it. Controlled duplication is a deliberate read optimisation.
If you’re still deciding whether to use MongoDB at all, that’s a different article — Relational, Document, or Platform? walks the choice with a scoring framework. This piece assumes the choice is made, or was made for you, and answers the next question: it does X — now how do I think in it?
Here is the whole map. The rest of the article is a walk through the rows that surprise people.
Coming from SQL? The MongoDB equivalent
Every relational capability you rely on, mapped to a first-class MongoDB feature with a version and a ship date.
Relational concept
MongoDB equivalent
Since
Table
Collection
Schema-flexible — documents in one collection can vary in shape.
always
Row
Document (BSON)
Nested arrays and sub-documents, not just flat scalars.
always
Column / fixed schema
Field + optional $jsonSchema validation
Schema is opt-in and evolvable, not mandatory upfront.
3.6 (2017)
Primary key
_id (auto ObjectId, unique-indexed)
Every document has one; supply your own if you want.
always
Foreign key + JOIN
$lookup (left outer join) / embedding
Joins work — embedding often removes the need on hot paths.
3.2 (2015)
UNION ALL
$unionWith
Combine collections in one pipeline.
4.4 (2020)
Window function (OVER)
$setWindowFields
Running totals, moving averages, rank.
5.0 (2021)
Multi-row transaction
Multi-document ACID transaction
Replica set (4.0), sharded (4.2); single-doc writes already atomic.
4.0 / 4.2
View
Standard view
Computed on read; uses the source collection's indexes.
3.4 (2016)
Materialized view
On-demand materialized view ($merge)
Stored on disk, indexable, refreshed on schedule.
4.2 (2019)
Stored procedure / function
Aggregation pipeline (server-side data logic)
Programmable stages run close to the data.
always
CHECK / NOT NULL / type
$jsonSchema validation
Enforced on every write; strict or moderate.
3.6 (2017)
UNIQUE constraint
Unique index
Plus partial, compound, and TTL indexes.
always
Multiple databases per server
Multiple databases per deployment
deployment → database → collection → document.
always
GROUP BY / aggregates
Aggregation pipeline ($group, $sum, …)
A full analytical engine, not just SELECT.
always
Partitioning
Native sharding
Horizontal scale-out built in.
always
Beyond parity — no single-database relational equivalent
CDC (logical decoding + Debezium)
Change Streams (native, resumable)
No external CDC stack — the database is the event source.
3.6 (2017)
Recursive graph traversal
Native hierarchy traversal — no single-DB SQL equivalent.
3.4 (2016)
Vector / full-text search
Public preview in Community & Enterprise server (8.2, mongot) — no bolt-on.
8.2 (2025)
02
Yes, it has transactions (you need them less than you think)
Start with the reframe, because it changes how you read every other answer: a single-document write in MongoDB has always been atomic. Updating three fields, pushing onto an embedded array, incrementing a counter, and setting a timestamp — all inside one document, in one operation — either all commits or none does. No transaction block required [MongoDB Docs, “Transactions”].
That single fact absorbs a large fraction of the transactions a normalised schema would need. In 3NF, an order and its line items live in two tables, so writing them consistently means a transaction spanning both. Embed the line items in the order document and the same write is a single atomic operation — no transaction, no locks held across statements. The document model doesn’t remove the need for atomicity; it moves the boundary so more writes fall inside a single document.
For the cases that genuinely span documents, MongoDB has multi-document ACID transactions — on replica sets since 4.0 (June 2018) and on sharded clusters since 4.2 (2019) [MongoDB, “Multi-Document ACID Transactions GA”, 2018]. They give you snapshot isolation and all-or-nothing execution — the same guarantees an RDBMS engineer expects. There’s a deliberate guardrail: a transaction defaults to a 60-second runtime ceiling via transactionLifetimeLimitSeconds, after which a background process aborts it [MongoDB Docs, “Production Considerations”]. That limit is a design nudge — long, chatty, multi-document transactions are an anti-pattern here, the same way a 10-table write transaction held open for a minute is an anti-pattern in Postgres.
The canonical case is a double-entry ledger, which I’ll use for the rest of this article. One primer sentence for anyone who hasn’t touched accounting: every transaction is a balanced set of debits and credits that sum to zero — money moves from one account to another, it never appears or vanishes. The invariant is sacred. You must never observe a debit without its matching credit.
Post a transfer — debit checking €200, credit savings €200 — and you have the textbook argument for atomicity. Two account balances must move together or not at all:
Atomic transfer in a multi-document transaction
const session = client.startSession()
try {
session.startTransaction({ readConcern: { level: "snapshot" }, writeConcern: { w: "majority" } })
// Append the balanced journal entry (immutable audit spine)
await journal.insertOne({
_id: "txn_8f3a",
timestamp: new Date(),
description: "Transfer to savings",
postings: [
{ account_id: "acc_checking_01", direction: "debit", amount: 20000, currency: "EUR" },
{ account_id: "acc_savings_01", direction: "credit", amount: 20000, currency: "EUR" },
],
}, { session })
// Move the two balances (amounts in integer minor units — no float rounding)
await accounts.updateOne({ _id: "acc_checking_01" }, { $inc: { balance: -20000 } }, { session })
await accounts.updateOne({ _id: "acc_savings_01" }, { $inc: { balance: 20000 } }, { session })
await session.commitTransaction()
} catch (err) {
await session.abortTransaction() // debit and credit both vanish; balances snap back
throw err
} finally {
await session.endSession()
}If the second updateOne throws — network blip, write conflict, process crash — abortTransaction rolls back everything, including the journal insert. No half-posted transfer. The sum of debits and credits stays zero because the only moment it’s observable is after commit.
Amounts are stored as integer minor units (cents), not floats — a small detail that saves you from 0.1 + 0.2 !== 0.3 rounding errors in money. That’s not a MongoDB quirk; it’s the same discipline you’d apply in any language with IEEE-754 floats.
Atomic transfer — step through it
Move €200 from checking to savings as one balanced double-entry posting. Advance step by step and watch what an outside reader can see — the two legs only ever become visible together.
Start a session
const session = client.startSession()
Grab a session handle. Nothing has changed yet — this just scopes the writes that follow so they commit or roll back as one unit.
Visible balances (concurrent outside reader)
checking
€1,585.00
savings
€0.00
Committed / visible ledger (live)
Debits total
€0.00
Only published to outside readers at commit.
Credits total
€0.00
Lands in the same atomic step as its matching debit.
Delta (debits − credits)
€0.00
Always €0.00 to the outside world — the invariant is never observably broken.
03
One relational model versus many document models
This is the real shift, and it’s worth slowing down for. In a relational database, once you fix the entities, third normal form largely determines the schema. Two competent engineers normalising the same ledger arrive at nearly the same tables. There is essentially one right answer, and the design work is discovering it.
The document model doesn’t work that way. The same facts have several correct shapes, and which one is right depends on a question that has no relational equivalent: what does my application read and write together? [MongoDB Docs, “Embedded Data Versus References”]. Access patterns drive the schema, not a normal form.
Take the ledger. Normalised to 3NF, it’s four tables, and there’s not much to argue about: owners hold accounts; a transaction is one business event; its postings are the individual debit and credit legs that net to zero. Now here are three valid document models for exactly that data. Each is optimal for a different access pattern, and none is wrong.
One model vs many models
Normalisation converges on a single schema for the ledger. The document model forks into several — each shape correct for the access pattern it serves.
3NF — one right answer
accounts.owner_id → owners._id · postings.txn_id → transactions._id · postings.account_id → accounts._id
Document — many right answers
Journal-first — the immutable source of truth
{
"_id": "txn_8f3a",
"timestamp": "2026-07-08T10:15:00Z",
"description": "Transfer to savings",
"postings": [
{ "account_id": "acc_checking_01", "direction": "debit", "amount": 20000, "currency": "EUR" },
{ "account_id": "acc_savings_01", "direction": "credit", "amount": 20000, "currency": "EUR" }
]
}Same four facts, three shapes. Model A optimises the write path and the audit trail. Model B optimises the single hottest read. Model C optimises ad-hoc reporting. In a relational database you’d serve all three from the one normalised schema plus indexes, views, and denormalised reporting tables — you already keep multiple physical shapes of the same data, you just don’t call them models. The document world makes the choice explicit and up front.
04
Embed or reference? (and why “duplicate data” isn’t a dirty word)
The instinct that embedding is “just denormalisation” comes from a real place. Denormalisation in a relational database is usually a retreat — you had the clean model, then a slow query forced you to copy a column and now you own the drift. So embedding feels like starting from the compromised position.
It isn’t, because the decision is governed by a clear rule, not by which query happened to be slow. Embed when related data is read together, bounded, and updated with its parent. Reference when it’s queried on its own, unbounded, shared across many parents, or written from many places’ [MongoDB Docs].
In the ledger, both answers show up in the same schema:
- Postings embed in the journal transaction (Model A). They’re bounded — two to a handful per transaction — always read with the transaction, and written atomically with it. One-to-few, read together, written together. Textbook embed.
- The full posting history of an account is referenced — it lives in its own collection, not stuffed into the account document. It’s unbounded (a busy account accrues millions of postings over years) and queried independently for statements and reports. One-to-squillions. Textbook reference.
The hard constraint that keeps embedding honest is the 16 MiB BSON document ceiling [MongoDB Docs, “Limits and Thresholds”]. An unbounded array inside a document is the classic anti-pattern precisely because it marches toward that wall. If a set can grow without limit, it doesn’t belong embedded — that’s not a style preference, it’s a physical bound. (For genuinely large binary blobs, GridFS chunks them across documents.)
Now the reframe on duplication. Model B keeps a copy of recent postings on the account and a pre-computed balance. That is duplicated data. It is also the correct design, because the journal remains the single source of truth and the account document is a derived read cache you rebuild from the journal at will. Relational systems do the identical thing — materialized views, summary tables, denormalised reporting schemas, cache layers — they just don’t advertise it as part of the data model. The document model makes controlled duplication a first-class, intentional decision instead of an emergency patch. What keeps it from turning into the relational horror story is discipline, and that discipline has a name and a catalogue.
Embed or reference? — decide by workload
Every question is framed by observable read/write behaviour — what you’d watch your queries do in production — not by abstract properties of the data.
When you read the parent, do you almost always need this related data in the same request?
e.g. a transaction is useless without its debit and credit legs — you never render one without the other.
Does the related set have a hard upper bound, or can it grow without limit?
a transfer has two postings; an account's lifetime posting history has millions. 16 MiB is the wall.
Is the same fact written from many places at once, or only with its parent?
a posting is written once, atomically, with its transaction. An account balance is touched by every transfer.
Is your workload for this data read-heavy or write-heavy?
read-heavy favours denormalisation (embed, compute); write-heavy favours normalisation (reference).
Running tally (0/4 answered)
05
The pattern language (managing controlled duplication)
MongoDB’s official “Building With Patterns” catalogue is the vocabulary that keeps embedding and duplication from drifting into chaos [MongoDB, “Building With Patterns: A Summary”, 2019]. If you come from relational modelling, think of it as the document world’s equivalent of the normal forms — a shared language for “the right shape for this access pattern.” Every one of them appears somewhere in the ledger.
The pattern language
MongoDB's Building With Patterns catalogue — the document world's normal forms. Click a card to see the ledger example.
There’s one rule of thumb underneath all of them: read-heavy workloads favour denormalisation (embed, compute, extend the reference); write-heavy workloads favour normalisation (reference), because you don’t want to update the same fact in twenty places on every write [MongoDB Docs]. The ledger sits on both sides: the write-hot journal stays normalised and append-only; the read-hot account view denormalises aggressively. Same database, opposite strategies, chosen per access pattern.
06
Views, joins, and where server-side logic lives
The leftover “does it have…?” questions, answered with ledger snippets.
Standard views shipped in 3.4 (2016) [MongoDB Docs, “Views”]. A view is a named object defined by an aggregation pipeline over a source collection. It’s computed on read, stores nothing on disk, and uses the underlying collection’s indexes — the direct analogue of CREATE VIEW. A reporting view that joins postings to their accounts is a two-line definition.
Joins are the $lookup stage, a left outer join introduced in 3.2 (2015) [MongoDB Docs, “$lookup”]. It adds an array field to each input document holding the matching foreign documents; no match yields an empty array — left-outer semantics. Since 3.6 it takes a sub-pipeline (let + pipeline) for correlated and non-equality joins, and since 5.1 it works against sharded collections. The document-model instinct inverts the relational one: $lookup is the fallback for cold paths and reporting, not the default for hot reads. On the hot path you embed or extend a reference so the join isn’t needed at all. Recursive traversal — the recursive-CTE equivalent — is $graphLookup, which the graph capabilities article covers in depth.
Materialized views are the $merge stage, added in 4.2 (2019) [MongoDB Docs, “On-Demand Materialized Views”]. Unlike a standard view, the result is written to a real collection on disk, so you can index it and reads don’t recompute the pipeline. A daily-balance rollup per account is the natural example — run the pipeline on a schedule, $merge the results into a daily_balances collection, and dashboards read pre-aggregated rows:
Daily-balance rollup → materialized view
db.postings.aggregate([
{ $group: {
_id: { account: "$account_id", day: { $dateTrunc: { date: "$ts", unit: "day" } } },
net: { $sum: { $cond: [{ $eq: ["$direction", "credit"] }, "$amount", { $multiply: ["$amount", -1] }] } }
}},
{ $merge: { into: "daily_balances", on: "_id", whenMatched: "replace", whenNotMatched: "insert" } }
])$out fully replaces the target on every run; $merge updates incrementally — insert new, replace matched, or merge fields — and can write to any collection, including sharded ones. That incremental behaviour is why you’d refresh a rollup with $merge rather than rebuild it wholesale.
Stored procedures and server-side logic are the question that maps least literally, so answer it in two halves.
The behavioural half — “where does my data logic run, close to the data?” — is the aggregation pipeline itself. A pipeline is a programmable, server-side sequence of stages — $match, $group, $project, $lookup, $setWindowFields, $merge, and dozens more — that executes inside the database, next to the data, without shipping rows to the application to be transformed. That’s the same locality argument that justified stored procedures: don’t move the data to the logic, move the logic to the data. A rollup-and-write pipeline like the one above is server-side procedural logic; it just happens to be declarative and composable rather than a block of PL/SQL.
The declarative half — “where do my constraints live?” — is schema validation with $jsonSchema, added in 3.6 (2017) [MongoDB Docs, “Schema Validation”]. This covers the CHECK, NOT NULL, and type-constraint roles you might otherwise enforce in a trigger. You attach a JSON Schema to a collection and MongoDB enforces required fields, BSON types, value ranges, enums, regex patterns, and array bounds on every insert and update:
Schema validation with $jsonSchema
db.createCollection("postings", {
validator: { $jsonSchema: {
bsonType: "object",
required: ["account_id", "direction", "amount", "currency"],
properties: {
direction: { enum: ["debit", "credit"] },
amount: { bsonType: "long", minimum: 1 }, // positive minor units only
currency: { bsonType: "string", pattern: "^[A-Z]{3}$" }
}
}},
validationLevel: "strict", // validate all inserts and updates
validationAction: "error" // reject writes that fail
})validationLevel is strict or moderate; validationAction is error or warn. The difference from a relational schema is that validation is opt-in and evolvable — you develop with a loose shape and tighten to strict in production, rather than being forced into a rigid schema on day one. Unique constraints, meanwhile, are just unique indexes, available since always, alongside compound, partial, and TTL indexes.
$lookup, $group, $merge — the flow
One ledger aggregation, two endings — recompute the net balance on every read, or persist it to a collection you can index.
01
postings
collection
Append-only ledger — one document per debit/credit event.
02
$lookup
join accounts
Enrich each posting with its owning account document.
03
$group
by account / day
Fold credits minus debits into a net balance per bucket.
04
computed on read
standard view
Stores nothing on disk; recomputed each query; uses source indexes.
01
postings
collection
Append-only ledger — one document per debit/credit event.
02
$lookup
join accounts
Enrich each posting with its owning account document.
03
$group
by account / day
Fold credits minus debits into a net balance per bucket.
04
computed on read
standard view
Stores nothing on disk; recomputed each query; uses source indexes.
db.postings.aggregate([
{ $group: {
_id: { account: "$account_id", day: { $dateTrunc: { date: "$ts", unit: "day" } } },
net: { $sum: { $cond: [{ $eq: ["$direction", "credit"] }, "$amount", { $multiply: ["$amount", -1] }] } }
}},
{ $merge: { into: "daily_balances", on: "_id", whenMatched: "replace", whenNotMatched: "insert" } }
])Drop the final $merge stage and the same pipeline is a standard view — computed on read, persisting nothing.
07
Change streams: the capability with no relational equivalent
Here’s the row that goes the other way — a first-class MongoDB primitive with no clean single-database equivalent in the relational world. Change streams are a native, resumable feed of every data change, introduced in 3.6 (2017) [MongoDB Docs, “Change Streams”].
To get the same thing in Postgres you assemble a stack: logical replication, a decoding plugin like wal2json or pgoutput, Debezium to turn the WAL into events, and Kafka Connect to move them. Four moving parts, each with its own failure modes and operational burden. Change streams collapse all of that into one built-in call:
Watch the journal — one built-in call
const stream = db.collection("journal").watch(
[{ $match: { operationType: "insert" } }],
{ fullDocument: "updateLookup" }
)
for await (const event of stream) {
handlePosting(event.fullDocument, event._id) // event._id is the resume token
}Two properties make it trustworthy for something as unforgiving as a ledger.
It’s built on the oplog — the same replication log that keeps replica set members in sync. There’s no second write path to fall out of sync with the data; the change feed is the replication feed, filtered and reshaped for you.
It’s resumable. Every event carries a resume token — the event’s _id — and you reopen the stream with resumeAfter or startAfter to pick up exactly where you stopped, as long as the oplog still holds that history. A consumer that crashes and restarts persists its last token and loses no events. On a sharded cluster, mongos opens a stream per shard and merges them into a single totally-ordered feed using a global logical clock — ordering holds across shards, which matters when your ledger is sharded by account.
One honest footnote: fullDocument: "updateLookup" fetches the current post-image rather than just the delta, and under rapid deletes with a $match filter it can surface “Resume Token Not Found” edge cases; the mitigation is pre- and post-image options. A footnote, not a headline.
For the ledger, this is what turns the append-only journal into an event backbone. A posting commits to the journal, and a change stream fans that one event out to independent consumers, each an ordinary application service subscribed to the stream. Each consumer tracks its own resume token, so they fail and recover independently. The journal stays the single source of truth; everything else is a projection rebuilt from the stream. That’s event-driven architecture with no CDC stack bolted on — the database is the event source.
Change streams — the event backbone
One posting commits to the journal; the change stream fans it out to independent subscribers, each tracking its own resume token.
journal
A posting commits here — the immutable source of truth.
oplog
The replication log every write lands in, ordered and durable.
change stream
token: —Resumable and ordered — the token is a durable pointer into the oplog, not an offset the client has to persist by hand.
Balance projection
Updates the account read model — running balance + recent subset.
acked seq0
resume—
Audit log
Writes an immutable, tamper-evident audit record.
acked seq0
resume—
Notification service
Sends “you received €200” to the account owner.
acked seq0
resume—
Analytics sink
Forwards the event to Kafka / a warehouse for reporting.
acked seq0
resume—
Restart a consumer while emitting: it drops, the oplog keeps the events, and it resumes from its own token — the other three never notice.
08
The real difference
The capability questions are settled. Transactions, views, joins, materialized views, schema validation, multiple databases — every one has a real answer with a version number, most of them shipped between 2015 and 2019. If you’re weighing MongoDB against a relational database, do it on data model, operational fit, and scaling shape — not on a feature checklist, because the checklist comes out even, and on change streams and native search it tips the other way. Vector and full-text search, once Atlas-only, entered public preview in the Community and Enterprise server too as of 8.2 in 2025 [MongoDB, “MongoDB 8.2 Is Now Available”, 2025] — so even the “and more” tail is available to self-managed deployments. If you want the ground-up explanation of what those vectors are, start with What Are Vector Embeddings? and RAG From the Ground Up.
So the thing a SQL engineer actually has to learn isn’t a missing feature to work around. It’s a new degree of freedom. In the relational world, modelling is a search for the one correct schema. In the document world, modelling is a design decision you make against your access patterns — the same facts have several correct shapes, and picking the right one for how your application reads and writes is the whole job.
Before you draw a single collection, write down what your application reads together and what it writes together. That list — not third normal form — is your schema.
The transactions were never the hard part. The freedom is.
The arc — capabilities shipping, 2015 → 2025
Ten years closed the gap one release at a time. The last node is the one with no single-database relational answer — the beyond-parity capstone.
2015
$lookup joins
2015
Views · $graphLookup
2015
$jsonSchema · change streams
2015
Multi-doc ACID (replica set)
2015
Sharded ACID · $merge
2015
$unionWith
2015
$setWindowFields
2015
Vector / full-text in Community (preview)
2015
$lookup joins
2015
Views · $graphLookup
2015
$jsonSchema · change streams
2015
Multi-doc ACID (replica set)
2015
Sharded ACID · $merge
2015
$unionWith
2015
$setWindowFields
2015
Vector / full-text in Community (preview)