Document Foundations

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:

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:

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:

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 heuristics practitioners actually use

  1. "Data that is accessed together should be stored together." Start from the application's screens and endpoints, not from an entity diagram.
  2. 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.
  3. 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.
  4. 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:

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

  1. 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.
  2. 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:

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:

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:

Antipatterns that account for most Mongo pain in the wild:

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:

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.