Chapter 13 — Reps
Conditioning, not grading. pymongo against a local MongoDB this week.
Ground rules:
- Type every line yourself. Including the filter documents. The
$-operator vocabulary only sticks if your fingers learn it. - Run everything. Every insert, every find, every update — run it, then open a
mongoshshell (or re-query in Python) and confirm the database actually changed the way you expected. Trust nothing you didn’t observe. - AI / agents are ON (Phase 2) — but these reps are still hand-built. Use an agent to look up an operator or explain an error if you’re stuck, the way you’d ask a senior. Do not have it write the reps for you. The modeling reps (8, 9, 11) especially: the decision is yours, or the rep is worthless.
- One client, reused. Create a single
MongoClientat the top of each script and reuse it. Build the production habit now.
Setup for mongod + pip install pymongo lives in Appendix B. Everything below assumes mongod is running on localhost:27017. Each rep uses a throwaway database named reps13 so you can drop it and start clean: client.drop_database("reps13").
Reps 1–3: Connect and CRUD
Rep 1 — Hello, Document
Write rep01.py:
from pymongo import MongoClient
client = MongoClient("mongodb://localhost:27017/")
notes = client["reps13"]["notes"]
notes.delete_many({}) # start clean
notes.insert_one({
"title": "Counting the Cost",
"passage": "Luke 14:28",
"tags": ["discipleship", "cost"],
})
notes.insert_one({
"title": "On Vocation",
"passage": "1 Cor 7:17",
"tags": ["vocation"],
"speaker": "Pastor Lindemann", # a field the first doc doesn't have
})
for doc in notes.find():
print(doc)
Run it. Confirm two documents print, each with an _id MongoDB generated. Note that the second has a speaker field and the first does not — and the collection accepted both. That is the document model. Write one sentence in a comment: what would Postgres have required before accepting these two rows?
Rep 2 — Filter Documents
Against the notes collection from Rep 1, write and run each of these queries. Predict the result before you run each one, then check:
notes.find_one({"title": "On Vocation"})— exact match.notes.find({"tags": "cost"})— array membership (matches the first doc).notes.find({"speaker": {"$exists": True}})— only docs that have aspeakerfield.notes.find({"speaker": {"$exists": False}})— only docs that don’t.notes.find({"tags": {"$in": ["vocation", "grace"]}})— tags contains any of these.
For #2, write a one-line comment explaining why matching the scalar "cost" against the array tags works. (This is the §13.4 idea.)
Rep 3 — Update Without Wiping
Add to the same script:
update_oneto setyear: 2026on the “On Vocation” note — using$set. Confirm the other fields survive.- Now, on purpose, run
update_one({"title": "On Vocation"}, {"speaker": "X"})without$set. Print the document. Watch the entire document get replaced by{"_id": ..., "speaker": "X"}. Every other field is gone. - Restore the document. Write a comment: this is the §13.7 bug. Never forget the operator.
- Use
$pushto append"calling"to the “On Vocation” note’stagsarray. - Use
$incto create-and-increment aview_counton it (run it twice; confirm it reaches 2).
The deliberate destruction in #2 is the rep. You will only respect $set after you’ve watched its absence eat a document.
Reps 4–6: The Query SQL Hates, and Nested Fields
Rep 4 — Natural Here, Awkward There
Insert three study entries with an arbitrary nested metadata object:
entries = client["reps13"]["entries"]
entries.delete_many({})
entries.insert_many([
{"word": "charis", "tags": ["grace"],
"metadata": {"language": "Greek", "strongs": "G5485"}},
{"word": "chesed", "tags": ["grace", "covenant"],
"metadata": {"language": "Hebrew", "strongs": "H2617"}},
{"word": "agape", "tags": ["love"],
"metadata": {"language": "Greek"}},
])
Now write the query: every entry tagged grace whose metadata.language is Greek.
for e in entries.find({"tags": "grace", "metadata.language": "Greek"}):
print(e["word"])
It should print charis only. Then, in a comment block, write out the SQL you’d need to get the same answer if metadata were arbitrary key/value rows in an EAV table plus a separate tags table. Count the joins. That contrast is the whole rep.
Rep 5 — Projection
Re-run the Rep 4 grace-and-Greek query, but return only the word and metadata.strongs, suppressing _id:
entries.find({"tags": "grace", "metadata.language": "Greek"},
{"word": 1, "metadata.strongs": 1, "_id": 0})
Confirm the returned dicts contain only those fields. This is SELECT word, strongs in document form.
Rep 6 — Delete and Count
delete_onetheagapeentry. Confirmentries.count_documents({})drops by one.- Run
entries.delete_many({"tags": "grace"}). Confirm both grace entries are gone. - Don’t run this, just write it as a comment and explain the danger:
entries.delete_many({}). What does an empty filter match? (§13.7.)
Reps 7–9: Modeling — Embed, Reference, and Both Ways
Rep 7 — Embed vs Reference
Model a sermon-with-speaker two ways in a modeling database.
Embedded — speaker info nested inside the sermon:
sermons.insert_one({
"title": "The Cost of Discipleship",
"speaker": {"name": "Pastor Lindemann", "ordained": 2009},
})
Referenced — a separate speakers collection, sermon stores an _id:
sid = speakers.insert_one({"name": "Pastor Lindemann", "ordained": 2009}).inserted_id
sermons.insert_one({"title": "On Vocation", "speaker_id": sid})
Then: write the code to fetch “the sermon and its speaker’s name” for each model. Notice the embedded one is one query; the referenced one is two (fetch the sermon, then fetch the speaker by _id). Write a two-line comment: which would you choose if the speaker’s name changes often, and why? (§13.5: embed = duplication + one read; reference = single source of truth + two reads.)
Rep 8 — The Same Data, Both Models
Take this one study entry:
{
"word": "charis",
"passage": "Eph 2:8",
"tags": ["grace", "salvation"],
"metadata": {"language": "Greek", "strongs": "G5485"},
"cross_refs": ["Rom 3:24", "Titus 2:11"]
}
- Store it as a single MongoDB document (you basically already can — insert it).
- On paper (or in a comment), design the relational schema that holds the same information: how many tables? (
entries,tags,cross_refs, and an EAV-ishmetadata— count them.) Write theCREATE TABLEstatements. - Make a two-column list: what the document model makes easy / hard vs what the relational model makes easy / hard, for this data. At minimum address: adding an entry with brand-new metadata fields; querying “all Greek grace words”; ensuring every entry has a valid
passage.
This rep is the Medium tier of the project in miniature. Do it honestly — both columns have real wins.
Rep 9 — Schema Flexibility Is Also Danger
Into one collection, deliberately insert these four documents, then write the query find({"tags": "grace"}):
c.insert_one({"title": "A", "tags": ["grace"]})
c.insert_one({"titel": "B", "tags": ["grace"]}) # typo key
c.insert_one({"title": "C", "tags": "grace"}) # tags is a string, not a list
c.insert_one({"title": "D", "tags": ["grace"], "title2": 7})
Which of these does find({"tags": "grace"}) match? Predict, then run. (Surprise: the string-tags doc also matches, because {"tags": "grace"} matches both “field equals the string” and “array contains the string.”) Then try to print each match’s ["title"] and watch doc B (the typo) be missing a title.
In a comment, answer: which of these four would Postgres have rejected at insert time, and which would have silently entered the database there too? This is §13.5 — you are the guardrail now.
Reps 10–11: The Architect’s Decision
Rep 10 — Upsert and Bulk
- Write an
update_one(filter, {"$set": {...}}, upsert=True)that updates a sermon if it exists and inserts it if it doesn’t. Run it twice with the same filter; confirm the second run updates rather than duplicating. insert_manyten small documents in one call. Confirmcount_documents({})jumps by ten. Note that one round trip inserted all ten — the batching lesson.
Rep 11 — Walk the Decision Table Cold
No code. For each of these three applications, walk the five-constraint table from §13.6 (query patterns, consistency needs, schema stability, relationships, scale shape) and write a one-paragraph verdict: relational, document, or mixed — and why. Name at least one constraint that drove the call.
- A church membership system — people, households, small groups, giving records, attendance. Queried every which way; reports across all of it; money must reconcile exactly.
- A sermon archive — each sermon has a title, passage, audio link, and free-form notes whose structure differs by preacher and series; readers fetch one whole sermon at a time; thousands of them, read-heavy.
- A small-group event log — append-only stream of activity events of many different shapes (joined, posted, prayed-for, RSVP’d), rarely updated, occasionally queried by group and date range.
For at least one of them, your honest answer should be mixed — and you should say which piece goes where. If all three came out “use Mongo,” you skipped §13.5; go back.
Done? One Last Thing.
From scratch, no looking — in a single script:
- Connect to local Mongo with one
MongoClient. - Create a
journalcollection andinsert_onea document-shaped study entry with at least one array field and one nested object. findit back by an array-membership filter.update_onea nested field using$setand dot notation (e.g.{"$set": {"metadata.reviewed": True}}).- Write the SQL-hating query (a filter on a nested field + array membership) and, in a comment, the multi-join SQL it would have replaced.
delete_oneit. Confirm the collection is empty.
If you can do that cold — connect, model a document, CRUD it, and articulate the one query that justifies the document model — you have the week’s mechanical skill. The judgment (Rep 11) is the part the project will test hardest.
Up next: Project 13 — Project 13: Model It in Mongo.