Skip to content

MongoDB Шпаргалка

NoSQL document database for high-volume data storage.

01

Getting Started

Connect to MongoDB

mongosh is the modern MongoDB Shell (replacing the legacy mongo shell). A connection string follows the URI format mongodb://[user:pass@]host[:port]/[db][?options]. The /admin database is the default auth database. You can connect to a specific database directly by appending it to the URI.

mongodb
# connect to a local server on default port
mongosh

# connect to a specific host and port
mongosh "mongodb://localhost:27017"

# connect with authentication
mongosh "mongodb://user:pass@localhost:27017/admin"

# connect to a specific database directly
mongosh "mongodb://localhost:27017/mydb"

# show connection info
db.getMongo().getDBNames()

Database Basics

MongoDB creates databases and collections lazily — they only appear after you insert the first document. 'use mydb' switches context but does not create the database until data is written. show dbs does not list empty databases. dropDatabase permanently deletes all collections and their data.

mongodb
# show all databases
show dbs

# switch to (or create) a database
use mydb

# show the current database
db

# create is implicit: inserting into a
# non-existent collection creates both the
# collection and the database

# drop the current database
db.dropDatabase()

Collections

Collections are analogous to SQL tables and hold documents. They are created automatically on first insert, but createCollection lets you set options like capped (fixed-size collections that overwrite old documents — useful for logs). stats() shows storage size, document count, and index details.

mongodb
# list collections in current database
show collections
db.getCollectionNames()

# create a collection explicitly with options
db.createCollection("users", {
  capped: true,
  size: 5242880,
  max: 5000
})

# rename a collection
db.users.renameCollection("accounts")

# drop a collection
db.users.drop()

# get collection stats
db.users.stats()

BSON Data Types

BSON extends JSON with additional types essential for applications: ObjectId (12-byte unique IDs), ISODate, NumberDecimal (exact decimal for money — avoids float rounding), NumberLong (64-bit ints), BinData (binary), and more. Using NumberDecimal for currency avoids classic floating-point errors.

mongodb
# MongoDB stores BSON (binary JSON) with rich types
{
  _id: ObjectId("65a1b2c3d4e5f6a7b8c9d0e1"),
  name: "Alice",                       // String
  age: 30,                             // Int32
  score: NumberLong(9007199254740992), // Int64
  price: NumberDecimal("19.99"),       // Decimal128 (exact money)
  active: true,                        // Boolean
  birthday: ISODate("1995-08-15"),     // Date
  tags: ["red", "blue"],               // Array
  meta: { views: 100 },                // Embedded document
  data: BinData(0, "aGVsbG8="),        // Binary
  id: UUID("...")                      // UUID
}

Shell Helpers & Help

mongosh supports full JavaScript, so you can write loops, functions, and variables in the shell. .pretty() formats output for readability (default in mongosh). load() runs a .js file in the shell context — useful for repetitive admin scripts. db.collection.help() lists all available methods.

mongodb
# get help on database methods
db.help()

# get help on collection methods
db.users.help()

# list common shell commands
help

# pretty-print query results
db.users.find().pretty()

# print JSON in a compact form
db.users.find().toArray()

# execute a JavaScript file
mongosh --file script.js
load("script.js")
02

CRUD Operations

Insert Documents

MongoDB auto-generates an _id (ObjectId) if you don't provide one. The _id must be unique within a collection — duplicate _id raises a write error. ordered:false lets insertMany continue inserting remaining documents after an error, improving throughput for bulk loads.

mongodb
# insert a single document
db.users.insertOne({
  name: "Alice",
  email: "[email protected]",
  age: 30
})

# insert multiple documents
db.users.insertMany([
  { name: "Bob", age: 25 },
  { name: "Carol", age: 28 }
])

# insert with a custom _id
db.users.insertOne({ _id: 1, name: "Dave" })

# ordered insert (stops on error) vs unordered
db.users.insertMany(docs, { ordered: false })

Find / Query Documents

find() returns a cursor (lazily evaluated); findOne() returns a single document or null. Projection controls which fields are returned — you cannot mix inclusion and exclusion (except _id). Use 1 to include, 0 to exclude. countDocuments is accurate; the older count() is deprecated for filtered counts.

mongodb
# find all documents
db.users.find()

# find with a filter
db.users.find({ age: 30 })
db.users.find({ "address.city": "NYC" })

# find one matching document
db.users.findOne({ name: "Alice" })

# projection: include/exclude fields
db.users.find({}, { name: 1, age: 1, _id: 0 })
db.users.find({}, { email: 0 })

# count matching documents
db.users.countDocuments({ age: { $gte: 18 } })

Update Documents

updateOne/updateMany use update operators ($set, $inc, etc.) to modify specific fields. replaceOne replaces the whole document except _id. upsert:true creates a new document when no match exists — useful for 'create or update' patterns. Always use $set to update fields; a bare object replaces the document (legacy behavior).

mongodb
# update one document
db.users.updateOne(
  { name: "Alice" },
  { $set: { age: 31, status: "active" } }
)

# update many documents
db.users.updateMany(
  { status: "pending" },
  { $set: { status: "active" } }
)

# replace an entire document (keeps _id)
db.users.replaceOne(
  { name: "Alice" },
  { name: "Alice", age: 32, city: "NYC" }
)

# upsert: insert if no match found
db.users.updateOne(
  { name: "Eve" },
  { $set: { age: 22 } },
  { upsert: true }
)

Update Operators

Update operators modify fields atomically at the document level. $inc is concurrency-safe (no read-modify-write race). $min/$max only update if the new value is smaller/larger. $currentDate is handy for 'last modified' timestamps. Multiple operators can be combined in one update.

mongodb
# $set and $unset
db.users.updateOne({n:"A"}, { $set: {age:30}, $unset: {temp:""} })

# $inc increments a numeric field
db.users.updateOne({n:"A"}, { $inc: {views: 1} })
db.users.updateOne({n:"A"}, { $inc: {balance: -50} })

# $rename a field
db.users.updateOne({n:"A"}, { $rename: {n: "name"} })

# $min / $max keep the smaller/larger value
db.users.updateOne({n:"A"}, { $min: {lowScore: 80} })

# $mul multiply the field value
db.users.updateOne({n:"A"}, { $mul: {price: 1.1} })

# $currentDate sets to current date
db.users.updateOne({n:"A"}, { $currentDate: {updatedAt: true} })

Delete Documents

deleteOne removes the first match; deleteMany removes all matches. deleteMany({}) empties the collection but keeps indexes (faster than drop+recreate if you want to preserve index definitions). findOneAndDelete returns the deleted document atomically. Deletions are irreversible — always filter carefully.

mongodb
# delete one matching document
db.users.deleteOne({ name: "Alice" })

# delete all matching documents
db.users.deleteMany({ status: "inactive" })

# delete all documents in a collection
db.users.deleteMany({})

# findAndDelete: return the deleted document
db.users.findOneAndDelete({ name: "Bob" })

# remove is deprecated, prefer deleteOne/deleteMany
db.users.remove({ name: "Bob" }, { justOne: true })

Array Update Operators

$push appends (allows duplicates); $addToSet deduplicates. $pull removes elements matching a condition. The positional operator ($) refers to the first array element matched by the query — essential for updating specific array elements without knowing their index. $[] updates all array elements.

mongodb
# $push append to an array
db.users.updateOne({n:"A"}, { $push: {tags: "new"} })

# $push with $each for multiple values
db.users.updateOne({n:"A"}, { $push: {tags: { $each: ["x","y"] } } })

# $addToSet adds only if not already present
db.users.updateOne({n:"A"}, { $addToSet: {tags: "x"} })

# $pull remove all matching array elements
db.users.updateOne({n:"A"}, { $pull: {tags: "x"} })

# $pop remove first (-1) or last (1) element
db.users.updateOne({n:"A"}, { $pop: {tags: 1} })

# update an array element by position
db.users.updateOne({}, { $set: {"tags.0": "first"} })
db.users.updateOne({"tags":"old"}, { $set: {"tags.$":"new"} })
03

Querying Documents

Comparison Operators

Comparison operators are the foundation of queries. $in is far more efficient than multiple $or conditions on the same field. You can combine multiple operators on one field (e.g. $gte and $lte for a range). $exists:true finds documents with the field, $exists:false finds those without it.

mongodb
# $eq equal (same as direct value)
db.products.find({ price: { $eq: 100 } })

# $ne not equal
db.products.find({ price: { $ne: 100 } })

# $gt, $gte, $lt, $lte
db.products.find({ price: { $gte: 50, $lte: 200 } })

# $in matches any value in an array
db.products.find({ category: { $in: ["books", "music"] } })

# $nin not in array
db.products.find({ category: { $nin: ["books"] } })

# $exists check field presence
db.products.find({ discount: { $exists: true } })

Logical Operators

Implicit AND (comma in a filter) is the most common and efficient. Use explicit $and only when you need multiple conditions on the same field (implicit AND on the same field overrides earlier conditions). $or uses separate index scans and merges results — ensure fields in $or are indexed for performance.

mongodb
# $and (explicit, useful for same-field conditions)
db.users.find({ $and: [
  { age: { $gte: 18 } },
  { age: { $lte: 65 } }
]})

# $or matches if ANY condition is true
db.users.find({ $or: [
  { status: "active" },
  { vip: true }
]})

# $nor matches if NO condition is true
db.users.find({ $nor: [{ status: "active" }]})

# $not inverts a condition
db.users.find({ age: { $not: { $gt: 18 } } })

# implicit AND (comma-separated)
db.users.find({ age: { $gte: 18 }, status: "active" })

Element & Evaluation Operators

$type filters by BSON type (useful for mixed-type fields). $regex supports options like 'i' (case-insensitive) — but regex without a prefix anchor cannot use indexes efficiently. $expr enables comparisons between document fields, which normal operators can't do. Use $expr sparingly as it can bypass indexes.

mongodb
# $type filter by BSON data type
db.users.find({ age: { $type: "int" } })
db.users.find({ age: { $type: ["int", "double"] } })

# $regex regular expression match
db.users.find({ name: { $regex: "^Al", $options: "i" } })

# $mod modulo operation
db.users.find({ age: { $mod: [2, 0] } })  # even ages

# $expr compare fields within a document
db.orders.find({ $expr: { $gt: ["$total", "$budget"] } })

# $jsonSchema validate against a schema
db.users.find({ $jsonSchema: {
  required: ["name", "email"]
}})

Array Query Operators

$all finds arrays containing all specified values (order-independent). $elemMatch is essential when an array element must satisfy multiple conditions simultaneously (a plain query would match elements individually). $size matches exact lengths only — there's no $size with a range; pre-compute and store the length as a separate field instead.

mongodb
# $all: array contains ALL specified values
db.posts.find({ tags: { $all: ["mongo", "db"] } })

# $elemMatch: at least one element matches all conditions
db.students.find({ scores: { $elemMatch: {
  subject: "math", score: { $gte: 90 }
}}})

# $size: array of exact length
db.users.find({ tags: { $size: 3 } })

# match if array contains the value
db.posts.find({ tags: "mongo" })

# match a specific array index
db.users.find({ "scores.0": 95 })  # first score is 95

Cursor Methods & Sorting

skip+limit implements pagination but skip becomes slow for large offsets (it scans all skipped docs). For deep pagination, use keyset pagination: query with a filter on the sort field greater than the last seen value. sort() can use an index to avoid in-memory sorting — always index your sort fields for large collections.

mongodb
# limit and skip (pagination)
db.users.find().limit(10)
db.users.find().skip(20).limit(10)  # page 3

# sort: 1 ascending, -1 descending
db.users.find().sort({ age: 1, name: -1 })

# count results
db.users.find({active:true}).count()

# iterate the cursor in the shell
db.users.find().forEach(doc => print(doc.name))

# get a specific field's distinct values
db.users.distinct("city")
04

Indexing

Create Indexes

Indexes dramatically speed up queries but slow down writes and consume disk. Single-field indexes support queries on that field in either sort direction. Compound indexes follow the ESR (Equality, Sort, Range) rule for optimal ordering. TTL indexes automatically delete documents after a duration — perfect for sessions and logs.

mongodb
# create a single-field index
db.users.createIndex({ email: 1 })          # ascending
db.users.createIndex({ age: -1 })            # descending

# create a unique index
db.users.createIndex({ email: 1 }, { unique: true })

# create a compound index
db.users.createIndex({ lastName: 1, firstName: 1 })

# create a text index
db.posts.createIndex({ title: "text", body: "text" })

# create a TTL (time-to-live) index
db.sessions.createIndex({ createdAt: 1 },
  { expireAfterSeconds: 3600 })

# name an index explicitly
db.users.createIndex({ email: 1 }, { name: "email_idx" })

View & Manage Indexes

getIndexes lists all indexes with their keys and options. hideIndex/unhideIndex lets you test the impact of removing an index without actually dropping it — the query planner ignores hidden indexes. If performance stays the same, it's safe to drop. Always run explain() before and after index changes.

mongodb
# list all indexes on a collection
db.users.getIndexes()

# total index size in bytes
db.users.totalIndexSize()

# drop an index by name
db.users.dropIndex("email_1")

# drop all indexes except _id
db.users.dropIndexes()

# hide an index (test impact before dropping)
db.users.hideIndex("email_1")
db.users.unhideIndex("email_1")

# check if queries use indexes
db.users.find({email:"[email protected]"}).explain("executionStats")

Special Index Types

2dsphere indexes power geo queries ($near, $geoWithin). Hashed indexes support hash-based sharding for even data distribution. Wildcard indexes ($**) cover unpredictable/variable field names in documents — useful for polymorphic data. Partial indexes only index matching documents, saving space when you only query a subset.

mongodb
# geospatial 2dsphere index (GeoJSON points)
db.places.createIndex({ location: "2dsphere" })
db.places.find({
  location: { $near: {
    $geometry: { type: "Point", coordinates: [-73.99, 40.73] },
    $maxDistance: 1000
  }}
})

# hashed index (for sharding)
db.users.createIndex({ user_id: "hashed" })

# wildcard index (index arbitrary field names)
db.products.createIndex({ "$**": 1 })

# partial index (only matching documents)
db.users.createIndex({ email: 1 },
  { partialFilterExpression: { active: true } })

Explain & Query Plans

explain() is the primary performance tool. COLLSCAN (collection scan) means no index was used — a red flag for large collections. IXSCAN means an index was used. totalDocsExamined >> nReturned indicates a poor index (examining many docs to return few). hint() forces a specific index for testing the planner's choice.

mongodb
# explain a query (basic)
db.users.find({age:30}).explain()

# explain with execution stats (timing)
db.users.find({age:30}).explain("executionStats")

# key fields in the output:
#   winningPlan.stage    -> COLLSCAN (bad) or IXSCAN (good)
#   totalDocsExamined    -> should be close to nReturned
#   executionTimeMillis  -> query duration
#   indexUsed            -> which index was chosen

# force a specific index (testing)
db.users.find({age:30}).hint("age_1")
db.users.find({age:30}).hint({age:1})

Indexing Best Practices

The ESR (Equality, Sort, Range) rule is the single most important compound-index design principle: put equality-filtered fields first, then sort fields, then range fields. A covered query (all fields are in the index) never fetches the document — the fastest possible query. Avoid indexes you don't use; each one adds write overhead.

mongodb
# ESR rule: Equality, Sort, Range for compound indexes
# equality fields first, then sort, then range
db.orders.createIndex({
  status: 1,        # Equality  (filter)
  date: 1,          # Sort      (order)
  amount: 1         # Range     (comparison)
})

# avoid over-indexing: each index slows writes
db.orders.getIndexes().length

# covered query: index satisfies query without fetching doc
db.users.createIndex({ name: 1, email: 1 })
db.users.find({ name: "A" }, { _id: 0, name: 1, email: 1 })

# background index build (deprecated in 4.2+, builds don't block)
db.users.createIndex({ name: 1 })  # non-blocking by default
05

Aggregation Framework

Aggregation Pipeline Basics

The aggregation pipeline processes documents through stages in sequence. $match early in the pipeline reduces the working set (and can use indexes). $group is like SQL GROUP BY. $count is a shortcut for $group + $project. Order matters: filter first, then group, then sort, then limit.

mongodb
# a pipeline is an array of stages
db.orders.aggregate([
  { $match: { status: "shipped" } },
  { $group: { _id: "$customerId", total: { $sum: "$amount" } } },
  { $sort: { total: -1 } },
  { $limit: 10 }
])

# each stage transforms documents and passes them
# to the next stage

# count documents
db.orders.aggregate([
  { $match: { status: "shipped" } },
  { $count: "shippedCount" }
])

$match, $project & $limit

Always put $match as early as possible — it reduces documents for all subsequent stages and can use indexes. $project reshapes documents and computes new fields using expressions. Renaming or computing fields in $project is common before $group. $unset is shorthand for excluding fields.

mongodb
# $match filters documents (like find)
{ $match: { status: "active", age: { $gte: 18 } } }

# $project reshapes documents (1 include, 0 exclude)
{ $project: { name: 1, _id: 0, "address.city": 1 } }

# $project with computed fields
{ $project: {
  fullName: { $concat: ["$first", " ", "$last"] },
  ageNextYear: { $add: ["$age", 1] }
}}

# $limit and $skip
{ $limit: 10 }
{ $skip: 20 }

# $unset removes fields (inverse of $project)
{ $unset: "tempField" }

$group & Accumulators

$group is the core aggregation stage. _id specifies the grouping key (use null to aggregate all documents). $push builds an array of values per group; $addToSet deduplicates. $first/$last refer to document order in the group — use $sort before $group if order matters. Memory limit is 100MB per stage (use allowDiskUse for large datasets).

mongodb
# $group: group by a field and aggregate
{ $group: {
  _id: "$category",
  count: { $sum: 1 },
  totalRevenue: { $sum: "$amount" },
  avgPrice: { $avg: "$price" },
  minPrice: { $min: "$price" },
  maxPrice: { $max: "$price" },
  products: { $push: "$name" },     # array of values
  firstOrder: { $first: "$date" },
  lastOrder: { $last: "$date" }
}}

# group by multiple fields
{ $group: { _id: { category: "$cat", status: "$status" } } }

# group ALL documents together (_id: null)
{ $group: { _id: null, total: { $sum: "$amount" } } }

$lookup (Joins)

$lookup performs a left outer join — like SQL LEFT JOIN. The joined data is placed in an array field named by 'as'. The pipeline form lets you filter and transform joined documents. $unwind deconstructs array fields (one output document per array element) — often used after $lookup to flatten results. Joins are less efficient than embedded data in MongoDB.

mongodb
# $lookup: join another collection (left outer join)
{ $lookup: {
  from: "orders",
  localField: "_id",
  foreignField: "customerId",
  as: "orders"
}}

# with pipeline (filtered/transformed join)
{ $lookup: {
  from: "orders",
  let: { custId: "$_id" },
  pipeline: [
    { $match: { $expr: { $eq: ["$customerId", "$$custId"] } } },
    { $project: { amount: 1, _id: 0 } }
  ],
  as: "recentOrders"
}}

# $unwind: flatten an array field into multiple docs
{ $unwind: "$orders" }

$unwind, $sort & $facet

$facet runs multiple sub-pipelines on the same input — perfect for dashboards needing several aggregations (top N, counts, breakdowns) in one query. $bucket auto-categorizes values into ranges. $unwind with preserveNullAndEmptyArrays keeps documents whose array is empty or missing — useful to avoid data loss in pipelines.

mongodb
# $unwind with preserveNullAndEmptyArrays
{ $unwind: {
  path: "$tags",
  preserveNullAndEmptyArrays: true
}}

# $sort within pipeline
{ $sort: { total: -1 } }

# $facet: run multiple pipelines in parallel
{ $facet: {
  "topCustomers": [{ $sort: {total:-1} }, { $limit: 5 }],
  "byCategory":   [{ $group: { _id: "$cat" } }],
  "totalCount":   [{ $count: "n" }]
}}

# $bucket: group into ranges automatically
{ $bucket: {
  groupBy: "$price",
  boundaries: [0, 50, 100, 500],
  default: "other",
  output: { count: { $sum: 1 } }
}}
07

Transactions

ACID Transactions

Multi-document transactions provide ACID guarantees across collections (requires a replica set or sharded cluster). Pass the session to every operation in the transaction. commitTransaction makes changes permanent and visible; abortTransaction rolls back all changes. Keep transactions short — they hold locks and can affect performance.

mongodb
# multi-document transaction (replica set required)
const session = db.getMongo().startSession()
session.startTransaction()

try {
  db.accounts.updateOne(
    { name: "Alice" }, { $inc: { balance: -100 } }, { session }
  )
  db.accounts.updateOne(
    { name: "Bob" }, { $inc: { balance: 100 } }, { session }
  )
  session.commitTransaction()
} catch (e) {
  session.abortTransaction()
  print("Transaction aborted: " + e)
} finally {
  session.endSession()
}

Transaction Read/Write Concern

Read concern controls how 'fresh' and consistent reads are. 'snapshot' gives a transactionally-consistent view across shards — ideal for reporting. Write concern controls durability: w:majority ensures data is replicated before ack (survives a primary failover); j:true waits for the journal flush. Higher concerns are safer but slower.

mongodb
# set transaction-level read/write concern
session.startTransaction({
  readConcern: { level: "snapshot" },
  writeConcern: { w: "majority", j: true, wtimeout: 5000 }
})

# read concern levels:
#   "local"      - sees latest local data (default)
#   "majority"   - committed, replicated to majority
#   "snapshot"   - consistent snapshot across shards

# write concern levels:
#   { w: 1 }             - ack from primary (default)
#   { w: "majority" }    - ack from majority of members
#   { j: true }          - wait for journal write

Retry Logic & Errors

MongoDB marks retryable errors with errorLabels: TransientTransactionError (retry the whole transaction) and UnknownCommitResult (retry commit only). Production drivers offer built-in retry. Always design transactions to be idempotent where possible. Avoid long-running transactions that hold locks and block other operations.

mongodb
# transactions can fail with TransientTransactionError
# retry the whole transaction on these errors

function runTransaction(txnFunc) {
  while (true) {
    const session = db.getMongo().startSession()
    try {
      session.startTransaction()
      txnFunc(session)
      session.commitTransaction()
      return
    } catch (e) {
      session.abortTransaction()
      if (e.errorLabels &&
          e.errorLabels.includes("TransientTransactionError")) {
        continue  // retry
      }
      throw e   // non-retryable error
    } finally {
      session.endSession()
    }
  }
}

Atomic Single-Document Ops

Single-document operations are atomic by default — no transaction needed. findOneAndUpdate/findOneAndReplace/findOneAndDelete return the document atomically (great for generating sequential IDs). For many workloads, embedding related data in one document and using atomic operators ($inc, $push) avoids the need for multi-document transactions entirely.

mongodb
# single-document operations are ALWAYS atomic
db.counters.updateOne(
  { _id: "pageViews" },
  { $inc: { count: 1 } }
)

# findAndModify: atomically find & update, return the doc
db.sequences.findOneAndUpdate(
  { _id: "orderId" },
  { $inc: { seq: 1 } },
  { returnNewDocument: true }
)

# atomic upsert (create-or-increment)
db.counters.updateOne(
  { _id: "visits", date: "2025-01-01" },
  { $inc: { count: 1 } },
  { upsert: true }
)

# use $expr for conditional atomic updates
db.products.updateMany(
  { $expr: { $lt: ["$stock", "$reorderLevel"] } },
  { $set: { needsRestock: true } }
)

Two-Phase Commit (Pattern)

The two-phase commit pattern simulates distributed transactions without native multi-doc transactions. It tracks a 'pending' state so a recovery process can resume or roll back if a crash occurs mid-transfer. Use native transactions instead when available — this pattern is complex and error-prone, kept mainly for educational and legacy purposes.

mongodb
# pattern for atomic transfers without transactions
# (pre-4.0 compatibility, or cross-system transfers)

# 1. create a pending transaction document
db.transactions.insertOne({
  _id: "t1", from: "Alice", to: "Bob", amount: 100, state: "pending"
})

# 2. debit source
db.accounts.updateOne(
  { name: "Alice", pendingTransactions: { $ne: "t1" } },
  { $inc: { balance: -100 }, $push: { pendingTransactions: "t1" } }
)

# 3. credit destination
db.accounts.updateOne(
  { name: "Bob", pendingTransactions: { $ne: "t1" } },
  { $inc: { balance: 100 }, $push: { pendingTransactions: "t1" } }
)

# 4. mark transaction done
db.transactions.updateOne({ _id: "t1" }, { $set: { state: "done" } })

# 5. clean up pending references
db.accounts.updateMany({},
  { $pull: { pendingTransactions: "t1" } })
08

Data Modeling

Embedding vs Referencing

The cardinal decision in MongoDB modeling: embed for data accessed together and rarely changing; reference for large, shared, or independently-updated data. Embed when the 'has-a' relationship is exclusive (a user's addresses). Reference when data is reused (a product in many orders) or grows unbounded (a user's millions of log entries).

mongodb
# EMBED: store related data in one document
db.users.insertOne({
  name: "Alice",
  addresses: [
    { city: "NYC", zip: "10001" },
    { city: "LA", zip: "90001" }
  ]
})
# Pros: single read, atomic update
# Cons: document size limit (16MB), data duplication

# REFERENCE: store IDs, join with $lookup
db.users.insertOne({ name: "Alice" })
db.orders.insertOne({
  customerId: ObjectId("..."),
  amount: 100
})
# Pros: no duplication, independent updates
# Cons: needs joins, more queries

One-to-Many Relationships

For 'few' relationships, embed. For 'many', reference from the child (parent ID on each child). For 'very many' (millions), use the bucket pattern: group child documents into parent-owned buckets (e.g., by month) to balance document size and query speed. This avoids both unbounded arrays and excessive child docs.

mongodb
# FEW: embed (e.g., a few addresses per user)
db.users.insertOne({
  name: "Alice",
  addresses: [{ city: "NYC" }, { city: "LA" }]
})

# MANY: reference with parent pointer
db.posts.insertOne({ title: "Hello", authorId: ObjectId("...") })

# VERY MANY: reference from parent (bucket pattern)
db.users.updateOne(
  { _id: userId },
  { $push: { postIds: postId } }   # beware 16MB limit
)

# BUCKET pattern for time series (thousands of items)
db.buckets.insertOne({
  ownerId: userId,
  date: "2025-01",
  measurements: [ {t: 1, v: 30}, {t: 2, v: 31} ]
})

Many-to-Many Relationships

For many-to-many, pick the approach by query patterns and data size. Two-sided arrays are fast but require dual maintenance. A junction collection (mapping table) is the most flexible and scales best — it can store relationship metadata (enrollment date, grade) and avoids bloating the parent documents.

mongodb
# option 1: arrays of ObjectIds on both sides
db.students.insertOne({ name: "Alice", courseIds: [c1, c2] })
db.courses.insertOne({ name: "Math", studentIds: [s1, s2] })

# option 2: one side references (smaller side)
db.students.insertOne({ name: "Alice", courseIds: [c1, c2] })

# option 3: mapping/junction collection
db.enrollments.insertOne({
  studentId: s1,
  courseId: c1,
  enrolledAt: ISODate(),
  grade: "A"
})

# query: find a student's courses
db.enrollments.find({ studentId: s1 })

Schema Design Patterns

The Attribute Pattern handles entities with varying attributes (products with different specs) — avoids sparse fields and enables dynamic attributes. The Polymorphic Pattern stores related-but-different shapes in one collection using a type discriminator. The Outlier Pattern handles exceptional documents (a celebrity with millions of followers) by referencing instead of embedding.

mongodb
# ATTRIBUTE PATTERN: flexible attributes (e.g., products)
db.products.insertOne({
  name: "Laptop",
  attributes: [
    { k: "cpu", v: "i7" },
    { k: "ram", v: 16 }
  ]
})
# query: db.products.find({ "attributes.k": "cpu", "attributes.v": "i7" })

# POLYMORPHIC PATTERN: one collection, type discriminator
db.events.insertMany([
  { type: "click", url: "/home", userId: 1 },
  { type: "purchase", orderId: 99, total: 50 }
])

# OUTLIER PATTERN: cap large lists
db.users.updateOne({n:"A"}, { $set: { reviews: "see reviews collection" } })

Schema Validation

Schema validation enforces document structure at write time — MongoDB's answer to fixed schemas. validationLevel 'strict' validates all writes; 'moderate' only validates inserts and full updates. validationAction 'error' rejects invalid docs; 'warn' logs but accepts them. Validation complements (not replaces) application-level validation.

mongodb
# add a JSON schema validator to a collection
db.createCollection("users", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["name", "email"],
      properties: {
        name: { bsonType: "string", maxLength: 100 },
        email: { bsonType: "string", pattern: "^\S+@\S+$" },
        age: { bsonType: "int", minimum: 0, maximum: 150 },
        role: { enum: ["admin", "user", "guest"] }
      }
    }
  },
  validationLevel: "strict",
  validationAction: "error"
})

# add validation to an existing collection
db.runCommand({ collMod: "users", validator: { ... } })

# check existing validation rules
db.getCollectionInfos({ name: "users" })
09

Replica Sets

Replica Set Basics

A replica set provides high availability and read scaling. Only one member is primary (accepts writes); others replicate asynchronously. If the primary fails, an election promotes a secondary automatically. An odd number of voting members (3 minimum) avoids election ties. rs.status() shows each member's health, state, and replication lag.

mongodb
# a replica set = 1 primary + N secondaries
# all writes go to the primary; secondaries replicate

# initiate a replica set
rs.initiate({
  _id: "rs0",
  members: [
    { _id: 0, host: "host1:27017" },
    { _id: 1, host: "host2:27017" },
    { _id: 2, host: "host3:27017" }
  ]
})

# check replica set status
rs.status()
rs.conf()

# step down the primary (force an election)
rs.stepDown(60)

# add a new member
rs.add("host4:27017")
rs.remove("host4:27017")

Read Preference

Read preference balances consistency vs load. primary guarantees you always read the latest writes. secondary/secondaryPreferred offload reads from the primary but data may be stale (replication lag, typically milliseconds). nearest minimizes latency — ideal for geographically distributed apps. Analytical/reporting workloads should use secondary reads.

mongodb
# control where reads are routed
# primary (default) - always read from primary (strongest consistency)
db.users.find().readPref("primary")

# primaryPreferred - primary if available, else secondary
db.users.find().readPref("primaryPreferred")

# secondary - only secondaries (may be stale)
db.users.find().readPref("secondary")

# secondaryPreferred - prefer secondaries to offload primary
db.users.find().readPref("secondaryPreferred")

# nearest - lowest latency member
db.users.find().readPref("nearest")

# set in connection string
mongodb://host1:27017/mydb?replicaSet=rs0&readPreference=secondary

Write Concern

Write concern trades speed for safety. w:1 acks as soon as the primary writes (fast but risks data loss if the primary crashes before replication). w:majority ensures the write is replicated — survives a single node failure (recommended for important data). j:true waits for an on-disk journal flush. Set a sensible default at the cluster level.

mongodb
# control how durable a write must be before ack
# w: 1 - primary acks (default, fastest, least safe)
db.users.insertOne(doc, { writeConcern: { w: 1 } })

# w: "majority" - majority of members confirmed
db.users.insertOne(doc, { writeConcern: { w: "majority" } })

# j: true - wait for journal flush (survives crash)
db.users.insertOne(doc, { writeConcern: { w: 1, j: true } })

# wtimeout - max wait time in ms
db.users.insertOne(doc, {
  writeConcern: { w: "majority", wtimeout: 5000 }
})

# set default write concern for the cluster
db.adminCommand({
  setDefaultRWConcern: { defaultWriteConcern: { w: "majority" } }
})

Arbiters & Election

Arbiters provide a vote to reach a majority without storing data — useful for two-node deployments, but a third data node is preferable. Priority 0 members never become primary (good for dedicated analytics or DR nodes). Hidden members are excluded from read preference 'nearest' and are ideal for backups. Delayed members lag behind, enabling recovery from accidental deletes.

mongodb
# an arbiter votes in elections but holds no data
rs.addArb("host5:27017")

# useful to break ties in even-member sets (e.g., 2 data + 1 arbiter)
# but prefer 3 data members for durability

# force a member to never become primary (priority 0)
rs.conf().members[1].priority = 0
rs.reconfig(rs.conf())

# hidden member: not visible to clients, used for analytics/backup
rs.conf().members[2].hidden = true
rs.conf().members[2].priority = 0
rs.reconfig(rs.conf())

# delayed member: lags behind (insurance against human errors)
rs.conf().members[3].secondaryDelaySecs = 3600
rs.conf().members[3].priority = 0

Oplog & Replication Internals

The oplog (operation log) is a capped collection recording every write — secondaries apply these entries in order to replicate. If a secondary falls behind beyond the oplog window, it needs a full resync. printReplicationInfo shows the oplog's time span; printSecondaryReplicationInfo shows each secondary's lag. Larger oplogs accommodate longer downtimes.

mongodb
# the oplog is a capped collection of all writes
use local
db.oplog.rs.stats()
db.oplog.rs.find().sort({ $natural: -1 }).limit(5)

# each oplog entry is an idempotent operation
# { ts: ..., op: "i", ns: "mydb.users", o: { ... } }

# check replication lag
rs.printReplicationInfo()   # primary's oplog window
rs.printSecondaryReplicationInfo()  # each secondary's lag

# resync a stale secondary (full re-sync)
rs.syncFrom("host2:27017")

# change the oplog size (MongoDB 4.4+)
db.adminCommand({ replSetResizeOplog: 1, size: 16384 })  # MB
10

Sharding

Sharding Basics

Sharding horizontally scales a collection across multiple shards. The shard key determines data distribution and is immutable after sharding. Hashed sharding distributes evenly (good for monotonically increasing keys like ObjectId); ranged sharding enables targeted queries but can cause hotspots. Choose the shard key carefully — it cannot be changed easily later.

mongodb
# sharding distributes data across multiple machines
# requires: config servers + shard replica sets + mongos router

# enable sharding on a database
sh.enableSharding("mydb")

# shard a collection (index on shard key required first)
sh.shardCollection("mydb.users", { userId: 1 })

# hash-based sharding (even distribution)
db.users.createIndex({ userId: "hashed" })
sh.shardCollection("mydb.users", { userId: "hashed" })

# view shard status
sh.status()
sh.addShard("rs1/host1:27017,host2:27017")

# balancer moves chunks between shards automatically
sh.startBalancer()
sh.stopBalancer()

Shard Key Selection

The shard key is the most critical sharding decision. It must support your common queries (queries without the shard key hit all shards — scatter-gather). High cardinality prevents unmovable jumbo chunks. Monotonic keys (timestamps, ObjectIds) cause all inserts to hit one shard — use hashed sharding for these. Compound keys should lead with the most selective field.

mongodb
# GOOD shard key properties:
# - high cardinality (many distinct values)
# - low frequency (no single value dominates)
# - non-monotonic (avoids hot shards) OR use hashed

# hashed shard key (even distribution)
sh.shardCollection("mydb.logs", { _id: "hashed" })

# ranged shard key (enables targeted queries)
sh.shardCollection("mydb.events", { region: 1, date: 1 })

# compound shard key
sh.shardCollection("mydb.orders", { customerId: 1, orderId: 1 })

# check chunk distribution
db.getSiblingDB("config").chunks.find({ ns: "mydb.users" })

# see data distribution per shard
db.users.getShardDistribution()

Zones & Tag-Aware Sharding

Zones (tag-aware sharding) route specific data ranges to specific shards — enabling data residency compliance (GDPR requires EU data in EU), geo-locality (fast regional reads), or hardware tiers (hot data on SSDs, cold on HDD). The balancer respects zone ranges when migrating chunks. Define shard tags first, then tag ranges.

mongodb
# tag shards by region (data locality / compliance)
sh.addShardTag("shard-us-east", "US")
sh.addShardTag("shard-eu", "EU")

# associate a key range with a tag
sh.addTagRange(
  "mydb.users",
  { region: "US", _id: MinKey },
  { region: "US", _id: MaxKey },
  "US"
)
sh.addTagRange(
  "mydb.users",
  { region: "EU", _id: MinKey },
  { region: "EU", _id: MaxKey },
  "EU"
)

# the balancer migrates tagged data to tagged shards
# useful for data residency (GDPR) and geo-locality

Chunk Management

Chunks are the unit of data movement between shards. The balancer automatically splits and migrates chunks to keep shards balanced. Jumbo chunks exceed the size limit and cannot migrate — avoid them with good shard key cardinality. Default 128MB chunk size balances migration speed and overhead. Manual chunk ops are for troubleshooting, not routine.

mongodb
# chunks are ranges of shard key values
# default chunk size: 128MB (configurable)

# view chunks for a collection
db.getSiblingDB("config").chunks.find({ ns: "mydb.users" })

# manually move a chunk (testing)
sh.moveChunk("mydb.users", { userId: 100 }, "shard2")

# split a chunk at a specific value
sh.splitAt("mydb.users", { userId: 1000 })

# merge contiguous chunks
sh.mergeChunks("mydb.users",
  { userId: MinKey },
  { userId: 1000 }
)

# find chunks too large to migrate (jumbo chunks)
sh.status(true)   # verbose

Sharded Cluster Components

A sharded cluster has three components: config servers (metadata, deployed as a replica set), mongos routers (stateless query routers clients connect to), and shards (each a replica set holding a subset of data). Clients never connect to shards directly. Removing a shard triggers data migration to remaining shards — a slow process that should be monitored.

mongodb
# config servers store metadata and routing info
# (deploy as a 3-member replica set)
# config server connection string:
# mongodb://cfg1:27019,cfg2:27019,cfg3:27019/config?replicaSet=cfgRS

# mongos is the query router (stateless, scale horizontally)
# clients connect to mongos, not shards directly
mongos --configdb cfgRS/cfg1:27019,cfg2:27019,cfg3:27019

# each shard is a replica set
sh.addShard("shardRS1/s1a:27017,s1b:27017,s1c:27017")
sh.addShard("shardRS2/s2a:27017,s2b:27017,s2c:27017")

# check cluster status
sh.status()

# remove a shard (migrates its data first)
db.adminCommand({ removeShard: "shardRS2" })
11

Security & Authentication

Authentication & Users

Authentication verifies identity. Create the admin user before enabling auth, or you'll be locked out. Built-in roles range from read-only to full admin. Use passwordPrompt() in the shell to avoid plaintext passwords in history. Each user is scoped to a database but can have roles on other databases. Enable TLS in production (net.tls.mode).

mongodb
# enable auth: start mongod with --auth
# or set security.authorization: "enabled" in config

# create the first admin user (before enabling auth)
use admin
db.createUser({
  user: "admin",
  pwd: passwordPrompt(),   // prompts securely
  roles: [ { role: "userAdminAnyDatabase", db: "admin" },
           { role: "readWriteAnyDatabase", db: "admin" } ]
})

# create a user for a specific database
use mydb
db.createUser({
  user: "appUser",
  pwd: passwordPrompt(),
  roles: [ { role: "readWrite", db: "mydb" } ]
})

# authenticate as a user
db.auth("appUser", "password")

Built-in Roles & Custom Roles

Built-in roles cover common cases; custom roles enable least-privilege. Privileges pair a resource (database/collection) with allowed actions (find, insert, update, etc.). Grant the minimum needed — a reporting user only needs 'find' on specific collections. Custom roles are stored in the admin database and can be reused across users.

mongodb
# built-in roles:
#   read, readWrite, dbAdmin, dbOwner, userAdmin  (per database)
#   readAnyDatabase, readWriteAnyDatabase,         (all databases)
#   userAdminAnyDatabase, dbAdminAnyDatabase
#   root (superuser), clusterAdmin, backup, restore

# create a custom role with fine-grained privileges
db.createRole({
  role: "analyst",
  privileges: [
    { resource: { db: "mydb", collection: "reports" },
      actions: ["find"] },
    { resource: { db: "mydb", collection: "" },
      actions: ["listCollections"] }
  ],
  roles: []
})

# grant a role to a user
db.grantRolesToUser("appUser", [{ role: "analyst", db: "mydb" }])
db.revokeRolesFromUser("appUser", [{ role: "analyst", db: "mydb" }])

TLS/SSL Encryption

TLS encrypts data in transit — mandatory for production. requireTLS rejects non-TLS connections. For internal PKI, use x.509 certificate authentication (no passwords — the cert IS the credential). The $external database stores x.509 users. Always use a real CA (or internal CA) in production; self-signed certs are for testing only.

mongodb
# enable TLS in mongod.conf
net:
  tls:
    mode: requireTLS
    certificateKeyFile: /etc/mongodb/server.pem
    CAFile: /etc/mongodb/ca.pem

# connect with TLS and verify the server cert
mongosh "mongodb://host:27017/mydb" \
  --tls --tlsCAFile /etc/mongodb/ca.pem

# connect with a client certificate (x.509 auth)
mongosh "mongodb://host:27017/mydb" \
  --tls --tlsCertificateKeyFile client.pem \
  --tlsCAFile ca.pem \
  --authenticationMechanism MONGODB-X509 \
  --authenticationDatabase '$external'

# generate a self-signed cert for testing
openssl req -x509 -newkey rsa:4096 -nodes \
  -keyout server.key -out server.crt -days 365

Network & Auditing

bindIp limits which network interfaces MongoDB listens on — never expose MongoDB directly to the internet (bindIp: 0.0.0.0 + no auth has caused countless breaches). Auditing (Enterprise only) logs security-relevant operations for compliance. The community edition can use OS-level tools (auditd) or app-level logging as alternatives.

mongodb
# restrict network interface in mongod.conf
net:
  bindIp: 127.0.0.1,10.0.0.5    # never 0.0.0.0 in production
  port: 27017

# enable auditing (enterprise feature)
auditLog:
  destination: file
  format: JSON
  path: /var/log/mongodb/audit.log
  filter: '{ atype: { $in: ["authenticate","createCollection","dropDatabase"] } }'

# view current authentication mechanisms
db.runCommand({ getCmdLineOpts: 1 })

# enable FIPS mode (government compliance)
setParameter:
  fipsMode: true

Field-Level Encryption (CSFLE)

CSFLE encrypts sensitive fields on the client so the server, backups, and logs never contain plaintext — strongest data protection. Deterministic encryption supports equality queries; random encryption is more secure but not queryable. Auto-encryption (via a driver schema) is transparent to app code. The encryption keys are managed in a KMS (AWS KMS, local key, etc.).

mongodb
# Client-Side Field Level Encryption encrypts fields
# before sending to the server — server never sees plaintext

# define a data key in the key vault
const clientEncryption = db.getMongo().getClientEncryption()
const keyId = clientEncryption.createDataKey(
  "local", { keyAltNames: ["mainKey"] }
)

# encrypt a field explicitly
const ssn = clientEncryption.encrypt(
  keyId, "123-45-6789", "deterministic"
)

# store encrypted, query with encrypted value
db.patients.insertOne({ name: "Alice", ssn: ssn })
db.patients.find({
  ssn: clientEncryption.encrypt(keyId, "123-45-6789", "deterministic")
})

# automatic CSFLE uses a schema to encrypt per a JSON schema
# { encryptMetadata: { keyId: [keyId], algorithm: "AEAD_AES_256_CBC..." } }
12

Backup & Restore

mongodump & mongorestore

mongodump creates a logical backup (BSON files) — portable but slow for large datasets and not point-in-time consistent across collections without --oplog. --drop drops existing collections before restore. --archive + --gzip produce a single compressed file. For large production backups, prefer filesystem snapshots or Percona Backup instead of mongodump.

mongodb
# dump an entire database
mongodump --db=mydb --out=/backup/$(date +%F)

# dump a single collection
mongodump --db=mydb --collection=users --out=/backup

# dump with a query filter
mongodump --db=mydb --collection=logs \
  --query='{"date":{"$gte":{"$date":"2025-01-01T00:00:00Z"}}}'

# compress the output archive
mongodump --archive=/backup/full.gz --gzip

# restore from a dump
mongorestore --db=mydb --drop /backup/2025-01-01/mydb
mongorestore --archive=/backup/full.gz --gzip

mongoexport & mongoimport

mongoexport/mongoimport work with JSON/CSV — for data exchange with other systems, not for backups (they lose BSON types like ObjectId and Date precision). For backups use mongodump. CSV exports are great for spreadsheets and BI tools. Always specify --fields for predictable CSV column order. JSON exports are limited to one document per line by default (--jsonArray for a single array).

mongodb
# export to JSON (human-readable)
mongoexport --db=mydb --collection=users \
  --out=users.json

# export to CSV
mongoexport --db=mydb --collection=users \
  --type=csv --fields=name,email,age --out=users.csv

# export with a query filter
mongoexport --db=mydb --collection=users \
  --query='{"status":"active"}' --out=active.json

# import from JSON
mongoimport --db=mydb --collection=users --file=users.json

# import from CSV with header row
mongoimport --db=mydb --collection=users \
  --type=csv --headerline --file=users.csv

Filesystem Snapshots

Filesystem snapshots are the fastest backup method for large datasets — they capture the entire data directory consistently. fsyncLock briefly blocks writes and flushes to disk so the snapshot is consistent. Snapshots are near-instant regardless of data size. Restore is as simple as copying files back — much faster than mongorestore. Works best on LVM, ZFS, or cloud block storage (EBS snapshots).

mongodb
# snapshot the dbPath volume for a consistent backup
# 1. flush writes to disk and lock (brief)
db.fsyncLock()

# 2. take a filesystem snapshot (LVM, ZFS, EBS, etc.)
lvcreate --snapshot --size 1G --name mongosnap /dev/vg/data

# 3. unlock the database
db.fsyncUnlock()

# 4. mount the snapshot and copy or back it up
mount /dev/vg/mongosnap /mnt/snap
rsync -a /mnt/snap/ /backup/

# restore: stop mongod, replace dbPath, start mongod
# (fastest restore method for large datasets)

Point-in-Time Recovery (oplog)

Point-in-time recovery (PITR) reconstructs the database state at a specific moment — essential after accidental deletes or bad migrations. Take regular base backups with --oplog, then replay the oplog up to the desired timestamp (--oplogLimit stops just before a bad operation). PITR requires a replica set (the oplog only exists on replica set members).

mongodb
# 1. take a base backup (snapshot or mongodump --oplog)
mongodump --archive=/backup/base.gz --oplog --gzip

# 2. note the time of the accident
# 3. replay the oplog up to just before the error

# dump the oplog from a secondary
mongodump --db=local --collection=oplog.rs \
  --query='{"ts":{"$gt":{"$timestamp":{"t":1700000000,"i":1}}}}' \
  --out=/oplog_dump

# restore the base backup, then replay oplog
mongorestore --archive=/backup/base.gz --oplogReplay \
  --oplogLimit=1700000123:1   # stop before this timestamp

# for a running replica set, use mongorestore --oplogReplay
# with a BSON oplog file

Atlas Backup & Cloud Options

MongoDB Atlas offers managed continuous backups with point-in-time recovery — simplest for cloud deployments. Percona Backup for MongoDB (PBM) is the leading open-source backup tool for self-hosted clusters, supporting physical backups and PITR across replica sets and sharded clusters. Always test restore procedures — a backup you can't restore is not a backup.

mongodb
# MongoDB Atlas: continuous backups with PITR
# configured via UI or Atlas CLI:
atlas backups policies create [clusterId] ...

# on-demand snapshot
atlas backups snapshots create [clusterId] \
  --description "pre-migration" \
  --retentionDays 7

# restore an Atlas snapshot
atlas backups restores start [clusterId] \
  --snapshotId [snapId] \
  --deliveryType automated \
  --targetClusterName [target]

# Percona Backup for MongoDB (PBM) — open source
pbm backup --type=physical
pbm restore [snapshotName]
pbm pitr restore --time "2025-01-15T14:30:00"
13

Performance & Optimization

Profiling Slow Queries

The database profiler logs operations slower than slowms (default 100ms) into system.profile (a capped collection). Level 2 logs everything — useful for debugging but impacts performance. Look at millis (duration), nreturned (docs returned), and docsExamined to find inefficient queries. Disable profiling when done.

mongodb
# enable the database profiler
# levels: 0=off, 1=slow ops only, 2=all ops
db.setProfilingLevel(1, { slowms: 100 })

# view profiled operations
db.system.profile.find().sort({ ts: -1 }).limit(5)

# find the slowest operations
db.system.profile.find({ millis: { $gt: 1000 } })
  .sort({ millis: -1 })

# profile a specific collection
db.system.profile.find({ ns: "mydb.users" })

# get current profiling level
db.getProfilingStatus()

# clear the profile collection
db.system.profile.drop()

explain() Deep Dive

explain() is your primary tuning tool. COLLSCAN means a full collection scan — add an index. The ratio of docsExamined to nReturned is the key efficiency metric: if you examine 10000 docs to return 10, your index is poor. allPlansExecution shows why the planner chose its plan. Index your query's equality, sort, and range fields (ESR rule).

mongodb
# three verbosity modes
db.users.find({age:30}).explain("queryPlanner")     # plan only
db.users.find({age:30}).explain("executionStats")    # + timing
db.users.find({age:30}).explain("allPlansExecution") # + rejected plans

# key metrics to check:
#   winningPlan.stage: COLLSCAN (bad) vs IXSCAN (good)
#   totalKeysExamined: index entries scanned
#   totalDocsExamined: documents fetched
#   nReturned: documents returned
#   executionTimeMillis: total time
#   works: number of work units

# ideal ratio: docsExamined ≈ nReturned
# if docsExamined >> nReturned -> missing or bad index

Working Set & Memory

Performance depends on the working set (data accessed frequently) fitting in RAM. If wiredTiger.cache 'pages read into cache' is high, you're hitting disk — add RAM or add indexes to reduce scans. mongotop shows which collections are I/O hot; mongostat shows query/insert/update rates and faults. compact reclaims wasted disk space from deleted/updated documents.

mongodb
# check the working set size
db.serverStatus().wiredTiger.cache
# look at: "maximum bytes configured", "current bytes"
#          "pages requested from disk" vs "pages read into cache"

# check storage stats
db.stats()
db.users.stats()
db.users.totalSize()   # data + index size in bytes

# mongotop: time spent reading/writing per collection
mongotop 30   # every 30 seconds

# mongostat: live server stats
mongostat --rowcount 10  # 10 iterations

# compact a collection (reclaims disk, blocks writes pre-4.4)
db.runCommand({ compact: "users" })

Connection Pooling

Each MongoDB connection consumes server memory — thousands of connections degrade performance. Use connection pooling (drivers maintain a reusable pool) with maxPoolSize tuned to your load (50-100 is typical). Share a single MongoClient across your app — never create one per request. Set maxIdleTimeMS to recycle stale connections behind load balancers.

mongodb
# each connection consumes memory (~1MB) on the server
# limit connections in the driver's connection string
mongodb://host:27017/mydb?maxPoolSize=50

# Node.js driver example
const { MongoClient } = require("mongodb")
const client = new MongoClient(uri, {
  maxPoolSize: 50,        // max connections
  minPoolSize: 5,         // keep-alive connections
  maxIdleTimeMS: 30000,   // close idle conns after 30s
  serverSelectionTimeoutMS: 5000
})

# view current connections on the server
db.serverStatus().connections
# { current: 120, available: 880, totalCreated: 200 }

Bulk Writes & Batch Sizes

Bulk writes group operations into a single round-trip — far faster than individual ops. unordered:true maximizes throughput (continues past errors, parallelizes on sharded clusters). For large imports, batch in groups of 1000-5000 documents to balance memory and round-trips. insertMany with ordered:false is the fastest way to load data.

mongodb
# ordered bulk write (stops on first error)
db.users.bulkWrite([
  { insertOne: { document: { name: "A" } } },
  { updateOne: { filter: {n:"B"}, update: {$set:{age:1}} } },
  { deleteOne: { filter: { n: "C" } } }
], { ordered: true })

# unordered bulk write (continues on errors, faster)
db.users.bulkWrite(ops, { ordered: false })

# insertMany is a bulk insert — batch large loads
const docs = Array.from({length: 100000}, (_,i) => ({ i }))
const batch = 1000
for (let i = 0; i < docs.length; i += batch) {
  db.big.insertMany(docs.slice(i, i + batch), { ordered: false })
}
14

Change Streams

Watching Changes

Change streams let applications react to data changes in real time by tailing the oplog. Each event includes the operation type, the full document (for inserts/updates with fullDocument option), and a resume token. Store the resume token to resume after a restart without missing or duplicating events. Requires a replica set or sharded cluster.

mongodb
# open a change stream on a collection
const stream = db.users.watch()

# process change events (Node.js driver)
stream.on("change", (event) => {
  console.log(event.operationType)  // insert|update|delete|replace
  console.log(event.fullDocument)   // the new document
  console.log(event.documentKey)    // the _id
  console.log(event.updateDescription)  // updated fields
})

# resume token enables restart from last position
const token = stream.resumeToken
// store token, then resume later:
const stream2 = db.users.watch([], { resumeAfter: token })

# close the stream
stream.close()

Filtering Change Events

Filter change streams with an aggregation pipeline — only matching events reach your app, reducing noise. By default, updates only include the changed fields, not the full document; fullDocument:'updateLookup' fetches the current document (extra query per event). fullDocumentBeforeChange (6.0+) includes the pre-change document — useful for audit logs.

mongodb
# filter with an aggregation pipeline
const stream = db.users.watch([
  { $match: { operationType: "update" } },
  { $match: { "updateDescription.updatedFields.status": "active" } },
  { $project: { fullDocument: 1, documentKey: 1 } }
])

# watch specific operations
{ $match: { operationType: { $in: ["insert", "update"] } } }

# full document lookup on update (fetches current doc)
db.users.watch([], {
  fullDocument: "updateLookup"   // include latest doc on updates
})

# fullDocumentBeforeChange (MongoDB 6.0+)
db.users.watch([], {
  fullDocumentBeforeChange: "whenAvailable"
})

Change Stream on Database/Cluster

Change streams can watch a single collection, an entire database, or the whole cluster. Database and cluster streams include the namespace (ns) in each event so you know where the change occurred. Use startAtOperationTime to replay changes from a specific point in the oplog — useful for backfilling or recovering missed events after a downtime.

mongodb
# watch all collections in a database
const stream = db.watch()

# watch all databases in a cluster
const stream = client.watch()

# events include the ns (namespace) field
stream.on("change", (e) => {
  console.log(e.ns.db + "." + e.ns.coll)
  console.log(e.operationType)
})

# useful for audit logging, cache invalidation,
# cross-system sync, and real-time analytics

# start at a specific timestamp
db.users.watch([], {
  startAtOperationTime: Timestamp(1700000000, 1)
})

Resume Tokens & Reliability

Resume tokens guarantee at-least-once delivery — persist them after processing each event so a restart resumes exactly where you left off. resumeAfter and startAfter are similar; startAfter works even after collection invalidation events (like a drop). Always handle errors and reconnect with the last token. The token is opaque — treat it as a black box.

mongodb
# each event has a resume token (_id)
stream.on("change", (e) => {
  saveToStore(e._id)        // persist the token
  processEvent(e)
})

# after a restart, resume from the last token
const lastToken = loadFromStore()
const stream = db.users.watch([], {
  resumeAfter: lastToken     // resume after this event
})

# alternative: start after a cluster time
db.users.watch([], { startAfter: lastToken })

# handle errors and reconnect
stream.on("error", (err) => {
  console.error("stream error", err)
  // reconnect with the last saved token
})

# the token is opaque — store it, don't parse it

Change Stream Use Cases

Change streams power real-time architectures: cache invalidation (delete stale cache entries), live notifications (WebSocket pushes), audit logging (record every change for compliance), and ETL/sync (replicate to Elasticsearch, a data warehouse, or another system). They replace manual polling and triggers with a clean, reactive event-driven model.

mongodb
# 1. Cache invalidation: clear cache when data changes
stream.on("change", (e) => {
  redis.del("user:" + e.documentKey._id)
})

# 2. Real-time notifications: push to WebSocket clients
stream.on("change", (e) => {
  io.emit("user-updated", e.fullDocument)
})

# 3. Audit log: record all changes to a separate store
stream.on("change", (e) => {
  auditLog.insertOne({
    op: e.operationType,
    doc: e.fullDocument,
    at: new Date()
  })
})

# 4. ETL: sync to a search index or warehouse
stream.on("change", async (e) => {
  await elasticsearch.index(e.fullDocument)
})
15

Administration & Tools

Server Status & Monitoring

serverStatus() is the primary monitoring source — track connections, opcounters, cache hit ratio, and replication lag. currentOp shows running operations; killOp terminates a long-running query by its opid. Set a monitoring agent (Prometheus, Datadog, Atlas) to collect these metrics continuously. Key alerts: connection count nearing limit, cache hit ratio dropping, replication lag spiking.

mongodb
# overall server status
db.serverStatus()

# key metrics to monitor:
db.serverStatus().connections       # active/idle connections
db.serverStatus().opcounters         # queries/inserts/updates/sec
db.serverStatus().wiredTiger.cache   # cache hit ratio (aim >95%)
db.serverStatus().metrics.document   # docs inserted/returned/deleted
db.serverStatus().repl               # replication state

# current operations (find long-running ops)
db.currentOp({ secs_running: { $gte: 5 } })

# kill a long-running operation
db.killOp(opid)

# host info (CPU, OS, memory)
db.hostInfo()

Database & Collection Stats

stats() shows storage details: dataSize (logical), storageSize (physical, after WiredTiger compression — often 50-80% smaller), and indexSize. estimatedDocumentCount is instant (from collection metadata) but approximate; countDocuments({}) is accurate but scans. Use estimated counts for dashboards and exact counts for billing. Monitor storage growth trends to plan capacity.

mongodb
# database-level stats
db.stats()
db.stats(1024*1024)   # in MB

# collection-level stats
db.users.stats()
db.users.dataSize()       # uncompressed data size
db.users.storageSize()    # on-disk size (with compression)
db.users.totalIndexSize() # total index size
db.users.totalSize()      # data + indexes

# count documents (accurate, may be slow)
db.users.countDocuments({})

# estimated count (fast, from metadata)
db.users.estimatedDocumentCount()

# list collections with sizes
db.runCommand({ listCollections: 1 })

Compact & Repair

WiredTiger reuses freed disk space, so compact is rarely needed — only after deleting a large fraction of a collection. compact blocks operations on the collection (pre-4.4) or runs online (4.4+). repairDatabase is a destructive last resort that rewrites all data — always back up first. For space reclamation on a replica set, resync a secondary (delete its data and let it re-replicate).

mongodb
# compact: reclaim disk space from deleted/updated docs
# (WiredTiger reuses freed space, so rarely needed)
db.runCommand({ compact: "users" })

# fullDatabase compact (compact every collection)
db.getCollectionNames().forEach(c => {
  db.runCommand({ compact: c })
})

# repairDatabase (legacy, rewrites all data — last resort)
# Stops the server! Back up first.
mongod --repair --dbpath /data/db

# check data integrity (validates BSON)
db.users.validate({ full: true })

# reclaim space by re-syncing a secondary instead
# (safer than repair on a running primary)

mongotop & mongostat

mongostat gives a real-time overview of server load (inserts, queries, cache usage, connections) — like top/vmstat for MongoDB. mongotop shows which collections consume the most I/O time — invaluable for finding hot collections. Both are bundled with MongoDB. For production monitoring, ship the mongod log and serverStatus metrics to a monitoring system for historical analysis.

mongodb
# mongostat: live server stats (like vmstat for MongoDB)
mongostat
mongostat --host=host:27017 --rowcount 10
# columns: insert/query/update/delete/getmore/command
#          dirty (cache %), used (cache %), conn, time

# mongotop: time spent per collection (read/write)
mongotop
mongotop 30          # every 30 seconds
mongotop --host=host:27017

# columns: ns (namespace), total/read/write time (ms)
# helps identify which collections are I/O hot

# log slow queries in the mongod log
# set in mongod.conf:
# operationProfiling:
#   mode: slowOp
#   slowOpThresholdMs: 100

Configuration & Startup

Production MongoDB runs via a config file (mongod.conf in YAML). Key settings: cacheSizeGB limits WiredTiger's RAM (default uses 50% of RAM — set explicitly on shared servers), bindIp restricts network access, authorization enables auth, and replSetName/clusterRole enable replication/sharding. Always review and version-control your mongod.conf. serverCmdLineOpts shows the active configuration.

mongodb
# start mongod with a config file
mongod --config /etc/mongod.conf

# typical mongod.conf (YAML)
storage:
  dbPath: /var/lib/mongodb
  wiredTiger:
    engineConfig:
      cacheSizeGB: 4        # limit RAM usage
  journal:
    enabled: true
net:
  port: 27017
  bindIp: 127.0.0.1
security:
  authorization: enabled
replication:
  replSetName: rs0
sharding:
  clusterRole: shardsvr

# view current configuration
db.serverCmdLineOpts()
db.runCommand({ getCmdLineOpts: 1 })
16

Drivers & Integration

Node.js Driver

The Node.js driver is async/await-based with full ES6+ support. Always use connection pooling (one MongoClient per app, reused). Cursors are async iterables. Use for-await-of to iterate without loading all documents into memory. Close the client on shutdown. The driver supports transactions, change streams, and all aggregation features.

mongodb
const { MongoClient } = require("mongodb")

const client = new MongoClient(
  "mongodb://localhost:27017",
  { maxPoolSize: 50 }
)

async function main() {
  await client.connect()
  const db = client.db("mydb")
  const users = db.collection("users")

  // insert
  await users.insertOne({ name: "Alice", age: 30 })

  // find with a cursor
  const cursor = users.find({ age: { $gte: 18 } })
  for await (const doc of cursor) {
    console.log(doc.name)
  }

  // update
  await users.updateOne(
    { name: "Alice" },
    { $inc: { age: 1 } }
  )
}

main().finally(() => client.close())

Python Driver (PyMongo)

PyMongo is the official Python driver. It's synchronous; for async use Motor (asyncio) or Beanie (ODM). PyMongo dictionaries are plain Python dicts — no special objects needed. Aggregation pipelines are lists of dicts. The context-manager transaction pattern ensures automatic commit/abort. Always use connection pooling (the default MongoClient pools connections).

mongodb
from pymongo import MongoClient

client = MongoClient("mongodb://localhost:27017")
db = client["mydb"]
users = db["users"]

# insert
users.insert_one({"name": "Alice", "age": 30})

# find
for doc in users.find({"age": {"$gte": 18}}).sort("age", -1):
    print(doc["name"])

# update
users.update_one(
    {"name": "Alice"},
    {"$inc": {"age": 1}}
)

# aggregation
pipeline = [
    {"$group": {"_id": "$city", "count": {"$sum": 1}}},
    {"$sort": {"count": -1}}
]
for doc in users.aggregate(pipeline):
    print(doc)

# use a context manager for transactions
with client.start_session() as session:
    with session.start_transaction():
        users.update_one({"n":"A"}, {"$inc":{"bal":-100}}, session=session)
        users.update_one({"n":"B"}, {"$inc":{"bal":100}}, session=session)

Indexes from Drivers

Create indexes from your application's setup/migration scripts, not on every request. createIndexes (plural) creates multiple indexes in one call. In production, index builds on large collections can be slow — MongoDB 4.2+ builds indexes non-blocking by default. Version your index creation scripts alongside schema migrations.

mongodb
// Node.js: create indexes
await db.collection("users").createIndex({ email: 1 }, { unique: true })
await db.collection("users").createIndex({ name: "text" })

// Python
users.create_index([("email", 1)], unique=True)
users.create_index([("name", "text")])

// list indexes
const indexes = await db.collection("users").indexes()

// drop an index
await db.collection("users").dropIndex("email_1")

// create indexes in bulk
await db.collection("orders").createIndexes([
  { key: { customerId: 1 } },
  { key: { status: 1, date: -1 }, name: "status_date" }
])

Connection String Options

The connection string carries all client config as query parameters. mongodb+srv uses DNS SRV records to discover hosts — Atlas uses this format; it simplifies connection strings and auto-updates when nodes change. retryWrites=true (default in modern drivers) auto-retries idempotent writes after network blips. Set reasonable timeouts to avoid hung connections.

mongodb
# standard connection string
mongodb://user:pass@host1:27017,host2:27017/mydb?replicaSet=rs0

# common options (as query params):
#   replicaSet=rs0            - replica set name
#   readPreference=secondary  - route reads to secondaries
#   readConcernLevel=majority - read committed data
#   w=majority                - write concern
#   journal=true              - wait for journal flush
#   maxPoolSize=50            - max connections
#   connectTimeoutMS=5000     - connection timeout
#   socketTimeoutMS=30000     - socket timeout
#   authSource=admin          - db for credentials
#   tls=true                  - enable TLS
#   retryWrites=true          - retry on network errors

# SRV connection string (Atlas, uses DNS)
mongodb+srv://user:[email protected]/mydb

ODMs & Higher-Level Libraries

ODMs (Object Document Mappers) add schemas, validation, and a higher-level API. Mongoose (Node.js) is the most popular — it enforces structure that MongoDB lacks natively. Beanie is the leading async Python ODM. ODMs are great for rapid development and team consistency, but they add overhead and can hide MongoDB's power. For performance-critical apps, use the raw driver.

mongodb
// Mongoose (Node.js ODM with schemas)
const mongoose = require("mongoose")
const userSchema = new mongoose.Schema({
  name: { type: String, required: true },
  age: { type: Number, min: 0 }
})
const User = mongoose.model("User", userSchema)
await User.create({ name: "Alice", age: 30 })
await User.find({ age: { $gte: 18 } })

# Beanie (Python async ODM)
from beanie import Document
class User(Document):
    name: str
    age: int
await User(name="Alice", age=30).insert()
await User.find(User.age >= 18).to_list()

# Mongose vs raw driver:
# ODMs add schemas, validation, and middleware
# but add overhead and a learning curve
# use the raw driver for max performance/control
17

GridFS & Large Files

GridFS Basics

GridFS stores files larger than the 16MB BSON document limit by splitting them into chunks (default 255KB) stored in fs.chunks, with metadata in fs.files. It's effectively a filesystem on top of MongoDB. Use it for files that exceed 16MB but still benefit from MongoDB's replication and sharding. For truly large media, consider S3 or a CDN instead.

mongodb
# GridFS stores files larger than 16MB (the BSON doc limit)
# by splitting them into chunks

# store a file via mongofiles (CLI)
mongofiles --db=mydb put large_video.mp4
mongofiles --db=mydb list
mongofiles --db=myfs get large_video.mp4

# two collections are used:
#   fs.files  - file metadata (one doc per file)
#   fs.chunks - file data in 255KB chunks

# query file metadata
db.fs.files.find().pretty()
db.fs.files.findOne({ filename: "large_video.mp4" })

# query chunks for a file
db.fs.chunks.find({ files_id: fileId }).sort({ n: 1 })

GridFS from Drivers

Drivers expose GridFS as a streaming API (GridFSBucket) — ideal for large files as they never load entirely into memory. Upload/download are streams you pipe to/from files or HTTP responses. metadata on uploads is queryable like any document field. GridFS chunks are automatically reassembled on download. The bucket name ('fs' by default) is configurable.

mongodb
// Node.js: upload and download with streams
const { MongoClient, GridFSBucket } = require("mongodb")
const fs = require("fs")

const bucket = new GridFSBucket(db)

// upload a file
fs.createReadStream("video.mp4")
  .pipe(bucket.openUploadStream("video.mp4", {
    metadata: { uploadedBy: "alice" }
  }))

// download a file
bucket.openDownloadStreamByName("video.mp4")
  .pipe(fs.createWriteStream("downloaded.mp4"))

// delete a file
await bucket.delete(fileId)

// find files
const files = await bucket.find({}).toArray()

Time Series Collections

Time series collections (5.0+) are optimized for sequential, time-ordered data (IoT sensors, metrics, logs). They store data in columnar-like format under the hood, achieving 30-50% compression and faster range queries than regular collections. granularity should match your data interval. TTL (expireAfterSeconds) auto-purges old data. Use $dateTrunc for time-bucket aggregation.

mongodb
# create a time series collection (MongoDB 5.0+)
db.createCollection("temperatures", {
  timeseries: {
    timeField: "timestamp",
    metaField: "sensorId",
    granularity: "seconds"   // seconds|minutes|hours
  },
  expireAfterSeconds: 2592000   // auto-delete after 30 days
})

# insert measurements
db.temperatures.insertMany([
  { timestamp: ISODate(), sensorId: "s1", value: 22.5 },
  { timestamp: ISODate(), sensorId: "s1", value: 22.7 }
])

# query by time range
db.temperatures.find({
  sensorId: "s1",
  timestamp: { $gte: ISODate("2025-01-01") }
})

# aggregate averages per hour
db.temperatures.aggregate([
  { $group: {
    _id: { $dateTrunc: { date: "$timestamp", unit: "hour" } },
    avg: { $avg: "$value" }
  }}
])

Capped Collections

Capped collections maintain insertion order and overwrite the oldest documents when full — perfect for logs, recent-activity feeds, and ring buffers. They support high-throughput inserts and tailable cursors (like tail -f). Limitations: you can't delete individual documents or grow documents beyond their original size. Change streams are the modern alternative for real-time tailing.

mongodb
# capped collections have a fixed size and overwrite old docs
db.createCollection("logs", {
  capped: true,
  size: 5242880,    # 5MB max
  max: 10000        # optional: max document count
})

# inserts only (no arbitrary deletes/updates)
db.logs.insertOne({ msg: "started", t: new Date() })

# high-throughput tail with a tailable cursor
const cursor = db.logs.find().addOption(2)  // tailable
while (true) {
  if (cursor.hasNext()) printjson(cursor.next())
}

# convert a regular collection to capped
db.runCommand({
  convertToCapped: "events",
  size: 1000000
})

BSON Size & Document Limits

The 16MB BSON document limit is a hard cap — use Object.bsonsize() to check document sizes. Large arrays (thousands of elements) bloat documents and slow updates (MongoDB rewrites the whole document on update). For unbounded data (reviews, comments, log entries), use a separate collection with a reference. For binary blobs over 16MB, use GridFS or external storage with a URL reference.

mongodb
# maximum BSON document size: 16MB
# check a document's size (in bytes)
Object.bsonsize(db.users.findOne())

# large arrays/fields should be referenced, not embedded
# a product with 10000 reviews -> use a separate collection

# check if a document is too large
const doc = { /* ... huge doc ... */ }
const size = Object.bsonsize(doc)
if (size > 16 * 1024 * 1024) {
  throw new Error("Document exceeds 16MB BSON limit")
}

# GridFS bypasses the limit by chunking
# alternatively, store large blobs externally (S3)
# and keep only a URL reference in the document
18

SQL Comparison & Migration

SQL to MongoDB Mapping

This mapping is the fastest way for SQL developers to learn MongoDB. The key difference: MongoDB has no fixed schema (documents in a collection can differ), and 'joins' are replaced by either embedding related data or using $lookup (slower than SQL joins — design your schema to minimize them). SQL GROUP BY maps to the $group aggregation stage.

mongodb
# SQL -> MongoDB terminology
# database          -> database
# table             -> collection
# row               -> document
# column            -> field
# index             -> index
# join              -> $lookup (or embed)
# primary key       -> _id (ObjectId by default)
# foreign key       -> reference (ObjectId field)
# group by          -> $group
# select            -> find / $project
# where             -> $match

# SQL query -> MongoDB query
# SELECT * FROM users WHERE age >= 18
db.users.find({ age: { $gte: 18 } })

# SELECT name, age FROM users
db.users.find({}, { name: 1, age: 1, _id: 0 })

# SELECT * FROM users ORDER BY age DESC LIMIT 10
db.users.find().sort({ age: -1 }).limit(10)

# SELECT city, COUNT(*) FROM users GROUP BY city
db.users.aggregate([
  { $group: { _id: "$city", count: { $sum: 1 } } }
])

Migrating from SQL

Migration is not a 1:1 table-to-collection mapping — the schema should be redesigned for MongoDB's document model. Embed small, exclusive, frequently-accessed-together data (a user's profile + addresses). Keep references for large, shared, or unbounded data (orders for a customer). MongoDB's Relational Migrator tool automates much of this analysis. Plan the schema, don't just port it.

mongodb
# step 1: export SQL data to JSON/CSV
# (mysqldump, pg_dump, or a script)

# step 2: transform relational data to documents
# SQL:
#   users(id, name, dept_id)
#   departments(id, name)
# MongoDB (embed small, related data):
db.users.insertOne({
  _id: 1,
  name: "Alice",
  department: { id: 10, name: "Engineering" }  # embedded
})

# step 3: for large related data, keep references
db.orders.insertOne({
  _id: 101,
  customerId: 1,    # reference, not embedded
  items: [...]      # embed order items (bounded)
})

# tools: MongoDB Relational Migrator (GUI), mongoimport
# https://www.mongodb.com/.../relational-migrator

Transactions vs SQL ACID

MongoDB provides ACID: single-document operations are always atomic (no transaction needed), and multi-document transactions (4.0+) provide full ACID across collections. The key difference: SQL defaults to ACID on every statement, while MongoDB encourages schema design that keeps related data in one document (atomic by default) and uses transactions only when truly needed. Prefer embedding to avoid transaction overhead.

mongodb
# SQL: ACID by default on every statement
BEGIN TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE name = 'Alice';
UPDATE accounts SET balance = balance + 100 WHERE name = 'Bob';
COMMIT;

# MongoDB: single-document ops are atomic (ACID)
db.accounts.updateOne({name:"Alice"}, {$inc:{balance:-100}})
# (one document = one atomic operation)

# MongoDB: multi-document ACID (transactions, replica set)
const s = db.getMongo().startSession()
s.startTransaction()
db.accounts.updateOne({name:"Alice"},{$inc:{balance:-100}},{session:s})
db.accounts.updateOne({name:"Bob"},{$inc:{balance:100}},{session:s})
s.commitTransaction()

# prefer embedding to avoid transactions entirely
# (embedded data updates atomically in one document)

Normalization vs Denormalization

SQL normalizes to avoid duplication; MongoDB denormalizes for read speed. The trade-off: denormalized data is faster to read but harder to update (multiple documents). Denormalize data that changes rarely (country names, product catalogs) and reference data that changes often (prices, inventory). The right balance depends on your read/write ratio — monitor and adjust.

mongodb
# SQL: normalize to avoid duplication (3NF)
#   users(id, name, city_id)
#   cities(id, name, country_id)
#   countries(id, name)
# -> 4 joins to get a user's country

# MongoDB: denormalize for read performance
db.users.insertOne({
  name: "Alice",
  city: "NYC",
  country: "USA"        # denormalized — duplicate but fast reads
})

# trade-off: updates must touch multiple documents
# when a city's name changes:
db.users.updateMany(
  { "city": "New York" },
  { $set: { "city": "NYC" } }
)

# rule: duplicate data that changes rarely,
# reference data that changes often

When to Use MongoDB vs SQL

There's no universal 'best' database. MongoDB excels at flexible schemas, hierarchical data, and horizontal scaling. SQL excels at relational integrity, complex joins, and heavy transactional workloads. Many modern apps are polyglot — MongoDB for catalogs/content/user-generated data, PostgreSQL for financial transactions. Match the database to the workload, not the hype.

mongodb
# CHOOSE MongoDB when:
# - schema is flexible or evolving rapidly
# - data is naturally hierarchical (embed)
# - you need horizontal scaling (sharding)
# - reads >> writes and you can denormalize
# - document-oriented domain (CMS, product catalogs, IoT)

# CHOOSE SQL (PostgreSQL/MySQL) when:
# - schema is fixed and relational (banking, ERP)
# - you need complex multi-table joins frequently
# - transactions span many tables constantly
# - strong consistency is non-negotiable
# - your team knows SQL well

# HYBRID: use both — MongoDB for product catalog/comments,
# SQL for orders/payments. Many large apps are polyglot.

# AVOID MongoDB for:
# - heavy multi-entity transactional workloads
# - apps requiring complex cross-table joins as core feature
19

Advanced Aggregation

$merge & $out (Materialized Views)

$out replaces a target collection with the pipeline output (destructive). $merge is more flexible — it can insert, replace, or combine with existing documents, enabling incremental materialized views. $merge is preferred for scheduled refreshes: only new/changed data is updated. Materialized views pre-compute expensive aggregations for fast dashboard queries.

mongodb
# $out: write aggregation results to a NEW collection
db.orders.aggregate([
  { $group: { _id: "$customerId", total: { $sum: "$amount" } } },
  { $out: "customer_totals" }
])

# $merge: merge results into an EXISTING collection (flexible)
db.orders.aggregate([
  { $group: { _id: "$customerId", total: { $sum: "$amount" } } },
  { $merge: {
    into: "customer_totals",
    on: "_id",
    whenMatched: "replace",    // replace|keepExisting|merge|fail
    whenNotMatched: "insert"   // insert|discard|fail
  }}
])

# build a materialized view refreshed on a schedule
# (cron job or change-stream triggered)
db.daily_stats.aggregate([
  { $match: { date: today } },
  { $group: { _id: "$category", count: { $sum: 1 } } },
  { $merge: { into: "daily_stats", whenMatched: "replace" } }
])

Window Functions ($setWindowFields)

$setWindowFields (5.0+) brings SQL window functions to MongoDB: running totals, rankings, moving averages, and lag/lead. partitionBy is PARTITION BY, sortBy is ORDER BY, and window defines the frame. Range windows use values (e.g., last 7 days) while documents windows use row counts. Essential for time-series analytics and reporting.

mongodb
# $setWindowFields: window functions (MongoDB 5.0+)
# like SQL OVER() — compute values across a window of docs
db.sales.aggregate([{
  $setWindowFields: {
    partitionBy: "$region",       # like PARTITION BY
    sortBy: { date: 1 },          # like ORDER BY
    output: {
      runningTotal: { $sum: "$amount", window: { documents: ["unbounded", "current"] } },
      rank: { $rank: {} },
      denseRank: { $denseRank: {} },
      movingAvg: { $avg: "$amount", window: { range: [-7, "current"], unit: "day" } },
      lagValue: { $shift: { by: -1, output: "$amount" } }
    }
  }
}])

# window: documents (count-based) or range (value-based)
# ["unbounded", "current"] = from start to current row
# [-3, "current"] = last 3 docs including current

$map, $filter & $reduce

$map, $filter, and $reduce are functional array operators for in-document transformations. $map transforms each element, $filter keeps matching elements, and $reduce aggregates an array into one value. The 'as' variable (default $$this) names the current element. These avoid $unwind for many array operations — faster and cleaner.

mongodb
# $map: transform each array element
{ $project: {
  squares: { $map: {
    input: "$nums",
    as: "n",
    in: { $multiply: ["$$n", "$$n"] }
  }}
}}

# $filter: keep elements matching a condition
{ $project: {
  adults: { $filter: {
    input: "$users",
    as: "u",
    cond: { $gte: ["$$u.age", 18] }
  }}
}}

# $reduce: fold an array into a single value
{ $project: {
  total: { $reduce: {
    input: "$items",
    initialValue: 0,
    in: { $add: ["$$value", "$$this.price"] }
  }}
}}

# variables: $$value (accumulator), $$this (current element), $$n (named)

$facet & Multi-Pipeline

$facet runs multiple sub-pipelines on the same input in a single query — perfect for dashboards needing several views of the data (breakdowns, stats, top N). Each sub-pipeline is independent. The result is one document with a field per sub-pipeline. This replaces multiple round-trips and ensures a consistent snapshot.

mongodb
# run several aggregations on the same data in one pass
db.products.aggregate([{
  $facet: {
    "byCategory": [
      { $group: { _id: "$category", count: { $sum: 1 } } },
      { $sort: { count: -1 } }
    ],
    "priceStats": [
      { $group: { _id: null, avg: { $avg: "$price" }, max: { $max: "$price" } } }
    ],
    "topRated": [
      { $sort: { rating: -1 } },
      { $limit: 5 },
      { $project: { name: 1, rating: 1, _id: 0 } }
    ],
    "totalCount": [{ $count: "n" }]
  }
}])
# result: a single doc with all four arrays/objects

$bucket & $bucketAuto

$bucket categorizes documents into explicit value ranges (like SQL CASE WHEN) — great for price tiers, age groups, and histograms. $bucketAuto lets MongoDB compute boundaries to produce a target number of buckets (good for data exploration when you don't know the distribution). Boundaries are left-inclusive, right-exclusive.

mongodb
# $bucket: group documents into explicit ranges
db.products.aggregate([{
  $bucket: {
    groupBy: "$price",
    boundaries: [0, 50, 100, 500, 1000],
    default: "other",
    output: {
      count: { $sum: 1 },
      names: { $push: "$name" },
      avgPrice: { $avg: "$price" }
    }
  }
}])
# buckets: [0,50), [50,100), [100,500), [500,1000), other

# $bucketAuto: let MongoDB choose the boundaries
db.products.aggregate([{
  $bucketAuto: {
    groupBy: "$price",
    buckets: 5,           # target 5 buckets
    output: { count: { $sum: 1 }, avg: { $avg: "$price" } }
  }
}])
# good for histograms and data exploration
20

Monitoring & Troubleshooting

Key Metrics to Monitor

The four golden signals for MongoDB: connections (approaching the limit signals leaks or missing pooling), cache hit ratio (pages read from disk indicates RAM pressure), replication lag (a lagging secondary risks failover data loss), and queue depth (lock contention). Set alerts on all four. A dropping cache hit ratio is the #1 performance warning sign.

mongodb
# connections (watch for connection leaks)
db.serverStatus().connections
# { current, available, totalCreated }

# operation rates (queries/inserts/updates/deletes per sec)
db.serverStatus().opcounters

# WiredTiger cache (disk reads = cache misses)
db.serverStatus().wiredTiger.cache
# "bytes currently in the cache" vs "maximum bytes configured"
# "pages read into cache" should be low (cache hits)

# replication lag (critical for HA)
rs.printSecondaryReplicationInfo()

# queue depth (operations waiting for locks)
db.serverStatus().globalLock.currentQueue

# open cursors (watch for leaks)
db.serverStatus().metrics.cursor

Resolving Lock Contention

MongoDB uses document-level locking (WiredTiger), so contention is rare — but unindexed queries, huge sorts, and long transactions still block. currentOp finds the culprit; killOp stops it. The root cause is usually a missing index (run explain). Schema migrations on large collections can lock for a long time — perform them in small batches or during low traffic.

mongodb
# check current queue (operations waiting)
db.serverStatus().globalLock.currentQueue
# { readers, writers, total }

# find long-running operations
db.currentOp({
  secs_running: { $gte: 5 },
  op: { $ne: "none" }
})

# kill a long-running op blocking others
db.killOp(opid)

# common causes of lock contention:
# - unindexed queries (COLLSCAN holds locks)
# - large in-memory sorts
# - long transactions
# - schema migrations on large collections

# check for slow ops in the log
db.adminCommand({ getLog: "global" }).log
  .filter(l => l.includes(" Slow query"))

Memory & Disk Analysis

WiredTiger's cache is the memory frontier — if it's full of dirty pages, writes slow down (flushing to disk). If 'pages read into cache' is high, add RAM or indexes to reduce scans. Drop unused indexes — they consume disk AND memory AND slow writes. Monitor storage growth to predict capacity needs. On sharded clusters, jumbo chunks (too large to migrate) indicate a poor shard key.

mongodb
# check storage engine memory usage
db.serverStatus().wiredTiger.cache
# "maximum bytes configured" = cache limit (default 50% RAM)
# "tracked dirty bytes" = dirty pages awaiting flush
# target: dirty < 20%, cache usage < 95%

# disk space per collection
db.users.stats().storageSize
db.users.totalIndexSize()

# find the largest collections
db.getCollectionNames().map(c => ({
  name: c, size: db.getCollection(c).storageSize()
})).sort((a,b) => b.size - a.size).slice(0, 5)

# check for jumbo chunks (sharded clusters)
sh.status(true)

# review index sizes (drop unused indexes)
db.users.getIndexes().map(i => ({
  name: i.name, size: db.users.stats().indexSizes[i.name]
}))

Log Analysis

The mongod log records slow operations, elections, errors, and more. Set slowOpThresholdMs to capture queries above your SLO. getLog retrieves the in-memory ring buffer; the on-disk log persists across restarts. logRotate archives the current log (use with logrotate for daily rotation). Increase component verbosity temporarily for deep debugging (e.g., replication: 2 for election issues), then reset.

mongodb
# view recent log entries
db.adminCommand({ getLog: "global" })

# log slow queries (set threshold)
db.adminCommand({
  setParameter: 1,
  logComponentVerbosity: { query: { verbosity: 1 } }
})

# mongod.conf: log slow operations
# operationProfiling:
#   mode: slowOp
#   slowOpThresholdMs: 100
#   slowOpSampleRate: 1.0

# grep the mongod log for slow queries
grep "Slow query" /var/log/mongodb/mongod.log
grep "COMMAND" /var/log/mongodb/mongod.log | tail -50

# rotate logs
db.adminCommand({ logRotate: 1 })

# set log verbosity for a component
db.adminCommand({
  setParameter: 1,
  logComponentVerbosity: { replication: 2 }
})

Common Errors & Solutions

These are the most common MongoDB errors. Duplicate key errors come from _id or unique index violations — handle them in app code. Aggregation memory limits are fixed with allowDiskUse (slower but works). WriteConflict in transactions is normal under contention — retry. Cursor timeouts mean your batch processing is too slow — use smaller batches or noCursorTimeout (but always close cursors).

mongodb
# ERROR: "E11000 duplicate key"
# cause: _id or unique index conflict
# fix: check the duplicate value, use upsert, or catch the error

# ERROR: "QueryExceededMemoryLimitNoDiskUseAllowed"
# cause: aggregation stage exceeded 100MB RAM
# fix: add { allowDiskUse: true } or add $match early
db.big.aggregate([...], { allowDiskUse: true })

# ERROR: "BSONObjTooLarge"
# cause: document > 16MB
# fix: use GridFS or split the document

# ERROR: "WriteConflict" (transaction)
# cause: two transactions modified the same doc
# fix: retry the transaction (TransientTransactionError)

# ERROR: "ReplicaSetNoPrimary"
# cause: no primary (election in progress)
# fix: wait for election; check rs.status()

# ERROR: "cursor not found"
# cause: cursor timed out (10 min default)
# fix: use noCursorTimeout or process faster

Was this helpful?