Chapter 13 · Week 13

Persistence III — MongoDB and the Document Model

When do you need a new wineskin?

Chapter 13 — Persistence III: MongoDB and the Document Model

“There is no silver bullet. There is no single development, in either technology or management technique, which by itself promises even one order-of-magnitude improvement within a decade in productivity, in reliability, in simplicity.” — Fred Brooks, No Silver Bullet

“Neither is new wine put into old wineskins. If it is, the skins burst and the wine is spilled and the skins are destroyed. But new wine is put into fresh wineskins, and so both are preserved.” — Matthew 9:17


Why This Matters

For two weeks you have lived inside the relational model. SQLite taught you tables, rows, columns, primary keys, and parameterized SQL. Postgres taught you what a real client/server database buys you — connection pools, indexes you can measure, transactions that roll back cleanly under MVCC. By now the relational model feels like the way data is stored.

It is not the only way. It is one tool.

Here is the uncomfortable truth that this chapter exists to teach: the relational model is a forced fit for some problems. When your data is a pile of forms with the same columns, every time, forever — the relational model is glorious. The schema is a contract, the constraints are guardrails, a join stitches related rows back together in milliseconds. But when your data is document-shaped — when every record has a slightly different set of fields, when the natural unit is a nested tree you almost always read and write all at once, when the shape itself changes as the product changes — then forcing it into rows and columns means a dozen tables, a dozen joins, and a schema migration every time someone adds a field.

That is the new wine bursting the old wineskin.

MongoDB is a document store. Instead of tables of rows, it holds collections of documents — JSON-like objects that can nest, that need not share a fixed schema, that you read and write as a whole. For the right problem, it is liberating. For the wrong problem, it is a loaded foot-gun: the same schema flexibility that frees you from migrations also removes every guardrail that kept your data honest.

So this is not a chapter about MongoDB being “better than SQL.” That sentence is the mark of someone who has not yet paid for the tradeoff. This is a chapter about a second shape for data, when it fits, and — the capstone of the whole persistence arc — how an architect decides between relational and document before a line of code is written. That decision is the soul of Weeks 11 through 13.

The Christian question for the week is when do you need a new wineskin? Jesus’ image in Matthew 9 is not a celebration of novelty for its own sake. The point is fit: new wine ferments and expands, and an old, already-stretched skin will burst under it; both are lost. The lesson cuts both ways. New wine needs a new skin — but you do not pour old wine into a new skin to look fashionable. The discipline is matching the vessel to the contents. That is exactly the architect’s job this week: match the store to the shape of the data, not to the hype cycle.


13.1 — The Document Model From Zero

Forget tables for a moment. A MongoDB document is, for all practical purposes, a JSON object. Here is one:

{
  "_id": "664f1a...",
  "title": "The Cost of Discipleship",
  "passage": "Luke 14:25-33",
  "date": "2026-05-24",
  "speaker": "Pastor Lindemann",
  "points": ["Counting the cost", "Carrying the cross", "Renouncing all"],
  "tags": ["discipleship", "cost", "sermon-on-the-mount"],
  "notes": {
    "intro": "Crowds were following; Jesus turns and raises the bar.",
    "application": "What am I unwilling to lay down?"
  }
}

Look at what just happened. A list (points). A nested object (notes). A flat set of scalars (title, date). All in one record, all stored and retrieved as a single unit. In a relational database this would be a sermons table, a points table, a tags table, and three foreign keys. Here it is one document.

The vocabulary maps cleanly onto what you already know:

RelationalDocument (MongoDB)
DatabaseDatabase
TableCollection
RowDocument
ColumnField
Primary key_id (auto-generated ObjectId if you don’t set one)
Schema (fixed, declared up front)No fixed schema required — each document can differ
JOIN across tablesEmbed (nest the data) or reference + a second query

Two facts deserve emphasis right now, because everything else in the chapter flows from them.

First: no fixed schema is required. You never run a CREATE TABLE with column types. You just insert a document, and the collection holds it. The next document you insert can have entirely different fields. MongoDB will not stop you. (Whether that is freedom or anarchy is §13.5’s whole argument.)

Second: documents are BSON, not literally JSON. On the wire and on disk MongoDB uses BSON — Binary JSON — a binary-encoded superset of JSON. BSON adds types JSON lacks: a real 64-bit integer, a true date type, binary blobs, and the ObjectId. You will write documents as Python dicts and think of them as JSON; under the hood the driver serializes them to BSON. When you see ObjectId('664f1a...') or a real datetime come back from a query, that is BSON’s richer type system showing through.

Coach’s Note — “Schemaless” is a marketing word and a lie of omission. Your data always has a schema — the set of fields your code expects to find. The only question is whether the schema lives in the database (relational: declared, enforced) or in your application code and your head (document: implicit, unenforced). MongoDB does not abolish the schema. It moves the responsibility for it from the database onto you. Never forget that you are now holding it.


13.2 — pymongo From Zero: Connect, Database, Collection

Everything in this chapter runs against a local MongoDB server. Installing mongod and the pymongo driver is covered in Appendix B; from here on we assume mongod is running on the default port 27017 and you have run pip install pymongo.

Coach’s Note — MongoDB is client/server, like Postgres — not a single file, like SQLite. mongod is the server process; pymongo is the client driver, the Mongo analogue of psycopg. If the connection hangs or refuses, the first question is always the same one as in Week 12: is the server actually running, on the port I think it is?

Connecting is three lines:

from pymongo import MongoClient

client = MongoClient("mongodb://localhost:27017/")
db = client["studyjournal"]        # a database (created lazily on first write)
sermons = db["sermons"]            # a collection (also created lazily)

Two things that will surprise a relational programmer:

  • The database and collection do not need to exist first. MongoDB creates them lazily, on the first document you actually insert. Asking for client["studyjournal"] when no such database exists is not an error; it is a promise to make one when you write.
  • MongoClient is cheap and meant to be long-lived. Create one client for your whole application (it manages an internal connection pool, exactly like the pool you built in Week 12) and reuse it. Do not open a new MongoClient per request. That is the Mongo version of the connection-pool lesson you already paid for.

The connection string mongodb://localhost:27017/ is the Mongo equivalent of a Postgres DSN. In production it carries credentials and a host; for local work the default is enough.


13.3 — CRUD: insert, find, update, delete

This is the heart of the chapter. Every operation takes Python dicts and returns Python dicts. There is no SQL string to build, no ? placeholders to count — the query itself is a document. Hold onto that idea; it is the thing that makes some queries natural here that were awkward in SQL.

Create — insert_one / insert_many

result = sermons.insert_one({
    "title": "The Cost of Discipleship",
    "passage": "Luke 14:25-33",
    "speaker": "Pastor Lindemann",
    "points": ["Counting the cost", "Carrying the cross", "Renouncing all"],
    "tags": ["discipleship", "cost"],
})
print(result.inserted_id)          # ObjectId('664f1a...') — Mongo generated _id for you

If you don’t supply an _id, MongoDB generates an ObjectId — a 12-byte value that is unique and roughly time-ordered. To insert several at once:

sermons.insert_many([
    {"title": "On Vocation", "passage": "1 Cor 7:17", "tags": ["vocation"]},
    {"title": "The Two Kingdoms", "passage": "Rom 13:1", "tags": ["government", "vocation"]},
])

Notice the second document has different fields from §13.1’s — no speaker, no points, no notes. The collection accepts both without complaint. That is the document model in action.

Read — find_one / find with a filter document

A query in MongoDB is a dict that describes what you’re looking for. This is called a filter document, and it is the single most important idea in the chapter.

# Exact match on a field — the equivalent of WHERE speaker = 'Pastor Lindemann'
one = sermons.find_one({"speaker": "Pastor Lindemann"})

# Multiple fields = AND
many = sermons.find({"speaker": "Pastor Lindemann", "passage": "Luke 14:25-33"})

# find() returns a lazy cursor; iterate it
for doc in sermons.find({"tags": "vocation"}):
    print(doc["title"])

That last query is worth a long pause. The filter {"tags": "vocation"} matches any document whose tags array contains the string "vocation". You did not write “find rows where the tags column equals an array containing vocation.” You said “tags is vocation,” and MongoDB, seeing that tags is an array, matched element-wise. Matching a scalar against an array field checks membership. In SQL, tags would be a separate join table and this would be a JOIN ... WHERE tag = 'vocation'. Here it is one field in one filter. (Hold that thought for §13.4.)

Filters use query operators for anything beyond exact match. They are dict keys that start with $:

You wantFilter document
speaker == "X"{"speaker": "X"}
year > 2025{"year": {"$gt": 2025}}
year >= 2025 AND year <= 2026{"year": {"$gte": 2025, "$lte": 2026}}
speaker IN (a, b){"speaker": {"$in": ["a", "b"]}}
tags array contains "cost"{"tags": "cost"}
tags contains ALL of these{"tags": {"$all": ["cost", "discipleship"]}}
field exists at all{"notes": {"$exists": True}}
NOT equal{"speaker": {"$ne": "X"}}

You can project (choose which fields come back) with a second argument, exactly like SELECT col1, col2:

# return only title and passage, suppress the _id
for doc in sermons.find({"tags": "vocation"}, {"title": 1, "passage": 1, "_id": 0}):
    print(doc)

Update — update_one / update_many

Updates take two documents: a filter (which documents) and an update (what to change). The update uses $ update operators — most often $set:

sermons.update_one(
    {"title": "On Vocation"},
    {"$set": {"speaker": "Pastor Lindemann", "year": 2026}}
)

Coach’s Note — The single most common, most destructive pymongo mistake: passing {"speaker": "X"} as the update instead of {"$set": {"speaker": "X"}}. Without $set, MongoDB replaces the entire document with {"speaker": "X"} — every other field is gone. The $ operators are not optional decoration. They are the difference between “change one field” and “obliterate the record.” Type $set every single time until your fingers do it without your brain.

Other update operators you’ll reach for:

sermons.update_one({"title": "On Vocation"},
                   {"$push": {"tags": "calling"}})       # append to an array
sermons.update_one({"title": "On Vocation"},
                   {"$inc": {"view_count": 1}})           # increment a number
sermons.update_one({"title": "On Vocation"},
                   {"$unset": {"draft": ""}})             # remove a field entirely

update_many applies the same update to every matching document. And update_one(..., upsert=True) inserts the document if no match exists — “update or insert,” the classic upsert.

Delete — delete_one / delete_many

sermons.delete_one({"title": "On Vocation"})
result = sermons.delete_many({"year": {"$lt": 2000}})
print(result.deleted_count)

delete_one removes the first match. delete_many removes all matches. A delete_many({}) with an empty filter deletes every document in the collection — the Mongo equivalent of DELETE FROM table with no WHERE. Respect it accordingly.

A full, runnable walkthrough of all of this lives in code/crud_demo.py.


13.4 — When the Document Model Fits (and a Query SQL Hates)

A document store is the right tool when the data is genuinely document-shaped. Four signs, and if most of them hold, you are looking at a document:

  1. Varying fields. Records don’t all have the same attributes. One study entry has a cross_references list; another has a hebrew_word and a transliteration; a third has neither. There is no clean set of columns.
  2. Nested structure is natural. The data is a tree — an object with sub-objects and lists — that you’d be carving into multiple tables only because the relational model forces you to.
  3. Read-mostly, and read as a whole. You almost always fetch the entire thing at once (the whole sermon, the whole study entry), display it, and rarely query its innards independently.
  4. The aggregate is the unit. The document is the thing you care about. You’re not constantly slicing one field across thousands of records to compute relational aggregates; you’re storing and retrieving whole objects.

Sermon notes hit all four. So do study journal entries with arbitrary structure. So does a product catalog where a book has page_count and a study guide has session_count and a hymnal has tune_names.

Here is the kind of query that is natural in Mongo and awkward in SQL. Suppose each study entry can carry an arbitrary nested metadata object, and we want every entry tagged grace whose metadata says the original language is Greek:

entries.find({
    "tags": "grace",
    "metadata.language": "Greek"
})

That "metadata.language" is dot notation — querying into a nested field. The document model makes querying nested structure a first-class operation: parent.child.grandchild reaches as deep as the document goes.

Now picture the same thing relationally. The metadata had arbitrary fields — you couldn’t have made it a fixed column. So either (a) you’d have a metadata table of (entry_id, key, value) rows — the dreaded entity-attribute-value pattern — and your query becomes a self-join filtering on key = 'language' AND value = 'Greek', and a join to a tags table for the grace tag; or (b) you’d have stuffed the metadata into a JSON column and be querying it with vendor-specific JSON functions — at which point you’ve bolted a tiny document store onto your relational database to escape the relational model. Either way, the relational schema is fighting the data.

Coach’s Note — “Awkward in SQL” almost always means one specific thing: the data has variable or deeply nested structure, and the relational model only natively understands flat rows. Whenever you find yourself reaching for an EAV table or a JSON column, stop. Your data is telling you it might be a document. Listen to it — then check it against the tradeoffs in §13.5 before you act, because the document store has a price too.


13.5 — The Tradeoffs, Honestly

This is the most important section in the chapter. Anyone can call insert_one. The architect knows what it costs.

Schema flexibility: freedom AND danger

In §13.3 you inserted documents with completely different fields into one collection, and Mongo accepted them all. That is freedom: no migration to add a field, no ALTER TABLE, ship a new feature by just writing the new shape.

It is also danger. Consider what the relational schema was silently doing for you that Mongo now does not:

# All of these go into the same collection without complaint:
entries.insert_one({"title": "Grace", "tags": ["grace"]})
entries.insert_one({"titel": "Mercy", "tags": ["mercy"]})       # typo'd key — accepted!
entries.insert_one({"title": "Hope", "tags": "hope"})           # tags a string, not a list!
entries.insert_one({"title": 7})                                # title is a number now!

Every one of those is a future bug. The typo’d titel is a field your code will never read. The string tags breaks the array-membership query from §13.4. The numeric title blows up your template. In Postgres, three of these four would have been rejected at the door by a NOT NULL, a type, or a column that simply doesn’t exist. Mongo enforces none of it by default. The guardrails are gone, and you are the guardrail now — in your application code, in your validation layer, in your code review. (MongoDB does offer optional schema validation rules you can attach to a collection, which claws some of this back; reach for them in any serious system. But they are opt-in, and the default is wide open.)

Denormalization: embed vs reference

In the relational world you normalized: every fact lived in exactly one place, and you joined to bring facts together. The document world often does the opposite — it denormalizes, duplicating data so that one read fetches everything. The fundamental modeling choice is embed or reference:

Embed (nest the related data)Reference (store an id, query again)
ShapeRelated data lives inside the documentA field holds another document’s _id
ReadOne query gets everythingTwo queries (or a $lookup), like a join
WriteUpdate the parentUpdate the referenced doc once
DuplicationDuplicates the embedded data everywhereSingle source of truth, no duplication
Best whenData is owned by, and read with, the parentData is shared, large, or changes independently

Example. A sermon has a speaker. Embed the speaker’s name and you fetch the whole sermon in one read — but if Pastor Lindemann’s title changes, you must update every sermon document that embedded it. Reference a speaker_id and the speaker lives in one place, updated once — but every read of a sermon-with-speaker is now two queries.

This is not a bug in MongoDB; it is the document-model tradeoff, stated plainly:

Embedding trades duplicated data (and the risk of those copies drifting out of sync) for avoided joins (and faster reads).

You will recognize the shape of this from Phase 1: it is a time/space tradeoff wearing new clothes. Embedding spends space (duplication) to buy time (one read instead of two). Referencing spends time (the extra query) to buy space and consistency (one copy, never stale). The architect picks per relationship, based on read pattern and how often the shared data changes.

What you LOSE versus a relational schema

Be honest about the bill:

  • Joins. Mongo has $lookup in its aggregation pipeline, but it is not the optimized, indexed, first-class join the relational engine spent decades perfecting. If your data is full of many-to-many relationships you constantly join across, you are fighting the document model. That is the relational model’s home court.
  • Strong constraints. No NOT NULL, no CHECK, no foreign-key enforcement by default. The single-source-of-truth guarantee a normalized schema gives you for free, you now maintain by hand.
  • Multi-document transactions are supported in modern MongoDB but are the exception, not the everyday tool; the relational BEGIN/COMMIT/ROLLBACK you used in Week 12 is more central and more battle-worn there.
  • A declared, enforced shape that any new engineer can read off the schema. In Mongo the shape lives in your code and your discipline.

Coach’s Note — Say it out loud so it sticks: NoSQL is not “better than SQL.” It is a different tool. The name “NoSQL” is itself misleading — it was a hashtag, not a thesis. The real distinction is relational vs document (and key-value, and graph, and column-family — there’s a whole family of non-relational stores, each fit to a different shape). An engineer who reaches for Mongo because SQL feels “old” has made the exact mistake this book exists to prevent: choosing a tool by fashion instead of by constraint.


13.6 — The Decision: Relational vs Document

This is the capstone of the persistence arc — Weeks 11, 12, and 13 converging on a single judgment. You now know both shapes from the inside. Here is how an architect chooses, driven by the constraints of the problem, not by preference.

Walk these five questions before you choose. No single answer decides it; the weight of them together does.

ConstraintLeans RelationalLeans Document
Query patternsMany ad-hoc queries, aggregations, slicing fields across all rows, frequent joinsFetch/store whole aggregates; queries follow the document shape; little cross-record joining
Consistency needsStrong: foreign keys, multi-row transactions, no stale duplicates tolerableEventual/relaxed is acceptable; duplication manageable; the aggregate is the consistency boundary
Schema stabilityShape is known, shared across all records, and stableShape varies per record and evolves quickly; fields differ legitimately
RelationshipsRich many-to-many, deeply interconnected entitiesMostly self-contained aggregates with few cross-references; tree-shaped data
Scale shapeVertical scale and complex querying on one strong node is fineMassive horizontal scale, sharding by document key, read-heavy at huge volume

Name the conditions plainly:

Choose relational when the data is tabular and uniform, the relationships are rich and you join across them constantly, you need strong consistency and real transactions, and the schema is stable enough that migrations are rare. A church membership system — people, households, groups, giving records, all richly related and queried every which way — is relational to its bones.

Choose document when the data is genuinely document-shaped (the four signs of §13.4), the fields vary legitimately per record, you read and write whole aggregates, the relationships are shallow, and the schema needs to evolve fast. A sermon-notes archive, a CMS with heterogeneous content types, a product catalog of wildly different product shapes, an event/activity log — these are documents.

And the honest middle: most real systems are mixed. The relational database holds the membership and the giving (it must be consistent and joined); the document store holds the sermon notes and the content (it varies and is read whole). “Pick one database for the whole system” is itself often the wrong frame. The architect picks per bounded piece of the data.

Coach’s Note — The wrong way to make this decision is to make it after you’ve chosen the database — to pick Mongo because a tutorial used it, then bend your richly-relational data into documents and spend two years writing application code to re-implement joins and constraints the relational engine would have given you for free. The decision comes first, from the constraints, before the first insert_one. That ordering — tool after constraint, never before — is the entire thesis of this book in one sentence.


13.7 — Common Bugs

Bug: An update_one wipes every field except the one you set. Example: sermons.update_one({"_id": x}, {"speaker": "Lindemann"}) — no $set. MongoDB replaces the whole document with {"speaker": "Lindemann"}. Fix: Always wrap field changes in an update operator: {"$set": {"speaker": "Lindemann"}}. The bare-dict form is a full replacement, almost never what you want.


Bug: A query that should match returns nothing because of a type mismatch. Example: find({"year": "2026"}) returns nothing because the documents stored year as an int 2026, not the string "2026". BSON distinguishes 2026 from "2026". Fix: Match the BSON type exactly. With no schema enforcement, your own code must be disciplined about always storing the same type for a field. This is the “you are the guardrail now” lesson biting.


Bug: ObjectId lookups silently fail because you passed a string. Example: find_one({"_id": "664f1a2b..."}) returns None even though the document exists, because _id is an ObjectId, not a string. Fix: from bson import ObjectId and wrap it: find_one({"_id": ObjectId("664f1a2b...")}).


Bug: find() “returns nothing” the second time you loop over it. Example: cursor = coll.find({...}); you loop once to count, then loop again to print — and the second loop is empty. Fix: find() returns a cursor, consumed once. Materialize it with list(cursor) if you need to iterate more than once, or issue the query again.


Bug: A new MongoClient is created on every request and connections pile up. Example: Opening MongoClient(...) inside a request handler. Each one spins up its own pool; under load you exhaust connections. Fix: Create one MongoClient at application startup and share it. It is thread-safe and pools internally — the same lesson as the Postgres connection pool in Week 12.


Bug: The delete_many({}) you meant to run on test data ran on production. Example: An empty filter {} matches every document. delete_many({}) empties the collection. Fix: Treat empty filters with the same fear you treat DELETE FROM table with no WHERE. Read the filter twice before a destructive operation. In code review, an empty-filter delete/update is always a flag.


13.8 — Reps

Open the exercises for the full set. This week’s reps build the muscle for both halves of the project — the Mongo CRUD and the relational comparison — and force the decision you’ll defend in the Hard tier.

A preview:

  • Rep 1 — Connect to local Mongo, insert a handful of document-shaped records, read them back.
  • Rep 4 — Write the query that’s natural in Mongo and painful in SQL, then write the SQL it would have taken.
  • Rep 8 — Model the same data both ways and list what each makes easy and hard.
  • Rep 11 — Walk the five-constraint decision table for three different apps cold.

Phase 2 means agentic AI is on — but these reps are still hand-built. The agent can scaffold a pymongo call all day. It cannot make the modeling decision for you, and the modeling decision is the point.


13.9 — This Week’s Project

You’re ready for Project 13 — Model It in Mongo, in Project 13.

Normal tier: take a genuinely document-shaped problem, model it in MongoDB, implement full CRUD against a collection, and demonstrate one query that is natural here and awkward in SQL. Medium tier: model the same data relationally back in Postgres/SQLite and write a side-by-side of what each model makes easy and hard, including the denormalization tradeoff. Hard tier: write the architect’s relational-vs-document memo for a concrete application and its real constraints — defended, with the conditions under which you’d choose the other — and show exactly where an agent scaffolded the code and where the modeling decision had to be yours.

Phase 2 rules: agentic AI is on, and an agent-log.txt is required. The project is shaped so the agent cannot finish it alone — the modeling and the decision are human work by design.


13.10 — Coach’s Final Word for Week 13

Three weeks, three databases, one skill.

SQLite taught you the relational model on the smallest possible footing. Postgres taught you the same model at real weight, with the costs you can measure. Mongo taught you that the relational model is not the only shape data comes in — and, more importantly, how to tell which shape you’re holding.

If you finish this week thinking “Mongo is the new hotness, SQL is legacy,” you have learned the opposite of the lesson. Go back and read §13.5 until it stings. If you finish thinking “documents are a toy, real engineers use SQL,” you have also missed it — go model the sermon notes relationally and feel the joins multiply.

The lesson is the wineskin. New wine needs a new skin; old wine does not need a fashionable one. The fit is the whole thing. An architect who can look at a problem and say this is relational or this is document — and say why, from the constraints — has the judgment this entire book was built to grow.

You can now hold both shapes in your head. Next week you put a face on the system: the front end the world actually sees.

If you find this hard: that’s the gap. Close it.

See you on Monday.


Up next: Complete every rep in the exercises, then build Project 13 — Project 13: Model It in Mongo. After that, Chapter 14 — the front end that’s good enough. (Coming from Chapter 12 — PostgreSQL and scaling up.)