MongoDB
Article.
MongoDB stores JSON-like documents (BSON) in collections, with no schema required by the server. The senior questions are not about syntax. They are about what one document can guarantee, how to shape documents so that the guarantee covers what you need, and what the defaults do on failover.
When does MongoDB lose a write you were told succeeded, and when does your own code lose it? Both are covered below. Outputs are from MongoDB 9.0.2 (a single-node replica set in Docker) and PyMongo 4.18.3.
The model
{
_id: ObjectId("..."),
name: "Alice",
addresses: [ { type: "home", city: "Berlin" } ],
preferences: { lang: "en", newsletter: true }
}- A document is the unit of atomicity. One
update_onewith several operators is applied all-or-nothing, however many nested fields it touches. - A document is limited to 16 MB. A 17 MB insert failed in the driver
with
DocumentTooLarge; an array grown by$pushpast the limit failed on the server withResulting document after update is large. - Storage is WiredTiger: document-level concurrency, compressed blocks, and its own cache, by default 50% of RAM minus 1 GB, on top of the OS cache.
Atomic operators, not read-modify-write
# lost updates: read, compute in Python, write back
doc = await db.acc.find_one({"_id": 1})
await db.acc.update_one({"_id": 1}, {"$set": {"n": doc["n"] + 1}})
# atomic: the server applies the change to the current value
await db.acc.update_one({"_id": 1}, {"$inc": {"n": 1}})| 100 concurrent increments | Final value |
|---|---|
find_one then $set |
18 |
$inc |
100 |
82 increments were lost with no error. The same applies to $push,
$addToSet, $pull, $min, $max. When the new value really must be
computed in the application, make the write conditional, an optimistic
lock on a version field:
result = await db.acc.update_one(
{"_id": 1, "version": doc["version"]}, # only if unchanged
{"$set": {"n": new_n}, "$inc": {"version": 1}},
)
if result.modified_count == 0:
... # someone won; retryfind_one_and_update(..., return_document=AFTER) does an atomic update and
returns the result in one round trip, the usual way to take a job from a
queue collection or allocate a sequence number.
Embedding vs referencing
The design decision that matters most, because the document boundary is the transaction boundary.
- Embed what is read together, owned by the parent and deleted with it, bounded in number, and must change atomically with it.
- Reference what is shared by many parents, updated on its own, unbounded (comments, events), or large and rarely read.
// embedded: an order and its lines are written and read as one
{ _id: "ord_42", user_id: "user_7",
user_name: "Alice", // extended reference: a copied field
items: [ { sku: "X1", qty: 2, price: NumberDecimal("9.99") } ],
total: NumberDecimal("19.98") }An order line embeds naturally: it belongs to one order and changes with it.
A user is referenced, with a copy of the few fields the order page shows. The
copy goes stale when the user renames, so it should only hold what is
acceptable to be stale, or what is a historical fact, like the price at
order time. Money uses Decimal128, never double.
Patterns worth naming
- Subset: embed the latest 10 reviews, keep all reviews in their own
collection.
$pushwith$slice: -10keeps the embedded array bounded. - Computed: keep
review_countandavg_ratingon the product, updated with$incon each write, instead of aggregating on every read. - Polymorphic: different shapes in one collection, told apart by a
typefield. - Bucket: many small measurements grouped into one document per hour. For time series, use a time-series collection (5.0+) instead, which buckets and compresses for you.
Schema: optional, not absent
The server does not require a schema, but the application always has one.
Without enforcement, total becomes an int in some documents and a string
in others, created_at turns into createdAt after a refactor, and every
reader has to cope. Enforce the shape that matters:
await db.create_collection("orders", validator={"$jsonSchema": {
"bsonType": "object",
"required": ["user_id", "total"],
"properties": {"total": {"bsonType": ["int", "double", "decimal"],
"minimum": 0}},
}})
# insert {"user_id": 1, "total": -5} -> WriteError 121, Document failed validationPydantic models in the application, plus a validator on the collection for the invariants, are the usual pair.
NULL and missing fields
find({"x": None}) returned documents where x is null and where x
does not exist (ids 1 and 2); {"x": {"$exists": False}} returned only the
missing one. Sparse and partial indexes, and the $group key of a missing
field, depend on the same distinction.
Queries need indexes, as in SQL
find({email: "u199999@x.io"}) on 200,000 documents
no index: COLLSCAN, 200,000 docs examined, 57 ms
index: IXSCAN, 1 key, 1 doc examined, 3 msexplain("executionStats") and the ratio of totalDocsExamined to
documents returned are the MongoDB version of comparing estimated and actual
rows.
Compound indexes follow the ESR rule: equality fields first, then sort, then range (MongoDB — Indexes).
Replication and the defaults
A replica set has one primary and secondaries replaying its oplog. On
primary failure, an election picks a new one within seconds; drivers retry
writes once by default (retryWrites=true).
| Setting | Default | Meaning |
|---|---|---|
| write concern | w: "majority" (5.0+) |
acked once most members have it |
| read concern | local |
newest data, may later be rolled back |
| read preference | primary |
reads go to the primary |
That answers the question at the top. With w: 1, an acknowledged write
that had not reached a secondary is rolled back when the primary fails over:
acknowledged, then lost. w: "majority", the default since 5.0, prevents
that.
Reading from secondaries (secondaryPreferred) gives up
read-your-writes unless you use a causally consistent session
(MongoDB — Transactions, Read & Write Concerns,
MongoDB — Replica Sets and Sharding).
Multi-document transactions
async with client.start_session() as s:
async with await s.start_transaction():
await db.acc.update_one({"_id": "a"}, {"$inc": {"bal": -30}}, session=s)
await db.acc.update_one({"_id": "b"}, {"$inc": {"bal": 30}}, session=s)Available on replica sets (4.0) and sharded clusters (4.2). They are real
snapshot-isolated ACID transactions, but they cost: a 60-second default
lifetime, write conflicts that abort with a retryable TransientTransactionError,
and, in 9.0, a cap of 10,000 concurrently open transactions. Needing them
often is a sign that the document boundary is in the wrong place.
From Python: PyMongo async, not Motor
Motor is deprecated since May 2025 and past end-of-life (May 2026). PyMongo 4.13+ has a native asyncio API:
from pymongo import AsyncMongoClient
client = AsyncMongoClient("mongodb://db:27017", maxPoolSize=50)
db = client.myapp
user = await db.users.find_one({"email": email})
async for order in db.orders.find({"user_id": user["_id"]}): # not awaited
...
cursor = await db.orders.aggregate(pipeline) # awaitedMigration catch: aggregate is a coroutine in PyMongo async and was not in
Motor, while find returns a cursor without await in both. Create one
client per process, at startup, and reuse it: it holds the connection pool.
Where it breaks in production
Unbounded arrays
Comments or events $pushed into the parent document. Each update rewrites
a bigger document, indexes on the array grow with it, and one day a write
fails at 16 MB. Bound the array ($slice) or move items to their own
collection.
Lost updates in application code
Read, modify in Python, $set back: correct in every test, and under load
82 of 100 increments disappeared. Use update operators, or a version field
in the filter.
A collection scan nobody saw
A new query filter with no index works on a small collection and turns
into COLLSCAN on millions of documents. Turn on the profiler for slow
operations or watch planSummary: COLLSCAN in the slow-query log.
w: 1 for speed
Someone sets w: 1 to cut write latency. Writes work until a failover, then
the last few seconds of acknowledged writes are rolled back into a
rollback/ directory that nobody reads.
A shard key on a timestamp or ObjectId
Monotonic keys send every insert to the last chunk, so one shard takes all writes. Use a hashed or compound key matched to the queries (MongoDB — Replica Sets and Sharding).
The trade not taken
The alternative is PostgreSQL with jsonb: documents inside a relational
database, with foreign keys, joins and transactions as the normal path, and
GIN indexes on the JSON.
For most backends whose data is relational with some flexible fields, it is the simpler choice, one database instead of two. MongoDB earns its place when the data really is document-shaped and self-contained, when write volume needs native sharding, or when the team models aggregates as documents from the start.
The deciding question is whether most operations stay inside one document (NoSQL Decision Guide — Which Store, and When, PostgreSQL architecture).
Related
Interview angle 6
- “How does MongoDB handle atomicity?” - a single document is updated
atomically, including several operators at once. Multi-document
transactions exist (4.0 replica sets, 4.2 sharded) but cost latency and
abort on conflicts.
- Follow-up: “Then why did my counter lose updates?” - the code read the
value, added one in Python and
$setit back: 18 of 100 survived.$incgave 100. Use operators, or a version field in the filter.
- Follow-up: “Then why did my counter lose updates?” - the code read the
value, added one in Python and
- “Embed or reference?” - embed what is owned, read together, bounded and must change atomically with the parent; reference what is shared, unbounded or updated on its own. Copy a few display fields when staleness is acceptable.
- “Can MongoDB lose an acknowledged write?” - with
w: 1, yes: on failover, writes not yet on a secondary are rolled back. The default since 5.0 isw: "majority", which survives a failover.- Follow-up: “And reads?” -
localread concern can return data that is later rolled back;majoritycannot. Secondary reads can be stale.
- Follow-up: “And reads?” -
- “Schemaless means no schema?” - no: the application has one, and
$jsonSchemavalidators enforce the invariants on the server. - “Async driver?” - PyMongo’s
AsyncMongoClient; Motor is deprecated and past end-of-life.aggregateis awaited,findis not. - “Why MongoDB over PostgreSQL with jsonb?” - when most operations stay
within one document and you need native sharding. Otherwise
jsonbin PostgreSQL usually covers the flexibility with joins and constraints.