MongoDB

Updated 6 interview angles 8 min read source
On this page12
  1. The model
  2. Atomic operators, not read-modify-write
  3. Embedding vs referencing
  4. Schema: optional, not absent
  5. Queries need indexes, as in SQL
  6. Replication and the defaults
  7. Multi-document transactions
  8. From Python: PyMongo async, not Motor
  9. Where it breaks in production
  10. The trade not taken
  11. Related
  12. Interview angle

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

javascript
{
  _id: ObjectId("..."),
  name: "Alice",
  addresses: [ { type: "home", city: "Berlin" } ],
  preferences: { lang: "en", newsletter: true }
}
  • A document is the unit of atomicity. One update_one with 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 $push past the limit failed on the server with Resulting 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

python
# 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:

python
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; retry

find_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.
javascript
// 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. $push with $slice: -10 keeps the embedded array bounded.
  • Computed: keep review_count and avg_rating on the product, updated with $inc on each write, instead of aggregating on every read.
  • Polymorphic: different shapes in one collection, told apart by a type field.
  • 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:

python
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 validation

Pydantic 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

text
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 ms

explain("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

python
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:

python
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)                      # awaited

Migration 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).

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 $set it back: 18 of 100 survived. $inc gave 100. Use operators, or a version field in the filter.
  • “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 is w: "majority", which survives a failover.
    • Follow-up: “And reads?” - local read concern can return data that is later rolled back; majority cannot. Secondary reads can be stale.
  • “Schemaless means no schema?” - no: the application has one, and $jsonSchema validators enforce the invariants on the server.
  • “Async driver?” - PyMongo’s AsyncMongoClient; Motor is deprecated and past end-of-life. aggregate is awaited, find is not.
  • “Why MongoDB over PostgreSQL with jsonb?” - when most operations stay within one document and you need native sharding. Otherwise jsonb in PostgreSQL usually covers the flexibility with joins and constraints.