Database NotesRohit’s interview study guide
Chapter 21

Schema Design in MongoDB

Designing documents around how the application reads and writes them: when to embed, when to reference, the 16 MB limit, the classic patterns, and enforcing a schema anyway.

6 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Embedding vs referencing

In a relational database you design the schema from the data: normalise it into tables so that every fact lives in one place, then join at query time. In MongoDB you design from the queries: the guiding principle is "data that is accessed together should be stored together". You have two tools. Embedding puts related data inside the parent document (an order's line items inside the order). Referencing stores the related data in its own collection and keeps its _id in the parent (the order stores customerId), the way a foreign key would.

My notes say "very important: embed if possible", and call the two options normalising (a separate collection) and denormalising (the same document). The mental model is a filing folder. Embedding is putting the invoice, the delivery note and the packing list in one folder: grab the folder and you have everything, and you can update all of it in one go. Referencing is a folder with a note saying "customer details: see drawer C, file 17": the folder stays slim and the customer file is shared by every folder that mentions it, but reading the whole picture means a second trip to the cabinet.

This chapter's examples use the site's sample data as MongoDB collections (database ch19). The orders collection already makes both choices: line items are embedded, the customer is referenced:

mongosh
db.orders.findOne({ _id: 1 })
db.orders.aggregate([
  { $match: { _id: 1 } },
  { $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "customer",
               pipeline: [ { $project: { _id: 0, name: 1, email: 1 } } ] } },
  { $set: { customer: { $first: "$customer" } } },
  { $project: { items: 0 } }
])
Result
{
  _id: 1,
  customerId: 1,
  status: 'shipped',
  orderedAt: ISODate('2025-02-01T10:00:00.000Z'),
  items: [
    { productId: 1, qty: 1, unitPrice: 899 },
    { productId: 7, qty: 2, unitPrice: 29 }
  ]
}
[
  {
    _id: 1,
    customerId: 1,
    status: 'shipped',
    orderedAt: ISODate('2025-02-01T10:00:00.000Z'),
    customer: { name: 'Rohit', email: 'rohit@example.com' }
  }
]
What’s happening
  1. One findOne returns the order with all its lines: no join, one read, one index lookup on _id. In MySQL the same data needs orders joined to order_items.
  2. The items are embedded because they belong to the order: they're always read with it, never on their own, created with it, and bounded (an order has a handful of lines, not millions). Each line also stores unitPrice, the price at the time of sale, which must not change when the product's price does.
  3. The customer is referenced by customerId because a customer exists independently, has many orders, and changes (email, address). Embedding a copy of the customer in every order would mean updating every order when the customer moves.
  4. Reading the customer with the order needs a $lookup (or a second query). $first turns the joined one-element array into a plain sub-document.
  5. So one collection uses both techniques, chosen per relationship. That is the normal case: the question is never "embed or reference?" for the whole schema, but for each relationship.
AdvancedEmbedding gives you atomic multi-part updates for free

A write to a single document is atomic in MongoDB, including everything embedded in it. Adding a line to an order and changing its status is one operation that either fully happens or doesn't, with no transaction:

mongosh
db.orders.updateOne({ _id: 2 }, { $push: { items: { productId: 7, qty: 1, unitPrice: 29 } }, $set: { status: "paid" } }).modifiedCount
db.orders.updateOne({ _id: 2 }, { $pull: { items: { productId: 7 } } }).modifiedCount
Result
1
1
What’s happening
  1. The first update appends a charger to order 2's items and sets status in the same write. No reader can ever see the new line without the new status, or the other way round.
  2. With referenced line items (an order_items collection), the same change would be two writes to two collections, and keeping them consistent would need a multi-document transaction (see Multi-document transactions).
  3. The second update removes the line again, so the sample data stays as it was.
  4. This is a big reason the MongoDB docs push embedding: well-designed documents make most transactions unnecessary.
AdvancedHow to decide, relationship by relationship
Embed when…Reference when…
The child is read together with the parentThe child is often read on its own
The child belongs to one parent onlyThe child is shared by many parents (many-to-many)
The number of children is small and boundedThe number of children is large or grows without limit
The child rarely changes, or changes with the parentThe child changes often and independently
You want atomic updates of parent and childThe combined document would approach 16 MB

A useful middle ground is the extended reference: keep the reference and copy the few fields you need for display (an order stores customerId plus customerName), accepting that those copies can be slightly stale. See Schema design patterns.

Embed or reference?

A blog has posts and comments. A post page shows the post and its comments. Some posts get 5 comments, a few viral ones get 200,000. Users also have a "my comments" page. Embed the comments in the post?

Show answer

Not entirely. Reference them (a comments collection with postId, indexed), and optionally embed a subset: the latest 10 comments in the post for fast first page loads.

What’s happening
  1. Read together: yes, the post page shows comments, which argues for embedding.
  2. But the count is unbounded: 200,000 comments of a few hundred bytes would break the 16 MB limit, and long before that every new comment rewrites a huge document (measured in the next topic).
  3. The "my comments" page reads comments on their own, across posts, which a separate collection with an index on { authorId: 1, createdAt: -1 } serves directly.
  4. The subset pattern gives most of the read benefit of embedding: $push with $slice: -10 keeps the last 10 in the post, and the full list is paged from comments.

#The 16 MB document limit

A single MongoDB document, after BSON encoding, can be at most 16 MiB (16,777,216 bytes). The limit exists so that one document can't monopolise RAM or network bandwidth: a document is always read, sent and cached whole. My notes list it as the main drawback of embedding, and that's the right way to see it: the limit is rarely hit by accident, but it's the reason unbounded embedded arrays are a design bug.

Think of a document as a parcel with a courier's maximum size. Anything that fits travels as one item, quickly and atomically. If you keep stuffing things into the same box (every comment, every log line, every reading), one day the courier refuses it, and long before that it was getting expensive to carry.

mongosh
const mb = 1024 * 1024
db.big.insertOne({ _id: 1, blob: "x".repeat(16 * mb) })
db.big.insertOne({ _id: 2, blob: "x".repeat(16 * mb - 100) })
db.big.insertOne({ _id: 3, blob: "x".repeat(15 * mb) }).acknowledged
db.big.aggregate([ { $project: { bytes: { $bsonSize: "$$ROOT" } } } ])
db.big.updateOne({ _id: 3 }, { $set: { more: "y".repeat(2 * mb) } })
Result
Uncaught MongoServerError: object to insert too large. size in bytes: 16777241, max size: 16777216
{ acknowledged: true, insertedId: 2 }
true
[ { _id: 2, bytes: 16777141 }, { _id: 3, bytes: 15728665 } ]
Uncaught MongoServerError: Plan executor error during update :: caused by :: Resulting document after update is larger than 16777216
What’s happening
  1. A 16 MiB string plus the _id, the field name and BSON overhead comes to 16,777,241 bytes: 25 bytes over the 16,777,216-byte maximum, so the insert is rejected.
  2. 100 bytes less fits: 16,777,141 bytes is accepted. The limit is on the encoded BSON size, field names and type bytes included.
  3. $bsonSize (4.4+) reports a document's encoded size, which is how you find documents that are getting close.
  4. The limit applies to updates too: document 3 is 15 MB, and adding 2 MB would make it 17 MB, so the update fails and the document is left unchanged. In production that's the failure mode: an array that's been growing for months, and one day the $push starts failing.
AdvancedUnbounded arrays cost long before 16 MB

Two posts: one with no comments, one with 30,000 comments of ~400 characters (12.9 MB). Then 200 $pushes of one comment into each, twice:

mongosh
const comment = (i) => ({ by: "user" + i, at: new Date(), text: "c".repeat(400) })
db.posts.insertOne({ _id: "small", comments: [] }).acknowledged
db.posts.insertOne({ _id: "huge", comments: Array.from({ length: 30000 }, (_, i) => comment(i)) }).acknowledged
db.posts.aggregate([ { $project: { n: { $size: "$comments" }, MB: { $round: [ { $divide: [ { $bsonSize: "$$ROOT" }, 1048576 ] }, 1 ] } } } ])
const pushes = (id) => { const t = Date.now(); for (let i = 0; i < 200; i++) db.posts.updateOne({ _id: id }, { $push: { comments: comment(i) } }); return Date.now() - t }
({ smallMs: pushes("small"), hugeMs: pushes("huge") })
({ smallMs: pushes("small"), hugeMs: pushes("huge") })
let n = 0; try { while (true) { db.posts.updateOne({ _id: "huge" }, { $push: { comments: { $each: Array.from({ length: 1000 }, (_, i) => comment(i)) } } }); n += 1000 } } catch (e) { print(e.message) }
db.posts.aggregate([ { $match: { _id: "huge" } }, { $project: { n: { $size: "$comments" } } } ])
Result
true
true
[ { _id: 'small', n: 0, MB: 0 }, { _id: 'huge', n: 30000, MB: 12.9 } ]
{ smallMs: 440, hugeMs: 1970 }
{ smallMs: 462, hugeMs: 2068 }
Plan executor error during update :: caused by :: Resulting document after update is larger than 16777216
[ { _id: 'huge', n: 36400 } ]
What’s happening
  1. The same tiny $push takes about 2.2 ms on the small post and about 10 ms on the 13 MB one: 4–5 times slower, because WiredTiger stores a document as a unit and has to write a new version of the whole 13 MB document for every comment.
  2. Every reader of that post also pays: fetching the post sends 13 MB over the network unless you project the array away.
  3. Pushing 1,000 at a time, the post fails at 36,400 comments with the familiar 16 MB error. That's the hard wall; the slowdown before it is the real cost.
  4. The fix is a separate comments collection (one document per comment, indexed by postId), optionally with the latest few embedded: the subset pattern in Schema design patterns.
AdvancedFiles bigger than 16 MB: GridFS

Binary files over 16 MB (videos, large PDFs) go in GridFS, a convention every driver implements: the file is split into chunks of 255 KB stored in fs.chunks, with metadata in fs.files. The mongofiles tool ships with the MongoDB database tools; here it stores a 20 MB file:

Shell (inside the mongo container)
head -c 20971520 /dev/urandom > /tmp/ch19-video.bin
mongofiles --quiet --db=ch19 put /tmp/ch19-video.bin
mongofiles --db=ch19 list
Result
/tmp/ch19-video.bin	20971520
AdvancedInside GridFS: fs.files and fs.chunks
mongosh
db.fs.files.find({}, { _id: 0, filename: 1, length: 1, chunkSize: 1 })
db.fs.chunks.countDocuments()
Result
[
  {
    length: Long('20971520'),
    chunkSize: 261120,
    filename: '/tmp/ch19-video.bin'
  }
]
81
What’s happening
  1. The 20 MB file (run inside the Mongo container) is accepted, although no single document could hold it.
  2. fs.files has one metadata document: length 20,971,520 bytes, chunk size 261,120 bytes (255 KB).
  3. fs.chunks has 81 chunk documents: 20971520 / 261120 = 80.3, rounded up. Drivers reassemble them on read and can stream or seek within the file.
  4. In practice most teams keep files in object storage (S3, GCS, Azure Blob) and store only the URL and metadata in MongoDB. GridFS is the choice when you want files backed up, replicated and secured together with the database.
AdvancedEdge case: nesting depth

The documented limit is 100 levels of nesting (each object or array is a level). This 9.0 server accepted deeper documents in a test and only rejected an insert at 180 nested objects, with object exceeds 180 levels of nesting. Treat 100 as the real limit: tools, drivers and indexes are only guaranteed to handle that, and a schema that nests more than a few levels is hard to query and index anyway.

#When to normalise in MongoDB

My notes put it well: normalisation is ideal when the data might change frequently (an address, an email) and you want to avoid updating redundant copies across many documents, which is the same reasoning as in relational design (see Why normalise: update, insert and delete anomalies). They also say the preferred approach in MongoDB is denormalisation (embedding). Both are true: embed by default, and normalise the relationships where duplication would cost you.

The mental model is a company phone list. If every department keeps its own printed copy, looking someone up is instant, but when someone changes their extension, twenty copies are wrong until someone updates them all. Keep one central directory and everyone has to call it, but it's always right. The trade is read speed versus write cost and consistency.

Here the same 100,000 orders exist twice: ordersEmbed copies the customer's name, email and address into every order; ordersRef stores only customerId (1,000 customers in custs, both collections indexed on customer and date). Customer 1, the busiest, has 787 orders. "Customer 1 moves house" and "show customer 1's latest 20 orders with name and street", each averaged over many runs:

mongosh
const loop = (n, f) => { f(0); const t = Date.now(); for (let i = 1; i <= n; i++) f(i); return +((Date.now() - t) / n).toFixed(2) }
const readEmbedded = () => db.ordersEmbed.find({ "customer._id": 1 }, { total: 1, "customer.name": 1, "customer.address.street": 1 }).sort({ orderedAt: -1 }).limit(20).toArray()
const readReferenced = () => db.ordersRef.aggregate([
  { $match: { customerId: 1 } }, { $sort: { orderedAt: -1 } }, { $limit: 20 },
  { $lookup: { from: "custs", localField: "customerId", foreignField: "_id", as: "customer" } },
  { $project: { total: 1, "customer.name": 1, "customer.address.street": 1 } }
]).toArray()
({
  moveCustomerMs: {
    referenced: loop(50, i => db.custs.updateOne({ _id: 1 }, { $set: { "address.street": i + " Bay St" } })),
    embedded: loop(50, i => db.ordersEmbed.updateMany({ "customer._id": 1 }, { $set: { "customer.address.street": i + " Bay St" } }))
  },
  latest20Ms: { embedded: loop(200, readEmbedded), referenced: loop(200, readReferenced) },
  docsTouchedPerMove: { referenced: 1, embedded: db.ordersEmbed.countDocuments({ "customer._id": 1 }) }
})
Result
{
  moveCustomerMs: { referenced: 2.38, embedded: 8.52 },
  latest20Ms: { embedded: 0.82, referenced: 1.18 },
  docsTouchedPerMove: { referenced: 1, embedded: 787 }
}
What’s happening
  1. Write: with references, moving house is one updateOne on one customer document: 2.4 ms (mostly the replica set's majority write acknowledgement). With embedded copies it's an updateMany over 787 orders: 8.5 ms here, and it grows linearly with the number of orders. A second run gave 2.6 vs 10.8 ms.
  2. Read: the embedded version reads 20 orders in one indexed find: 0.82 ms. The referenced version adds a $lookup per order: 1.18 ms, about 45% slower, and still fast, because custs._id is indexed.
  3. Consistency: with embedding, if the updateMany is forgotten in one code path, or fails halfway (it's 787 separate document writes, not one atomic operation), some orders show the old address. With a reference there is only one copy to get wrong.
  4. Space: the embedded collection is 16.8 MB against 5.7 MB, because the customer's details are repeated 100,000 times.
  5. So the choice depends on the ratio of reads to changes. For an address that changes once in years and is shown on every order page, embedding (or an extended reference) wins. For data that changes often, or must always be current, reference it.
AdvancedShould the order keep the old address?

Not every copy is a mistake. Historical facts should be copied, not referenced. An order's shipping address is "where we shipped it", and it must not change when the customer moves, just like unitPrice must not change when the product price does. Updating the 787 orders above would actually be wrong for orders already shipped. Ask of every duplicated field: is it a snapshot (copy it, never update it) or a current value (reference it, or update every copy)?

AdvancedReasons to normalise
  • Frequently changing data shared by many documents (my notes' address and email example): one copy, one update.
  • Many-to-many relationships: students and courses, products and tags with metadata; embedding either side duplicates the other.
  • Unbounded children: comments, events, readings, log lines.
  • Independent access: the child is queried, paged, sorted or secured on its own.
  • Large sub-documents read rarely: keep the hot document small so more of the working set fits in RAM (move the big, rarely read part out).
  • Strict consistency across copies matters more than read latency.
Spot the problem

A product's name, price and category are embedded in every order line, and the team runs updateMany({ "items.productId": 7 }, { $set: { "items.$[i].price": 25 } }, { arrayFilters: [{ "i.productId": 7 }] }) whenever a price changes. What's wrong?

Show answer

It rewrites history. A price change must not alter orders that were already placed at the old price.

What’s happening
  1. The price in an order line is a snapshot: what the customer actually paid. Copying it is right; updating it later is wrong.
  2. The update silently changes past revenue, invoices and refunds for every order containing product 7.
  3. name is a judgement call (show the name at the time of sale, or the current one?), but price is not.
  4. Correct design: keep productId plus the snapshot fields (unitPrice, and name as sold), and read the current product from products when you need current data.

#Relationships: one-to-few, one-to-many and many-to-many

Added

MongoDB doesn't have relationship types, so you model each one from two questions: how many related items can there be, and which side do you read from? A common way to talk about it (from MongoDB's own "6 rules of thumb") splits one-to-many into three sizes:

  • One-to-few (a person's addresses, an order's lines): embed the children in the parent.
  • One-to-many (a product's parts, a customer's orders, hundreds to thousands): reference, usually by putting the parent's id in each child (orders.customerId), or an array of child ids in the parent if the children are few and you navigate from the parent.
  • One-to-squillions (a server's log lines, a sensor's readings: unbounded): always put the parent id in the child. An array of ids in the parent would itself grow without bound.
  • Many-to-many (students and courses): an array of references on one side (or both), joined with $lookup from either direction.

Think of it as sizing the container to the contents: a wallet holds a few cards (embed), a filing cabinet with a label per folder holds hundreds (reference), and a warehouse with an inventory system holds millions (child-to-parent references, indexed). One-to-one is the trivial case: embed it, unless one half is large and rarely read.

A many-to-many: students keep an array of course ids, and an index on that array (multikey) makes the reverse direction fast too:

mongosh
db.courses.insertMany([
  { _id: "db101", title: "Databases" }, { _id: "js201", title: "Advanced JavaScript" }, { _id: "ml300", title: "Machine Learning" }
]).acknowledged
db.students.insertMany([
  { _id: 1, name: "Asha", courseIds: ["db101", "js201"] },
  { _id: 2, name: "Ben", courseIds: ["db101"] },
  { _id: 3, name: "Chloe", courseIds: [] }
]).acknowledged
db.students.createIndex({ courseIds: 1 })
db.students.aggregate([
  { $lookup: { from: "courses", localField: "courseIds", foreignField: "_id", as: "courses" } },
  { $project: { _id: 0, name: 1, courses: "$courses.title" } }
])
db.courses.aggregate([
  { $lookup: { from: "students", localField: "_id", foreignField: "courseIds", as: "students" } },
  { $project: { title: 1, students: "$students.name" } }
])
Result
true
true
courseIds_1
[
  { name: 'Asha', courses: [ 'Databases', 'Advanced JavaScript' ] },
  { name: 'Ben', courses: [ 'Databases' ] },
  { name: 'Chloe', courses: [] }
]
[
  { _id: 'db101', title: 'Databases', students: [ 'Asha', 'Ben' ] },
  { _id: 'js201', title: 'Advanced JavaScript', students: [ 'Asha' ] },
  { _id: 'ml300', title: 'Machine Learning', students: [] }
]
What’s happening
  1. Each student stores the ids of their courses. A student takes a bounded number of courses, so the array stays small: that's why the array goes on the student side, not on the course side (a course can have thousands of students).
  2. Student → courses: localField: "courseIds" is an array, so $lookup matches every course whose _id is in it. "$courses.title" turns the joined documents into a plain list of titles. Chloe has no courses and gets [].
  3. Course → students: foreignField: "courseIds" is the array side. $lookup finds students whose array contains the course id, using the multikey index courseIds_1.
  4. Machine Learning has no students: left outer join behaviour again, an empty array.
  5. If the relationship itself has data (grade, enrolment date), use a third collection enrolments: { studentId, courseId, grade, enrolledAt }, exactly like a SQL junction table, indexed both ways.
AdvancedOne-to-few: embed

Addresses belong to one person, are few, and are always shown with the person:

mongosh
db.people3.insertOne({ _id: 1, name: "Rohit", addresses: [ { type: "home", city: "Toronto" }, { type: "work", city: "Toronto" } ] }).acknowledged
Result
true
What’s happening
  1. One document holds the person and both addresses. One read, one atomic write, and you can still index addresses.city (multikey) to find people by city.
  2. In SQL this would be a person_addresses table and a join, which is what MongoDB is designed to avoid for small, owned data.
AdvancedOne-to-many and one-to-squillions: reference from the child

The sample orders already do this: each order has customerId, and with an index on { customerId: 1, orderedAt: -1 }, "the latest orders of customer X" is a single index range, however many orders the customer has. The opposite design, customers.orderIds: [1, 2, ...], works for a few dozen children but grows without bound for a busy customer, and makes every new order a write to two documents.

For truly unbounded children, never put an array in the parent. A log line stores hostId; a reading stores sensorId (or goes into a bucket, see the next topic). The parent stays small and stable.

AdvancedTwo-way references and their cost

Sometimes both sides store references (students.courseIds and courses.studentIds), so each direction is a plain find without $lookup. It's faster to read but every enrolment becomes two writes that must stay in sync: wrap them in a transaction, or accept short-lived inconsistency. Use it only when both directions are read far more often than enrolments change, and the arrays stay bounded.

#Schema design patterns

Added

MongoDB's documentation and training describe a set of named schema design patterns: reusable answers to recurring modelling problems, much like design patterns in code. Knowing their names helps in interviews and design reviews. The ones you'll meet most:

PatternProblem it solvesIdea
SubsetHuge documents, but only part is read oftenEmbed the hot subset (latest 10 reviews), keep the rest in another collection
ComputedThe same aggregate is computed on every readMaintain it on write (reviewCount, ratingSum)
Extended reference$lookup on every read for two or three fieldsCopy those fields next to the reference
BucketMillions of tiny documents (readings, events)Group them per entity per time window
OutlierA few documents are far bigger than the restNormal design for most, overflow documents for the outliers
AttributeMany optional fields that all need queryingTurn them into [{ k, v }] pairs with one index
PolymorphicDifferent kinds of entity queried togetherOne collection, a kind field, different shapes
Schema versioningThe document shape changes over timeA schemaVersion field; migrate lazily
TreeHierarchies (categories, org charts)Parent reference, child list, ancestors array or materialised path
ArchiveOld data slows down hot dataMove it to another collection or cheaper storage

The idea behind all of them: you're allowed to shape, duplicate and pre-compute data for the way it's read, as long as you know what keeping it correct costs. The main example combines the two most common ones, subset and computed: a product keeps a running review count and rating sum, and only its 3 latest reviews, while every review lives in reviews:

mongosh
const addReview = (productId, r) => {
  db.reviews.insertOne({ productId, ...r })
  return db.items.findOneAndUpdate(
    { _id: productId },
    { $inc: { reviewCount: 1, ratingSum: r.rating },
      $push: { recentReviews: { $each: [ { by: r.by, rating: r.rating, text: r.text } ], $slice: -3 } } },
    { returnDocument: "after", projection: { _id: 0, reviewCount: 1, ratingSum: 1, "recentReviews.by": 1 } }
  )
}
addReview(8, { by: "Maya", rating: 5, text: "Great on flights" })
addReview(8, { by: "Liam", rating: 4, text: "Comfy" })
addReview(8, { by: "Noah", rating: 3, text: "Bass is weak" })
addReview(8, { by: "Zara", rating: 5, text: "Best ANC I've tried" })
db.items.aggregate([ { $project: { _id: 0, name: 1, reviewCount: 1,
  avgRating: { $round: [ { $divide: ["$ratingSum", "$reviewCount"] }, 2 ] }, recent: "$recentReviews.by" } } ])
db.reviews.countDocuments({ productId: 8 })
Result
{ reviewCount: 1, ratingSum: 5, recentReviews: [ { by: 'Maya' } ] }
{
  reviewCount: 2,
  ratingSum: 9,
  recentReviews: [ { by: 'Maya' }, { by: 'Liam' } ]
}
{
  reviewCount: 3,
  ratingSum: 12,
  recentReviews: [ { by: 'Maya' }, { by: 'Liam' }, { by: 'Noah' } ]
}
{
  reviewCount: 4,
  ratingSum: 17,
  recentReviews: [ { by: 'Liam' }, { by: 'Noah' }, { by: 'Zara' } ]
}
[
  {
    name: 'Noise-cancelling Headphones',
    reviewCount: 4,
    avgRating: 4.25,
    recent: [ 'Liam', 'Noah', 'Zara' ]
  }
]
4
What’s happening
  1. items starts as { _id: 8, name: "Noise-cancelling Headphones", reviewCount: 0, ratingSum: 0, recentReviews: [] }. Each review is first inserted into reviews, the complete history.
  2. Computed: $inc keeps reviewCount and ratingSum up to date on every write: 0 → 1 → 2 → 3 → 4 reviews, 0 → 5 → 9 → 12 → 17 rating points. The product page shows "4.25 from 4 reviews" without aggregating the reviews collection on every view.
  3. Subset: $push with $each and $slice: -3 appends the review and keeps only the last 3. After the fourth review, Maya's drops out of the product document (she's still in reviews). The array can never grow past 3, so the product document stays small.
  4. Both changes to the product happen in one atomic findOneAndUpdate, so the count, the sum and the subset can't disagree with each other. The reviews insert is a separate write; if it must never be lost without the counter update (or the other way round), wrap both in a transaction, or rebuild the computed fields periodically from reviews.
  5. Storing the sum rather than the average is deliberate: avgRating is derived at read time (17 / 4 = 4.25), and a sum can be updated with a simple $inc, while an average can't.
AdvancedBucket pattern: one document per sensor per hour

Readings arrive once a minute from 5 sensors for a day: 7,200 readings. Stored one per document, or bucketed per sensor-hour with an upsert that pushes the reading and maintains running statistics:

mongosh
const start = Date.UTC(2026, 9, 15, 0, 0, 0)
const one = [], ops = []
for (let s = 1; s <= 5; s++) for (let m = 0; m < 24 * 60; m++) {
  const ts = new Date(start + m * 60000), temp = +(20 + rand() * 5).toFixed(1)
  one.push({ sensor: s, ts, temp })
  ops.push({ updateOne: {
    filter: { sensor: s, hour: new Date(start + Math.floor(m / 60) * 3600000) },
    update: { $push: { readings: { m: m % 60, temp } }, $inc: { count: 1, sum: temp }, $min: { minTemp: temp }, $max: { maxTemp: temp } },
    upsert: true } })
}
db.readings.insertMany(one).acknowledged
db.readingBuckets.bulkWrite(ops).upsertedCount
db.readings.createIndex({ sensor: 1, ts: 1 })
db.readingBuckets.createIndex({ sensor: 1, hour: 1 })
db.adminCommand({ fsync: 1 }).ok
const st = (c) => { const s = db[c].stats(); return { docs: s.count, dataKB: Math.round(s.size / 1024), indexKB: Math.round(s.totalIndexSize / 1024) } }
({ onePerReading: st("readings"), bucketPerHour: st("readingBuckets") })
db.readingBuckets.findOne({ sensor: 1 }, { _id: 0, sensor: 1, hour: 1, count: 1, minTemp: 1, maxTemp: 1,
  avg: { $round: [ { $divide: ["$sum", "$count"] }, 2 ] }, firstTwo: { $slice: ["$readings", 2] } })
Result
7200
true
120
sensor_1_ts_1
sensor_1_hour_1
1
{
  onePerReading: { docs: 7200, dataKB: 419, indexKB: 300 },
  bucketPerHour: { docs: 120, dataKB: 221, indexKB: 40 }
}
{
  sensor: 1,
  hour: ISODate('2026-10-15T00:00:00.000Z'),
  count: 60,
  maxTemp: 24.9,
  minTemp: 20,
  avg: 22.65,
  firstTwo: [ { m: 0, temp: 23.6 }, { m: 1, temp: 20.2 } ]
}
What’s happening
  1. rand() is a small seeded random number generator, so temperatures are between 20 and 25. (The 7200 printed first is just mongosh echoing the loop's last value.)
  2. Each bulk operation finds the bucket for (sensor, hour); upsert: true creates it on the first reading of the hour. $push adds the reading, $inc keeps count and sum, $min/$max keep the extremes: a computed pattern inside a bucket.
  3. 7,200 readings become 120 documents (5 sensors × 24 hours), each holding 60 readings: a bounded array, because a new bucket starts every hour.
  4. Data shrinks from 419 KB to 221 KB (no repeated _id, sensor and full timestamp per reading) and the index from 300 KB to 40 KB (120 keys instead of 7,200). Hourly statistics come straight from the bucket: count 60, min 20, max 24.9, average 22.65.
  5. Since MongoDB 5.0, time series collections do this bucketing automatically (next example), so the hand-made version is mostly for older servers or for buckets with extra application logic.
AdvancedTime series collections do the bucketing for you

The same 7,200 readings in a time series collection, where you insert one document per reading and the server buckets and compresses them internally:

mongosh
db.createCollection("readingsTS", { timeseries: { timeField: "ts", metaField: "sensor", granularity: "minutes" } })
db.readingsTS.insertMany(db.readings.find({}, { _id: 0 }).toArray()).acknowledged
db.readingsTS.countDocuments()
db.adminCommand({ fsync: 1 }).ok
const s = db.readingsTS.stats()
({ buckets: s.timeseries.bucketCount, avgReadingsPerBucket: s.timeseries.avgNumMeasurementsPerCommit, storageKB: Math.round(s.storageSize / 1024) })
({ regularCollectionStorageKB: Math.round(db.readings.stats().storageSize / 1024) })
db.readingsTS.aggregate([ { $match: { sensor: 1 } },
  { $group: { _id: { $dateTrunc: { date: "$ts", unit: "hour" } }, avg: { $avg: "$temp" } } },
  { $sort: { _id: 1 } }, { $limit: 2 } ])
Result
{ ok: 1 }
true
7200
1
{ buckets: 10, avgReadingsPerBucket: 720, storageKB: 28 }
{ regularCollectionStorageKB: 168 }
[
  { _id: ISODate('2026-10-15T00:00:00.000Z'), avg: 22.65 },
  { _id: ISODate('2026-10-15T01:00:00.000Z'), avg: 22.726666666666667 }
]
What’s happening
  1. timeField is the timestamp, metaField identifies the series (the sensor). You write and query individual readings; countDocuments() says 7,200.
  2. Internally the server packed them into 10 buckets (2 per sensor for this day, with minutes granularity) and compresses each bucket column by column: 28 KB on disk against 168 KB for the plain collection.
  3. Queries look like ordinary queries: hourly averages via $dateTrunc give the same 22.65 for the first hour as the hand-made bucket.
  4. Time series collections also support expireAfterSeconds for automatic deletion of old data. Use them for metrics, IoT and events; use a manual bucket pattern when the "bucket" is something other than time, such as "all line items of an invoice page".
AdvancedAttribute pattern: many optional fields, one index

The catalog collection from the previous chapter has different specs for laptops, phones, books and chargers. The attribute pattern turns them into key/value pairs, which one compound index can serve:

mongosh
db.catalog.aggregate([ { $project: { sku: 1, kind: 1, price: 1, attrs: { $objectToArray: "$specs" } } }, { $out: "catalogAttrs" } ])
db.catalogAttrs.findOne({ kind: "laptop" }, { _id: 0 })
db.catalogAttrs.createIndex({ "attrs.k": 1, "attrs.v": 1 })
how({ attrs: { $elemMatch: { k: "ramGB", v: 32 } } })
how({ $and: [ { attrs: { $elemMatch: { k: "ramGB", v: 32 } } }, { attrs: { $elemMatch: { k: "cpu", v: "M4" } } } ] })
how({ attrs: { $elemMatch: { k: "watts", v: { $gte: 65 } } } })
Result
{
  sku: 'SKU0',
  price: 33,
  kind: 'laptop',
  attrs: [
    { k: 'ramGB', v: 8 },
    { k: 'cpu', v: 'M4' },
    { k: 'screenIn', v: 16 }
  ]
}
attrs.k_1_attrs.v_1
{ plan: 'FETCH <- IXSCAN', keys: 4187, docs: 4187, n: 4187 }
{ plan: 'FETCH <- IXSCAN', keys: 4104, docs: 4104, n: 1294 }
{ plan: 'FETCH <- IXSCAN', keys: 6243, docs: 6243, n: 6243 }
What’s happening
  1. $objectToArray converted specs: { ramGB: 8, cpu: "M4", screenIn: 16 } into attrs: [{ k, v }, ...] for all 50,000 products ($out wrote the new collection).
  2. One index on { "attrs.k": 1, "attrs.v": 1 } serves every attribute. $elemMatch is essential: it makes k and v match the same element, which gives tight bounds (k = ramGB, v = 32): 4,187 keys for 4,187 laptops.
  3. With two attribute conditions, the index serves one (cpu = M4, 4,104 keys) and the other is checked on the fetched documents: 1,294 laptops have both. Same limitation as the wildcard index.
  4. Ranges work too: chargers with watts ≥ 65, 6,243 exact.
  5. Compared with a wildcard index (see Wildcard indexes): the attribute pattern changes the document shape but works on any version and lets one compound index include the attribute alongside other fields. The wildcard index keeps natural documents.
AdvancedTree pattern: materialised paths

Categories form a tree (Electronics → Accessories → Chargers). Storing each node's ancestor path as a string makes "all descendants" a prefix query that an ordinary index can serve:

mongosh
db.categories.insertMany([
  { _id: "Electronics", parent: null, path: "," },
  { _id: "Laptops", parent: "Electronics", path: ",Electronics," },
  { _id: "Phones", parent: "Electronics", path: ",Electronics," },
  { _id: "Accessories", parent: "Electronics", path: ",Electronics," },
  { _id: "Chargers", parent: "Accessories", path: ",Electronics,Accessories," },
  { _id: "Books", parent: null, path: "," }
]).acknowledged
db.categories.createIndex({ path: 1 })
db.categories.find({ path: /^,Electronics,/ }, { _id: 1 }).sort({ path: 1, _id: 1 })
db.categories.find({ path: /^,Electronics,/ }).explain().queryPlanner.winningPlan.inputStage.indexBounds
db.categories.find({ parent: "Electronics" }, { _id: 1 })
db.categories.findOne({ _id: "Chargers" }).path.split(",").filter(Boolean)
Result
true
path_1
[
  { _id: 'Accessories' },
  { _id: 'Laptops' },
  { _id: 'Phones' },
  { _id: 'Chargers' }
]
{
  path: [
    '[",Electronics,", ",Electronics-")',
    '[/^,Electronics,/, /^,Electronics,/]'
  ]
}
[ { _id: 'Laptops' }, { _id: 'Phones' }, { _id: 'Accessories' } ]
[ 'Electronics', 'Accessories' ]
What’s happening
  1. Each category stores its parent (for "children of X") and its full path of ancestors (for "everything under X").
  2. All descendants of Electronics, at any depth, is an anchored regex on path. Chargers is included although its parent is Accessories.
  3. The index bounds show why it's fast: an anchored, case-sensitive prefix becomes a range from ",Electronics," up to ",Electronics-" (the next character after ,), so the server scans only that slice of the index.
  4. Direct children use the parent field; the ancestors of a node are its path split on commas.
  5. Alternatives: an ancestors: [...] array with a multikey index (exact matches instead of regex), a children array, or parent references only plus $graphLookup (see $lookup: joining collections). Paths and ancestor arrays make reads cheap; moving a subtree means rewriting the paths of every node under it.
AdvancedSchema versioning: change shape without downtime

Documents written by old code have name and phone; new code writes firstName, lastName and a phones array, and marks them schemaVersion: 2. Readers handle both, and a migration upgrades old documents whenever convenient:

mongosh
db.contacts.insertMany([
  { _id: 1, name: "Asha Rao", phone: "416-555-0101" },
  { _id: 2, schemaVersion: 2, firstName: "Ben", lastName: "Okafor", phones: [ { type: "mobile", number: "416-555-0102" } ] }
]).acknowledged
db.contacts.aggregate([ { $project: { _id: 1, v: { $ifNull: ["$schemaVersion", 1] },
  displayName: { $cond: [ { $gte: [ { $ifNull: ["$schemaVersion", 1] }, 2 ] },
                          { $concat: ["$firstName", " ", "$lastName"] }, "$name" ] } } } ])
db.contacts.updateMany({ schemaVersion: { $exists: false } }, [
  { $set: {
      schemaVersion: 2,
      firstName: { $arrayElemAt: [ { $split: ["$name", " "] }, 0 ] },
      lastName: { $arrayElemAt: [ { $split: ["$name", " "] }, 1 ] },
      phones: [ { type: "mobile", number: "$phone" } ]
  } },
  { $unset: ["name", "phone"] }
]).modifiedCount
db.contacts.find()
Result
true
[
  { _id: 1, v: 1, displayName: 'Asha Rao' },
  { _id: 2, v: 2, displayName: 'Ben Okafor' }
]
1
[
  {
    _id: 1,
    schemaVersion: 2,
    firstName: 'Asha',
    lastName: 'Rao',
    phones: [ { type: 'mobile', number: '416-555-0101' } ]
  },
  {
    _id: 2,
    schemaVersion: 2,
    firstName: 'Ben',
    lastName: 'Okafor',
    phones: [ { type: 'mobile', number: '416-555-0102' } ]
  }
]
What’s happening
  1. Version 1 documents have no schemaVersion; $ifNull treats a missing version as 1, so readers can branch on it.
  2. During the transition, the read path builds displayName from either shape. In application code that's usually a small "upgrade on read" function.
  3. The migration is an update pipeline (see Update pipelines): it computes the new fields from the old ones ($split the name, wrap phone into the phones array) and $unsets the old fields, one document at a time, while the application keeps running.
  4. Only Asha's document needed upgrading (modifiedCount: 1). Afterwards both documents have the version 2 shape, and the version 1 read path can be deleted.
  5. In SQL this would be an ALTER TABLE plus a data migration that has to finish before the new code ships; in MongoDB, old and new shapes can coexist, which is both the convenience and the risk (schema validation, next, keeps it under control).
AdvancedOutlier, extended reference, polymorphic and archive
  • Outlier: most books have a few dozen buyers, one bestseller has millions. Design for the common case (embed buyerIds up to, say, 1,000) and flag the outliers (hasOverflow: true) with the rest in overflow documents. Only the code path for outliers pays the extra complexity.
  • Extended reference: an order stores customerId and a copy of customerName and shippingAddress. The order page needs no $lookup; the copied fields are either snapshots (correct as historical data) or are updated in the background when the customer changes.
  • Polymorphic: the catalog collection holds laptops, phones, books and chargers with different specs, distinguished by kind. One collection means one query for "everything under $50" across kinds; indexes on the shared fields serve all of them.
  • Archive: move orders older than two years to ordersArchive (an aggregation with $merge, then a ranged delete) or to cheap object storage (Atlas Online Archive is the managed version, not available in this sandbox). The hot collection's working set then fits in RAM.

#Schema validation

Added

MongoDB is "schemaless" in the sense that the database doesn't require a schema, not that you shouldn't have one. Schema validation (3.2+, $jsonSchema since 3.6) lets a collection reject documents that don't match rules you define: required fields, BSON types, value ranges, enums, string patterns. It's the MongoDB counterpart of NOT NULL, column types and CHECK constraints (see UNIQUE, NOT NULL, DEFAULT and CHECK).

The mental model is a form with validation in front of the filing cabinet. Without it, anyone can file anything, and every reader has to cope with every shape that was ever written. With it, the cabinet itself refuses a document with a missing field or a string where a date belongs, no matter which application, script or intern wrote it. Application-level validation (Mongoose schemas, for example) is still useful for friendly error messages, but only the database sees every write.

mongosh
db.createCollection("members", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["email", "plan", "createdAt"],
      properties: {
        email: { bsonType: "string", pattern: "^[^@\\s]+@[^@\\s]+$", description: "must be an email address" },
        plan: { enum: ["free", "pro", "enterprise"] },
        seats: { bsonType: "int", minimum: 1, maximum: 500 },
        createdAt: { bsonType: "date" }
      }
    }
  },
  validationLevel: "strict",
  validationAction: "error"
})
db.members.insertOne({ email: "asha@example.com", plan: "pro", seats: 5, createdAt: new Date() }).acknowledged
db.members.insertOne({ email: "not-an-email", plan: "gold", seats: 0, createdAt: "2026-10-16" })
Result
{ ok: 1 }
true
Uncaught:
MongoServerError: Document failed validation
Additional information: {
  failingDocumentId: ObjectId('6ad24c553d67341a4d2fa925'),
  details: {
    operatorName: '$jsonSchema',
    schemaRulesNotSatisfied: [
      {
        operatorName: 'properties',
        propertiesNotSatisfied: [
          {
            propertyName: 'email',
            description: 'must be an email address',
            details: [
              {
                operatorName: 'pattern',
                specifiedAs: { pattern: '^[^@\\s]+@[^@\\s]+$' },
                reason: 'regular expression did not match',
                consideredValue: 'not-an-email'
              }
            ]
          },
          {
            propertyName: 'plan',
            details: [
              {
                operatorName: 'enum',
                specifiedAs: { enum: [ 'free', 'pro', 'enterprise' ] },
                reason: 'value was not found in enum',
                consideredValue: 'gold'
              }
            ]
          },
          {
            propertyName: 'seats',
            details: [
              {
                operatorName: 'minimum',
                specifiedAs: { minimum: 1 },
                reason: 'comparison failed',
                consideredValue: 0
              }
            ]
          },
          {
            propertyName: 'createdAt',
            details: [
              {
                operatorName: 'bsonType',
                specifiedAs: { bsonType: 'date' },
                reason: 'type did not match',
                consideredValue: '2026-10-16',
                consideredType: 'string'
              }
            ]
          }
        ]
      }
    ]
  }
}
What’s happening
  1. The validator is attached when the collection is created. required lists fields that must exist; properties constrains each field's type and values. Fields not mentioned (a nickname, say) are allowed unless you add additionalProperties: false.
  2. The valid member is inserted. The invalid one fails with Document failed validation, and since MongoDB 5.0 the error explains every rule that failed, with the value it considered: the email doesn't match the pattern, "gold" isn't in the enum, 0 is below the minimum, and createdAt is a string, not a date.
  3. The createdAt check is exactly what protects the TTL index from the previous chapter, which silently ignores string dates.
  4. validationLevel: "strict" (the default) checks every insert and update; validationAction: "error" (the default) rejects invalid writes. Both can be changed later with collMod.
  5. Before 5.0 the error said only "Document failed validation", with no details, which made validators painful to debug; that's one reason older codebases avoided them.
AdvancedPitfall: bsonType int and JavaScript numbers

bsonType: "int" means a 32-bit BSON integer, and what you send depends on the client. From mongosh (and the Node driver), whole numbers are sent as int, fractional numbers as double:

mongosh
const ok = (doc) => { try { db.members.insertOne(doc); return "inserted" } catch (e) { return e.message } }
ok({ email: "b@example.com", plan: "free", seats: 5.5, createdAt: new Date() })
ok({ email: "c@example.com", plan: "free", seats: 5.0, createdAt: new Date() })
ok({ email: "d@example.com", plan: "free", seats: NumberLong(5), createdAt: new Date() })
ok({ email: "e@example.com", plan: "free", createdAt: new Date(), nickname: "anything goes" })
db.members.find({}, { _id: 0, email: 1, seats: 1, t: { $type: "$seats" } })
Result
Document failed validation
inserted
Warning: NumberLong: specifying a number as argument is deprecated and may lead to loss of precision, pass a string instead
Document failed validation
inserted
[
  { email: 'asha@example.com', seats: 5, t: 'int' },
  { email: 'c@example.com', seats: 5, t: 'int' },
  { email: 'e@example.com', t: 'missing' }
]
What’s happening
  1. 5.5 is a double: rejected, as intended.
  2. 5.0 is the same JavaScript number as 5, so it's sent as an int and accepted.
  3. NumberLong(5) is a 64-bit long, which is not int: rejected, even though it's a perfectly good seat count. Other drivers (Java, Python with large values, C#) may send long routinely. Use bsonType: ["int", "long"], or "number" for any numeric type.
  4. seats is optional (not in required), so a member without it is fine, and the undeclared nickname field is allowed.
AdvancedvalidationLevel and validationAction

A real collection already has documents that break new rules. validationLevel: "moderate" applies the rules to inserts and to updates of documents that are already valid, leaving existing invalid documents alone; validationAction: "warn" logs violations instead of rejecting them. Together they let you introduce validation without breaking anything, find the bad data, fix it, then tighten:

mongosh
db.legacy.insertMany([ { _id: 1, email: "ok@example.com" }, { _id: 2, email: 42 } ]).acknowledged
const rule = { $jsonSchema: { required: ["email"], properties: { email: { bsonType: "string" } } } }
db.runCommand({ collMod: "legacy", validator: rule, validationLevel: "moderate", validationAction: "error" }).ok
db.legacy.find({ $nor: [ rule ] })
db.legacy.updateOne({ _id: 2 }, { $set: { note: "still invalid, but it was invalid before" } }).modifiedCount
db.legacy.updateOne({ _id: 1 }, { $set: { email: 7 } })
db.runCommand({ collMod: "legacy", validationLevel: "strict" }).ok
db.legacy.updateOne({ _id: 2 }, { $set: { note: "now rejected" } })
db.runCommand({ collMod: "legacy", validationAction: "warn" }).ok
db.legacy.insertOne({ _id: 3, email: false }).acknowledged
db.runCommand({ collMod: "legacy", validationAction: "errorAndLog" }).ok
db.legacy.insertOne({ _id: 4, email: null })
db.legacy.insertOne({ _id: 5, email: null }, { bypassDocumentValidation: true }).acknowledged
Result
true
1
[ { _id: 2, email: 42 } ]
1
Uncaught:
MongoServerError: Plan executor error during update :: caused by :: Document failed validation
Additional information: { failingDocumentId: 1, details: { ... consideredValue: 7, consideredType: 'int' ... } }
1
Uncaught:
MongoServerError: Plan executor error during update :: caused by :: Document failed validation
Additional information: { failingDocumentId: 2, details: { ... consideredValue: 42, consideredType: 'int' ... } }
1
true
1
Uncaught:
MongoServerError: Document failed validation
Additional information: { failingDocumentId: 4, details: { ... } }
true
AdvancedThe validation warnings in the server log
mongod log (trimmed)
"s":"W", "c":"STORAGE", "id":20294,   "msg":"Document would fail validation", "attr":{"namespace":"ch19.legacy","document":{"_id":3,"email":false}, ...
"s":"W", "c":"STORAGE", "id":7488700, "msg":"Document failed validation",       "attr":{"namespace":"ch19.legacy","document":{"_id":4,"email":null}, ...
What’s happening
  1. collMod adds the validator to an existing collection; it does not check existing documents. Querying with $nor: [rule] (a $jsonSchema works in find too) lists the documents that violate it: _id: 2 with a numeric email.
  2. Under moderate, updating the already-invalid document 2 is allowed, but making the valid document 1 invalid (email: 7) is rejected. The error details are trimmed here; they have the same shape as in the main example.
  3. Under strict, any update of document 2 is rejected until its email is fixed.
  4. With validationAction: "warn", the invalid insert of document 3 succeeds, and the server log records "Document would fail validation" (log id 20294). That's how you measure how much traffic would break before switching to error.
  5. errorAndLog (new in 8.1) rejects the write and logs it ("Document failed validation", id 7488700), useful for finding which client is sending bad data. A collection using it can't be downgraded below 8.1 until the action is changed.
  6. bypassDocumentValidation: true skips validation for users with the bypassDocumentValidation privilege (root has it): for migrations and restores, never for application code.
AdvancedQuery-operator validators and what validation can't do

Besides $jsonSchema, a validator can be any query filter, including $expr for cross-field rules that JSON Schema can't express:

mongosh
db.createCollection("bookings", { validator: { $and: [
  { $jsonSchema: { required: ["start", "end"], properties: { start: { bsonType: "date" }, end: { bsonType: "date" } } } },
  { $expr: { $lt: ["$start", "$end"] } }
] } })
db.bookings.insertOne({ start: ISODate("2026-10-20T10:00:00Z"), end: ISODate("2026-10-20T11:00:00Z") }).acknowledged
db.bookings.insertOne({ start: ISODate("2026-10-20T12:00:00Z"), end: ISODate("2026-10-20T11:00:00Z") })
Result
{ ok: 1 }
true
Uncaught:
MongoServerError: Document failed validation
Additional information: {
  failingDocumentId: ObjectId('6ad24e53637102f1d21a6e69'),
  details: {
    operatorName: '$and',
    clausesNotSatisfied: [
      {
        index: 1,
        details: {
          operatorName: '$expr',
          specifiedAs: { '$expr': { '$lt': [ '$start', '$end' ] } },
          reason: 'expression did not match',
          expressionResult: false
        }
      }
    ]
  }
}
What’s happening
  1. The validator combines a $jsonSchema (both fields must be dates) with an $expr that compares two fields of the same document, which JSON Schema can't express.
  2. A booking from 10:00 to 11:00 passes.
  3. A booking that ends before it starts fails, and the details point at clause index: 1 of the $and, the $expr, with expressionResult: false.
AdvancedWhat validation can't enforce

Validation checks one document at a time. It can't enforce uniqueness (use a unique index), referential integrity (that customerId points at an existing customer: MongoDB has no foreign keys, so the application or a transaction must ensure it), or rules across documents. Validators also can't use $where, $near, $text or $function.

Predict the result: moderate validation

A collection has a validator requiring email to be a string, with validationLevel: "moderate". Document A has email: 42 (inserted before the validator existed). What happens to updateOne({ _id: A }, { $set: { visits: 1 } }) and to insertOne({ email: 42 })?

Show answer

The update succeeds; the insert fails.

What’s happening
  1. moderate only validates updates to documents that currently pass the rules. Document A already fails (its email is a number), so updates to it aren't checked, and the $set goes through.
  2. Inserts are always validated (unless validationLevel: "off"), so a new document with email: 42 is rejected with "Document failed validation".
  3. Same behaviour as the legacy example above. Switch to strict once the existing bad documents have been fixed.

SQL and MySQL written fresh; MongoDB built from Rohit’s “mongoDB interview” doc. Every query was run on MySQL 9.7, MongoDB 9.0, Redis 8.10 or PostgreSQL 18.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.