Understanding MongoDB and Document Databases
A foundations guide, from first principles to real-world practice
CHAPTER 1What a Document Database Actually Is
MongoDB stores data as documents: JSON-like objects with fields, nested objects, and arrays. Documents live in collections, and collections live in databases. There is no fixed schema declared upfront; two documents in the same collection may have different fields, and the shape of the data is whatever the application writes.
A single document can represent what a relational database would spread across five tables:
{
"_id": "665f1c9ae4b0a1f2c3d4e5f6",
"username": "lakshay",
"email": "l@example.com",
"profile": {
"city": "New Delhi",
"interests": ["gamedev", "digital art"]
},
"orders": [
{ "total": 499.00, "status": "delivered", "items": ["milk", "ghee"] },
{ "total": 199.00, "status": "pending", "items": ["paneer"] }
]
}
This is the founding idea of the document model: data that is accessed together is stored together. A relational database normalizes this into users, profiles, orders, order_items tables and joins them back at read time. MongoDB pre-joins at write time by nesting. One read returns the whole picture.
MongoDB emerged in 2009 from the "NoSQL" wave, driven by two pressures on the relational model: web applications with rapidly changing data shapes (where every change meant a schema migration), and the need to scale horizontally across many machines (which joins and cross-machine transactions make hard). The document model answers both: flexible shape, and self-contained documents that shard cleanly across servers.
The tradeoffs are the mirror image of SQL's strengths, and this book will be honest about them: integrity enforcement moves from the database into your application, cross-document consistency is harder, and duplicated data must be kept in sync by code you write. Neither model is better; they optimize for different things.
CHAPTER 2Documents, Collections, and BSON
BSON, not JSON
MongoDB stores documents in BSON (Binary JSON), a binary encoding of JSON extended with types JSON lacks:
| BSON type | Why it exists |
|---|---|
ObjectId |
Compact unique identifiers (next chapter) |
Date |
Real timestamps, not strings |
Int32 / Int64 / Double |
Distinct numeric types |
Decimal128 |
Exact decimals for money |
Binary |
Raw bytes |
Practical consequences: dates are real date objects you can range-query and sort correctly; money should use Decimal128 rather than floats; and when documents travel to a JSON API, types like ObjectId and Date need explicit conversion to strings, because JSON has no such types.
A document has a size limit of 16 MB. This sounds huge and usually is, but it becomes relevant in data modeling (Chapter 7): an array that grows forever inside one document will eventually hit the wall.
Collections are lazy and schemaless
There is no CREATE TABLE. Inserting into a collection that does not exist creates it:
db.users.insertOne({ username: "lakshay" })
Nothing stops the next insert from having completely different fields. This is freedom and danger in equal measure: no migrations for adding a field, but also no engine-level guarantee that email exists or is a string. Chapter 10 covers the opt-in validation that tames this.
Naming conventions: databases and collections are lowercase, collections are plural nouns (users, orders), fields are camelCase or snake_case (pick one and be consistent).
CHAPTER 3The _id Field and ObjectId
Every document has an _id field, unique within its collection. It is the primary key, backed by an automatic unique index. If you do not supply one, MongoDB generates an ObjectId: a 12-byte value rendered as 24 hex characters, like 665f1c9ae4b0a1f2c3d4e5f6.
The 12 bytes are not random noise:
- 4 bytes: creation timestamp (seconds since epoch)
- 5 bytes: random value per machine/process
- 3 bytes: incrementing counter
Two useful consequences fall out of this structure. First, ObjectIds are roughly time-ordered, so sorting by _id approximates sorting by creation time, and the creation timestamp can be extracted from the id itself. Second, they can be generated on the client without coordination, which is what lets MongoDB scale writes across many machines: no central counter, unlike SQL's auto-increment.
The cost is ergonomic: ObjectIds are ugly in URLs, opaque to humans, and not JSON-native. Applications convert them to strings at the API boundary, and any system migrating from integer IDs to ObjectIds changes its public API shape in the process. You can use your own _id values (strings, UUIDs, integers) when you have a natural unique key, and this is common for things like country codes or slugs.
CHAPTER 4CRUD, Part 1: Creating and Reading
The examples in this chapter use mongosh, the MongoDB shell, which speaks JavaScript. Every driver mirrors these methods almost exactly.
Insert
db.users.insertOne({
username: "lakshay",
email: "l@example.com",
createdAt: new Date()
})
db.users.insertMany([
{ username: "a", email: "a@x.com" },
{ username: "b", email: "b@x.com" }
])
The return value includes the generated _id(s).
Find
find(filter, projection) is the workhorse:
// All documents (returns a cursor; the shell prints the first 20)
db.users.find()
// Equality filter
db.users.find({ username: "lakshay" })
// One document or null
db.users.findOne({ email: "l@example.com" })
// Projection: return only some fields (1 = include, 0 = exclude)
db.users.find({ isActive: true }, { username: 1, email: 1, _id: 0 })
// Sort, skip, limit (chainable on the cursor)
db.users.find().sort({ createdAt: -1 }).skip(20).limit(10)
Querying nested fields and arrays
Dot notation reaches inside sub-documents, and array fields match if any element matches:
// Nested field
db.users.find({ "profile.city": "New Delhi" })
// Array contains a value
db.users.find({ "profile.interests": "gamedev" })
// Match inside an array of sub-documents
db.users.find({ "orders.status": "pending" })
This "reach into anything" style is where the document model feels most natural: queries follow the shape of the data with no joins in sight.
CHAPTER 5Query Operators: The Filter Language
Filters beyond plain equality use $-prefixed operators. The essential vocabulary:
Comparison
db.orders.find({ total: { $gt: 500 } }) // >
db.orders.find({ total: { $gte: 100, $lte: 500 } }) // range
db.orders.find({ status: { $ne: "cancelled" } }) // not equal
db.orders.find({ status: { $in: ["pending", "paid"] } }) // set membership
Logical
db.users.find({ $or: [ { city: "Delhi" }, { city: "Mumbai" } ] })
db.users.find({ $and: [ { isActive: true }, { age: { $gte: 18 } } ] })
// Note: multiple fields in one filter object are an implicit AND already
Element and existence
db.users.find({ phone: { $exists: false } }) // documents missing the field
db.users.find({ deletedAt: null }) // null OR missing
That last pair is a classic subtlety: because documents can simply lack fields, "no value" splits into "field absent" and "field present but null", and $exists distinguishes them.
Arrays
db.posts.find({ tags: { $all: ["mongo", "python"] } }) // contains both
db.posts.find({ tags: { $size: 3 } }) // exactly 3 elements
db.orders.find({ items: { $elemMatch: { qty: { $gt: 2 }, price: { $lt: 100 } } } })
$elemMatch requires a single array element to satisfy all conditions, versus conditions being satisfied by different elements. This distinction produces subtle bugs when missed.
Evaluation
db.users.find({ email: { $regex: /@iitd\.ac\.in$/ } }) // pattern match
Regex filters that are not anchored at the start of the string cannot use indexes efficiently, the same trap as SQL's LIKE '%...'.
CHAPTER 6CRUD, Part 2: Updating and Deleting
Updates take a filter and an update document built from $ operators:
// Set fields
db.users.updateOne(
{ username: "lakshay" },
{ $set: { "profile.city": "Mandi", isActive: true } }
)
// Increment counters, current timestamp
db.posts.updateOne({ _id: id }, { $inc: { views: 1 } })
db.users.updateOne({ _id: id }, { $currentDate: { lastSeen: true } })
// Remove a field
db.users.updateOne({ _id: id }, { $unset: { legacyFlag: "" } })
// Array surgery
db.users.updateOne({ _id: id }, { $push: { interests: "photography" } })
db.users.updateOne({ _id: id }, { $pull: { interests: "gamedev" } })
db.users.updateOne({ _id: id }, { $addToSet: { interests: "art" } }) // no duplicates
// Update a matched array element via the positional operator
db.orders.updateOne(
{ _id: id, "items.sku": "MILK1L" },
{ $set: { "items.$.qty": 3 } }
)
Key behaviors:
updateOnetouches the first match;updateManytouches all matches. Choosing the wrong one is the Mongo equivalent of forgetting aWHEREclause.- Upsert:
updateOne(filter, update, { upsert: true })inserts a new document if nothing matches. Extremely common for "create or refresh" logic. replaceOneswaps the entire document;$setstyle updates are almost always what you want instead.- Single-document updates are atomic: two concurrent
$incoperations on the same document cannot lose an increment. This atomicity boundary (one document) is the foundation of Mongo data modeling.
Deletes mirror the update pair:
db.orders.deleteOne({ _id: id })
db.orders.deleteMany({ status: "cancelled" })
There is no cascade. Deleting a user does nothing to documents in other collections that reference it; that cleanup is the application's job, and forgetting it produces orphaned documents.
CHAPTER 7Data Modeling: Embedding vs Referencing
This is the most important chapter in the book. In SQL, schema design follows normalization rules toward one canonical answer. In MongoDB, schema design follows the question: how does the application read and write this data? The same entities can be modeled several defensible ways, and the design decision is choosing between two strategies.
Embedding: nest it inside
{
"_id": "...",
"username": "lakshay",
"address": { "city": "New Delhi", "pin": "110016" },
"orderHistory": [
{ "total": 499, "date": "..." }
]
}
Embed when:
- The child data is owned by the parent and has no life of its own (an address, order line items).
- Parent and child are read together nearly always. One query, no join, fast.
- The embedded data is bounded: an order has dozens of items, not millions.
- You want atomic updates across parent and child (single-document atomicity covers everything embedded).
Referencing: store the other document's _id
// users collection
{ "_id": ObjectId("AAA"), "username": "lakshay" }
// posts collection
{ "_id": ObjectId("BBB"), "authorId": ObjectId("AAA"), "title": "..." }
Reference when:
- The child is accessed independently of the parent (posts are listed, searched, paginated on their own).
- The relationship is many-to-many (store an array of ids on one or both sides).
- The array would grow without bound. Unbounded embedded arrays are the cardinal modeling sin: they bloat toward the 16 MB limit, make every parent read heavier, and degrade write performance as the array grows.
- Many parents share the child and duplication would be costly to keep in sync.
The heuristics practitioners actually use
- "Data that is accessed together should be stored together." Start from the application's screens and endpoints, not from an entity diagram.
- One-to-few: embed. One-to-many: reference from the child (child holds
parentId). One-to-squillions: definitely reference, and never keep an array of child ids on the parent. - Duplication is allowed and often correct. Storing the product's name and price inside the order line (a snapshot) is good design: the order should show what was true at purchase time. Denormalization is a tool, not a failure.
- Every reference implies application code to maintain integrity: existence checks on write, cleanup on delete. Budget for it.
A useful mental summary: SQL normalizes by rule and joins at read; MongoDB shapes by usage and pays either at write (duplication upkeep) or at read (multiple queries), your choice per relationship.
CHAPTER 8The Aggregation Pipeline
find() filters and projects. Everything analytical (grouping, reshaping, joining, computing) happens in the aggregation pipeline: an array of stages, each transforming the stream of documents and passing results to the next, like Unix pipes.
db.orders.aggregate([
{ $match: { status: "completed" } },
{ $group: {
_id: "$userId",
orderCount: { $sum: 1 },
revenue: { $sum: "$total" }
}},
{ $sort: { revenue: -1 } },
{ $limit: 10 }
])
Read it as a sentence: take completed orders, group them by user summing counts and totals, sort by revenue, keep the top 10. This is the direct equivalent of SQL's WHERE + GROUP BY + ORDER BY + LIMIT.
The core stages
| Stage | Role | SQL cousin |
|---|---|---|
$match |
Filter documents | WHERE |
$project |
Reshape, compute fields | SELECT expressions |
$group |
Aggregate by key | GROUP BY |
$sort, $limit, $skip |
Order and paginate | same names |
$lookup |
Join another collection | LEFT JOIN |
$unwind |
Explode an array into one doc per element | no direct cousin |
$count |
Count the stream | COUNT(*) |
$lookup: the join that does exist
MongoDB can join:
db.orders.aggregate([
{ $lookup: {
from: "users",
localField: "userId",
foreignField: "_id",
as: "user"
}},
{ $unwind: "$user" }
])
Each order gains a user array holding the matched user documents; $unwind flattens it to a single object. $lookup behaves like a left outer join. It works, and it is routinely used, but it runs on the database as a pipeline stage rather than as the deeply optimized core operation joins are in SQL engines. Heavy reliance on $lookup across large collections is a signal that the data might have wanted embedding, or a relational database.
$unwind and array analytics
Arrays inside documents become analyzable by exploding them:
// Most common tags across all posts
db.posts.aggregate([
{ $unwind: "$tags" },
{ $group: { _id: "$tags", count: { $sum: 1 } } },
{ $sort: { count: -1 } }
])
Order stages so that $match comes as early as possible: it shrinks the stream and can use indexes, whereas a $match after $group cannot.
CHAPTER 9Indexes in MongoDB
The mechanics are the same B-tree story as SQL: without an index, a query scans the whole collection (COLLSCAN); with one, it walks a sorted structure (IXSCAN).
db.users.createIndex({ email: 1 }) // ascending single-field
db.users.createIndex({ email: 1 }, { unique: true }) // uniqueness enforcement
db.orders.createIndex({ userId: 1, createdAt: -1 }) // compound
db.posts.createIndex({ title: "text", body: "text" }) // full-text search
db.sessions.createIndex({ at: 1 }, { expireAfterSeconds: 3600 }) // TTL: auto-delete
Points that matter in practice:
- Only
_idis indexed automatically. Every other index is your responsibility, including on reference fields (userIdand friends), the exact analogue of SQL's un-indexed foreign key trap. - Unique indexes are the only duplicate protection. With no schema layer, a unique email is enforced only by
createIndex({email: 1}, {unique: true}). A system that relied on SQLUNIQUEconstraints and moves to Mongo without creating unique indexes has silently lost that guarantee. - Compound index order matters, with the same prefix rule as SQL:
{userId: 1, createdAt: -1}serves queries onuserId, or both fields, but notcreatedAtalone. Design rule of thumb (ESR): equality fields first, then sort fields, then range fields. - Indexes on nested fields (
"profile.city") and array fields (multikey indexes) work naturally. db.collection.find({...}).explain("executionStats")is Mongo'sEXPLAIN: check whether the plan saysIXSCANorCOLLSCANand how many documents were examined versus returned.
CHAPTER 10Schema Validation: Optional Guardrails
"Schemaless" describes the default, not the ceiling. MongoDB supports JSON Schema validation per collection:
db.createCollection("users", {
validator: {
$jsonSchema: {
required: ["username", "email"],
properties: {
username: { bsonType: "string", maxLength: 50 },
email: { bsonType: "string", pattern: "@" },
balance: { bsonType: "decimal", minimum: 0 }
}
}
},
validationAction: "error" // or "warn"
})
With this in place, malformed inserts are rejected, recovering a slice of what SQL constraints provided. In real-world Python systems, validation more often lives one layer up, in Pydantic models (directly, or via the Beanie ODM), which validate data on the way in and out of the application. The mature setup uses both: Pydantic as the primary, expressive layer, and unique indexes plus optional JSON Schema as the database-level backstop.
What no layer of Mongo validation gives you is referential integrity: nothing built-in verifies that authorId points to a real user, and nothing cascades deletes. Those rules exist only if application code implements them. This is the single biggest mental shift for someone arriving from SQL: the database trusts the application.
CHAPTER 11Transactions, Replica Sets, and Consistency
The atomicity ladder
- Single-document operations are always atomic, including updates that touch many fields and nested arrays at once. Good data modeling exploits this: embed what must change together, and most workflows never need more.
- Multi-document transactions exist (since MongoDB 4.0) with familiar semantics:
const session = db.getMongo().startSession()
session.startTransaction()
try {
accounts.updateOne({ _id: 1 }, { $inc: { balance: -500 } }, { session })
accounts.updateOne({ _id: 2 }, { $inc: { balance: +500 } }, { session })
session.commitTransaction()
} catch (e) {
session.abortTransaction()
}
But with two caveats: they carry real performance cost and are meant to be the exception, not the default; and they require a replica set. A standalone mongod (the default in most local Docker setups) cannot run transactions at all, and code that uses them will fail at runtime against it. Local development that needs transactions runs a single-node replica set.
Replica sets
A replica set is MongoDB's high-availability unit: one primary accepting writes, secondaries replicating from it, automatic election of a new primary if the current one dies. Production MongoDB is always a replica set. Two tunables express the consistency/speed dial:
- Write concern:
w: 1(primary acknowledged, fast) up tow: "majority"(a majority of nodes have it, durable against failover). - Read concern / read preference: read from the primary for freshest data, or from secondaries for scale at the price of slight staleness.
Sharding, in one paragraph
Beyond replication, MongoDB shards: it partitions a collection across multiple replica sets by a shard key, routing queries to the right partition. This is the horizontal-scale story that motivated the document model, and it works precisely because well-modeled documents are self-contained: a shard can answer questions about its documents without asking other shards. Shard key choice is a deep topic; the takeaway at foundations level is simply that this option exists and shapes Mongo's design philosophy.
CHAPTER 12The Python Ecosystem: PyMongo, Motor, Beanie
Four names cover the landscape:
- PyMongo: the official synchronous driver. Dicts in, dicts out. Right for scripts, data jobs, Flask/Django-style sync code. Wrong inside
async defcode, because its blocking calls stall the event loop. - Motor: the official asynchronous driver, PyMongo's API with
await. The correct base for FastAPI and any asyncio application. - Beanie: an async ODM (Object-Document Mapper) built on Motor and Pydantic. Documents are Pydantic models, so validation and serialization come free, and the API layer and database layer share one definition of the data.
- MongoEngine: an older synchronous ODM, Django-flavored; you will meet it in legacy code.
A representative Beanie + FastAPI setup, because it shows all the moving parts at once:
from beanie import Document, PydanticObjectId, init_beanie
from motor.motor_asyncio import AsyncIOMotorClient
from pydantic import EmailStr
from fastapi import FastAPI
class User(Document):
username: str
email: EmailStr
class Settings:
name = "users" # collection name
indexes = ["email"] # index declarations live with the model
app = FastAPI()
@app.on_event("startup") # or the lifespan context manager
async def init():
client = AsyncIOMotorClient("mongodb://localhost:27017")
await init_beanie(database=client.mydb, document_models=[User])
@app.get("/users/{user_id}")
async def get_user(user_id: PydanticObjectId):
return await User.get(user_id)
@app.post("/users")
async def create_user(user: User):
return await user.insert()
Things this snippet quietly demonstrates: the client is created once at startup (drivers pool connections internally; creating clients per request is an antipattern); PydanticObjectId handles the ObjectId-to-string problem at the API boundary; every database call is awaited; and model, validation, and collection config live in one class.
Raw Motor, for when an ODM is not wanted:
client = AsyncIOMotorClient(uri)
db = client.mydb
user = await db.users.find_one({"email": "l@x.com"})
await db.orders.insert_one({"userId": user["_id"], "total": 499})
cursor = db.orders.find({"total": {"$gt": 100}}).sort("createdAt", -1)
orders = await cursor.to_list(length=50)
CHAPTER 13Performance Patterns and Antipatterns
Patterns worth copying:
- Model around queries: the fastest query is the one whose answer is a single document you shaped in advance.
- Index every field you filter or sort on regularly, and verify with
explain(). - Use projections to return only needed fields, keeping documents and network payloads lean.
- Paginate large result sets with range queries on an indexed field (
createdAtor_id) rather than largeskipvalues, becauseskip(100000)still walks 100,000 index entries. - Batch writes with
insertMany/bulkWriteinstead of loops of single inserts.
Antipatterns that account for most Mongo pain in the wild:
- Unbounded embedded arrays (Chapter 7's cardinal sin): comments on a viral post embedded in the post document, events appended to a device document forever.
- The N+1 query loop: fetch 50 orders, then loop and fetch each order's user individually. Fix with one
$inquery for all needed users, or a$lookup. - Treating Mongo as a relational database: fully normalized collections joined by
$lookupeverywhere recreates SQL's costs without SQL's optimizer or guarantees. - Treating Mongo as a junk drawer: no validation, no unique indexes, inconsistent field names (
userIdhere,user_idthere), fields whose type varies by document. Schemaless describes the engine, not a license for the team. - Missing indexes on reference fields, producing collection scans on every "find children of parent" query.
- New client per request instead of one pooled client per process.
CHAPTER 14Translating Between SQL and MongoDB Thinking
The vocabulary map:
| SQL | MongoDB |
|---|---|
| Database | Database |
| Table | Collection |
| Row | Document |
| Column | Field |
| Primary key | _id |
| Foreign key | Reference (someId field) + application discipline |
| JOIN | $lookup, or embedding at design time |
WHERE |
Filter document / $match |
GROUP BY |
$group |
UNIQUE constraint |
Unique index |
| Schema (DDL) | Optional JSON Schema validator + app models |
| Migration | Usually unnecessary for additive change; data backfills as scripts |
| Transaction | Single-doc atomicity first; multi-doc transactions on replica sets |
And the deeper conceptual translations, which matter more than the vocabulary:
- Where integrity lives. SQL: in the database, declared once, enforced always. Mongo: in the application (Pydantic/ODM validation, existence checks, cleanup code), with unique indexes as the one hard backstop.
- When the join happens. SQL joins at read time from normalized tables. Mongo "joins" at write time by embedding, or lightly at read time with
$lookupor extra queries. - What drives the design. SQL design starts from the data's logical structure (entities, normal forms). Mongo design starts from the application's access patterns (screens, endpoints, hot paths).
- How the schema evolves. SQL evolves through migrations, explicit and versioned. Mongo evolves through code, implicitly, which is lighter for additive changes and riskier for reshaping ones, since old and new document shapes coexist until backfilled.
- What each makes cheap. SQL makes ad-hoc questions and cross-entity guarantees cheap. Mongo makes the anticipated read path and horizontal scale cheap.
If a single sentence summarizes the shift: SQL protects the data from the application; MongoDB trusts the application with the data. Everything else follows.
CHAPTER 15Running MongoDB: Connection Strings, Docker, Operations
Connection strings
Everything a driver needs is one URI:
mongodb://user:password@host:27017/dbname?authSource=admin
mongodb+srv://user:pass@cluster0.abc.mongodb.net/dbname # Atlas (cloud), DNS-based
Applications read this from an environment variable (MONGODB_URI by convention), never hardcode it, and ship a .env.example documenting it.
Docker and Docker Compose
The standard local/production-ish setup:
services:
mongo:
image: mongo:7
restart: unless-stopped
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: example
volumes:
- mongo_data:/data/db # persistence across restarts
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 10s
retries: 5
api:
build: .
environment:
MONGODB_URI: mongodb://root:example@mongo:27017/app?authSource=admin
ports:
- "8000:8000"
depends_on:
mongo:
condition: service_healthy
volumes:
mongo_data:
The details that separate a working setup from a fragile one: the named volume (without it, data vanishes with the container), the healthcheck plus depends_on: condition (without it, the API races Mongo at startup and crashes), the hostname mongo inside the network (not localhost), and credentials via environment.
Everyday operational commands
// Inside: docker exec -it <container> mongosh -u root -p example
show dbs
use app
show collections
db.users.findOne()
db.users.countDocuments()
db.users.getIndexes()
db.stats()
Backups use mongodump / mongorestore (BSON archives) or mongoexport / mongoimport (JSON/CSV, lossier on types). Managed hosting via MongoDB Atlas offloads replication, backups, and upgrades, and is how most production Mongo actually runs today.
Closing perspective
MongoDB rewards teams that embrace its worldview: shape data around usage, exploit single-document atomicity, index deliberately, validate at the application layer, and accept responsibility for the integrity SQL used to provide for free. Used that way, it delivers what it promises: flexible modeling, natural fit with JSON-speaking applications, and a clean path to horizontal scale. Used as a schemaless SQL substitute, it delivers mostly regret. The difference is entirely in the modeling, which is why Chapter 7 is the one to reread.
End of guide.