Database NotesRohit’s interview study guide
Chapter 16

MongoDB Fundamentals

What MongoDB is, how documents and BSON work, what _id really is, the shell and the GUI, how SQL words map to MongoDB words, and which version you are running.

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

#What MongoDB is

Added

MongoDB is a document database. Instead of splitting a customer across a customers table, an orders table and an order_items table and stitching them back together with joins, you can store the customer as one JSON-like document that already contains their address and their orders. Documents live in collections (the rough equivalent of tables), and collections live in databases. There is no CREATE TABLE: a collection does not force every document to have the same fields, so two documents in the same collection can have different shapes.

The mental model: a relational database is a set of spreadsheets you cross-reference (the row in orders points at a row in customers by id). MongoDB is a filing cabinet of folders: each folder holds everything about one customer, so the common case, "show me this customer and their orders", is a single read of a single folder. The design rule MongoDB people repeat is "data that is accessed together should be stored together."

It is not a toy key-value store. MongoDB has a rich query language, secondary indexes of many kinds, an aggregation pipeline (its answer to GROUP BY, joins and reporting), multi-document ACID transactions (since 4.0), replication for high availability (replica sets) and sharding for horizontal scaling. The server is written in C++ and stores data with the WiredTiger storage engine.

mongosh
db.customers.insertOne({
  _id: 1,
  name: "Rohit",
  email: "rohit@example.com",
  address: { city: "Toronto", country: "CA" },
  orders: [
    { orderId: 1, status: "shipped", items: [
      { product: "ThinkBook 14", qty: 1, price: 899 },
      { product: "USB-C Charger", qty: 2, price: 29 } ] },
    { orderId: 2, status: "paid", items: [
      { product: "SQL Performance Explained", qty: 1, price: 39 },
      { product: "Designing Data-Intensive Applications", qty: 1, price: 49 } ] }
  ]
})

db.customers.findOne({ "orders.status": "paid" })
Result
{ acknowledged: true, insertedId: 1 }

{
  _id: 1,
  name: 'Rohit',
  email: 'rohit@example.com',
  address: { city: 'Toronto', country: 'CA' },
  orders: [
    {
      orderId: 1,
      status: 'shipped',
      items: [
        { product: 'ThinkBook 14', qty: 1, price: 899 },
        { product: 'USB-C Charger', qty: 2, price: 29 }
      ]
    },
    {
      orderId: 2,
      status: 'paid',
      items: [
        { product: 'SQL Performance Explained', qty: 1, price: 39 },
        { product: 'Designing Data-Intensive Applications', qty: 1, price: 49 }
      ]
    }
  ]
}
What’s happening
  1. db is the current database (ch16 here) and db.customers is a collection handle. The collection doesn't exist yet; MongoDB creates it on the first insert, so there was no schema to declare.
  2. insertOne sends one document. It has a nested object (address) and an array of nested objects (orders), and each order has its own array (items). This is the "folder" — one customer and everything about them.
  3. The server replies { acknowledged: true, insertedId: 1 }: the write was confirmed and the document's primary key is the _id: 1 we supplied (if we hadn't, the shell would have generated an ObjectId).
  4. findOne({ "orders.status": "paid" }) uses dot notation to reach into the array: "any element of orders whose status is paid". No join, no second query.
  5. The whole document comes back in one read, orders and items included. That is the core trade: reads of the "whole thing" are cheap and simple, at the cost of deciding up front how your data is grouped (see Embedding vs referencing).
AdvancedThe same customer in MySQL needs joins

The same data in the site's MySQL sample schema takes four tables and three joins to reassemble:

SQL
SELECT c.name, o.id AS order_id, o.status, p.name AS product, oi.quantity, oi.unit_price
FROM customers c
JOIN orders o       ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p     ON p.id = oi.product_id
WHERE c.id = 1
ORDER BY o.id, p.name;
Result
+-------+----------+---------+---------------------------------------+----------+------------+
| name  | order_id | status  | product                               | quantity | unit_price |
+-------+----------+---------+---------------------------------------+----------+------------+
| Rohit |        1 | shipped | ThinkBook 14                          |        1 |     899.00 |
| Rohit |        1 | shipped | USB-C Charger                         |        2 |      29.00 |
| Rohit |        2 | paid    | Designing Data-Intensive Applications |        1 |      49.00 |
| Rohit |        2 | paid    | SQL Performance Explained             |        1 |      39.00 |
+-------+----------+---------+---------------------------------------+----------+------------+
What’s happening
  1. In the relational design, a customer is one row in customers, each order is a row in orders, and each line of an order is a row in order_items, with the product name in yet another table, products.
  2. To show "Rohit and his orders" the database joins all four, matching foreign keys (o.customer_id = c.id, and so on).
  3. The result is flat: four rows, Rohit's name repeated on every one and each order's status repeated per item. The application has to fold them back into "one customer, two orders, four items" itself.
  4. The MongoDB document stored that nesting directly. Neither approach is "better" in general: the relational one avoids duplication and makes ad-hoc questions ("best-selling product across all customers") easy, while the document one makes the common read path one lookup. Chapter 21 is about choosing.
AdvancedA second document with a different shape

Flexible shape means a second customer can look completely different, and MongoDB accepts it without complaint:

mongosh
db.customers.insertOne({
  _id: 2,
  name: "Maya",
  phones: ["+91 98200 00000", "+91 22 2000 0000"],
  vip: true
})

db.customers.find({}, { name: 1, address: 1, phones: 1, vip: 1 })
Result
{ acknowledged: true, insertedId: 2 }

[
  { _id: 1, name: 'Rohit', address: { city: 'Toronto', country: 'CA' } },
  {
    _id: 2,
    name: 'Maya',
    phones: [ '+91 98200 00000', '+91 22 2000 0000' ],
    vip: true
  }
]
What’s happening
  1. Maya has no email, no address and no orders, but she has a phones array and a vip flag that Rohit doesn't. The insert succeeds anyway: by default a collection does not validate the shape of its documents.
  2. find({}, { … }) means "every document, but only these fields" (a projection). Each document returns only the fields it actually has; _id comes back even though we didn't ask for it.
  3. Rohit has no phones, so it is simply absent from his result, not null. Missing and null are different things in MongoDB, and queries treat them slightly differently ($exists and $type).
  4. This flexibility is why MongoDB is popular for evolving products: you can start writing a new field today without a migration. The flip side is in the next box.
AdvancedPitfall: flexible schema hides typos
AdvancedWhere MongoDB fits and where it doesn't

Where MongoDB fits well, and where it fits less well:

  • Good fits: product catalogues where every category has different attributes, user profiles and settings, content management, event and activity logs, IoT and time-series data (it has dedicated time-series collections), mobile and web back ends whose data is naturally JSON, and anything where the read path is "fetch one aggregate by id".
  • Less natural fits: heavily relational data with many many-to-many relationships queried from every direction, ad-hoc reporting across many entities (possible with aggregation, but SQL is more expressive), and workloads where almost every operation must update several entities atomically (transactions work, but if everything needs them, a relational database is the more natural tool).

#Documents, BSON and collections

Added

A document is an ordered set of field–value pairs, like a JSON object. Values can be strings, numbers, booleans, null, nested documents, arrays, and a few types JSON doesn't have. On disk and on the wire, MongoDB doesn't store JSON text: it stores BSON ("binary JSON"), a binary format where every value carries a type tag and every document and string carries its length.

Why bother? JSON has one number type and no dates, so you can't tell a 32-bit integer from a 64-bit one, a money amount from a float, or a date from a string. BSON can: it has int (32-bit), long (64-bit), double, decimal (128-bit decimal, exact for money), date, objectId, binData, regex and more. The length prefixes let the server skip over a field it doesn't need without parsing it, which makes scanning documents fast. The mental model: JSON is a postcard you have to read word by word; BSON is a parcel with labelled compartments and the size printed on each one.

A collection is a group of documents, and a database is a group of collections. Both are created lazily the first time you write to them. One hard limit to remember: a single document can be at most 16 MiB of BSON (16,777,216 bytes), with at most 100 levels of nesting.

mongosh
db.types.insertOne({
  _id: 1,
  qty: 12,
  price: 899.5,
  views: NumberLong("9007199254740993"),
  total: NumberDecimal("1299.00"),
  createdAt: new Date("2025-01-05T09:00:00Z"),
  inStock: true,
  discount: null,
  tags: ["laptop", "ultrabook"],
  dims: { w: 32.4, h: 1.6 }
})

db.types.aggregate([
  { $project: {
      _id: 0,
      qty: { $type: "$qty" },         price: { $type: "$price" },
      views: { $type: "$views" },     total: { $type: "$total" },
      createdAt: { $type: "$createdAt" }, inStock: { $type: "$inStock" },
      discount: { $type: "$discount" },   tags: { $type: "$tags" },
      dims: { $type: "$dims" },       colour: { $type: "$colour" }
  } }
])
Result
{ acknowledged: true, insertedId: 1 }

[
  {
    qty: 'int',
    price: 'double',
    views: 'long',
    total: 'decimal',
    createdAt: 'date',
    inStock: 'bool',
    discount: 'null',
    tags: 'array',
    dims: 'object',
    colour: 'missing'
  }
]
What’s happening
  1. The shell is JavaScript, so 12 and 899.5 are both plain JS numbers. When the driver serialises the document to BSON, it stores a whole number that fits in 32 bits as int and anything else as double, which is why qty became 'int' and price became 'double'.
  2. NumberLong("9007199254740993") is passed as a string on purpose: that value is bigger than JavaScript can represent exactly (2^53 + 1), so writing it as a number literal would already have lost the last digit before it reached MongoDB. It is stored as 'long'.
  3. NumberDecimal("1299.00") is stored as 'decimal' (Decimal128): exact base-10 arithmetic for money. new Date(…) becomes a real 'date' (milliseconds since the epoch, always UTC), not a string.
  4. null is a value with its own type, 'null'. Arrays are 'array' and nested documents are 'object'.
  5. colour doesn't exist in the document, and $type reports 'missing': a missing field is not the same as a field set to null. This distinction comes back in queries ($exists and $type).
AdvancedWhy money belongs in Decimal128

Doubles can't represent most decimal fractions exactly, so sums drift. Decimal128 doesn't:

mongosh
db.types.aggregate([
  { $project: {
      _id: 0,
      asDouble:  { $add: [0.1, 0.2] },
      asDecimal: { $add: [NumberDecimal("0.1"), NumberDecimal("0.2")] }
  } }
])
Result
[ { asDouble: 0.30000000000000004, asDecimal: Decimal128('0.3') } ]
What’s happening
  1. $add runs on the server, inside an aggregation, so this is MongoDB's arithmetic, not JavaScript's.
  2. 0.1 and 0.2 are doubles (binary floating point). Neither has an exact binary representation, and their sum comes out as 0.30000000000000004.
  3. The same sum on Decimal128 values is exactly Decimal128('0.3'), because the type stores base-10 digits.
  4. Use Decimal128 (or integer cents in an int/long) for prices, balances and anything you add up; use doubles for measurements where tiny rounding doesn't matter. In Node, the driver gives you a Decimal128 object, not a JS number, so you convert explicitly when displaying it.
AdvancedNumbers of different types compare by value

A query for qty: 12.0 (a double) still finds the document where qty is the int 12, and so does a query on a 64-bit NumberLong("12"):

mongosh
db.types.find({ qty: 12.0 }, { qty: 1 })
db.types.find({ qty: NumberLong("12") }, { qty: 1 })
Result
[ { _id: 1, qty: 12 } ]
[ { _id: 1, qty: 12 } ]
What’s happening
  1. For comparisons and sorting, MongoDB treats int, long, double and decimal as one family, numbers, compared by numeric value.
  2. So 12.0 matches the stored int 12, and so does NumberLong("12"). Both queries return the document.
  3. What does not match is the string "12": strings and numbers are different type families, and a query for one never matches the other. That is the classic bug when data was imported from CSV ($exists and $type shows how to find such documents).
AdvancedCollections and databases appear on first write
mongosh
db.getCollectionNames().includes("drafts")
db.drafts.find()
db.getCollectionNames().includes("drafts")
db.drafts.insertOne({ title: "first" })
db.getCollectionNames().includes("drafts")
db.drafts.drop()
Result
false

false
{
  acknowledged: true,
  insertedId: ObjectId('6ad24852023048831046f878')
}
true
true
What’s happening
  1. db.drafts doesn't exist yet, so includes("drafts") is false.
  2. Reading from it is allowed and just returns nothing (the shell prints no documents). Reading never creates anything, so the second check is still false.
  3. The insertOne creates the collection implicitly, and now the check is true. Databases work the same way: use shop only switches the shell's db variable; shop appears in show dbs only after its first write.
  4. drop() deletes the collection and its indexes and returns true.
  5. When you need options, create the collection explicitly with db.createCollection(name, options): a JSON Schema validator, a default collation, capped size limits, timeseries settings, or clusteredIndex. Implicit creation gives you none of those.
AdvancedThe 16 MiB document limit, for real
mongosh
db.big.insertOne({ s: "x".repeat(16 * 1024 * 1024) })
db.big.insertOne({ s: "x".repeat(16 * 1024 * 1024 - 100) }).acknowledged
bsonsize(db.big.findOne())
db.big.drop()
Result
MongoServerError: object to insert too large. size in bytes: 16777246, max size: 16777216
true
16777146
true
What’s happening
  1. A string of exactly 16 × 1024 × 1024 characters plus the field name, the _id and the BSON framing comes to 16,777,246 bytes, 30 bytes over the limit. The server rejects it: object to insert too large. size in bytes: 16777246, max size: 16777216.
  2. 100 characters shorter, the document fits and the insert is acknowledged (true).
  3. bsonsize(doc) is a shell helper that returns a document's BSON size in bytes: just under 16 MiB here.
  4. You won't normally hit this with ordinary records. You hit it with unbounded arrays (every comment, every log line, every follower pushed into one document) — which is why "embed if possible" comes with a warning (The 16 MB document limit). For large files, use GridFS or object storage.

#_id and ObjectId

Added

Every document has an _id field: its primary key. MongoDB creates a unique index on _id in every collection automatically, so two documents can never share one. _id can be almost any type — a number, a string, a nested document — but not an array, and once a document is inserted its _id is immutable.

If you don't supply _id, the client (the shell or your driver, not the server) generates an ObjectId: a 12-byte value made of a 4-byte timestamp (seconds since the Unix epoch), a 5-byte random value chosen once per process, and a 3-byte counter that starts at a random number and increments. That design lets thousands of application servers generate ids at the same time without coordinating and without asking the database for the next number.

The mental model: an ObjectId is a deli ticket stamped with the time and the machine that printed it. Any machine can print tickets on its own and they will never clash, and you can read roughly when each ticket was printed — but unlike AUTO_INCREMENT, the numbers aren't a strict, gap-free sequence.

mongosh
db.people.insertOne({ name: "Liam", city: "London" })

const liam = db.people.findOne({ name: "Liam" })
liam
liam._id.getTimestamp()
typeof liam._id
liam._id.toHexString().length
Result
{
  acknowledged: true,
  insertedId: ObjectId('6ad24852023048831046f87b')
}

{
  _id: ObjectId('6ad24852023048831046f87b'),
  name: 'Liam',
  city: 'London'
}
ISODate('2026-10-16T15:52:50.000Z')
object
24
What’s happening
  1. The document has no _id, so mongosh generated an ObjectId before sending the insert, and the reply's insertedId shows it. The server then built the _id index entry for it.
  2. findOne returns the stored document, and _id comes first: MongoDB always puts _id at the front of a stored document.
  3. getTimestamp() decodes the first 4 bytes back into a date — the second the shell created the id. That is free "created at" information, accurate to the second.
  4. typeof says 'object': an ObjectId is not a string. Its text form, toHexString(), is 24 hex characters (12 bytes × 2).
  5. This is why you rarely need a separate createdAt just for ordering, and why you should still add one when you need milliseconds, time zones or a date the user can change.
AdvancedYour own _id, and the errors around it

You can use any natural key that is genuinely unique and never changes, such as an ISBN or an existing numeric id. The rules are enforced:

mongosh
db.people.insertOne({ _id: "maya@example.com", name: "Maya" })
db.people.insertOne({ _id: "maya@example.com", name: "Maya again" })
db.people.insertOne({ _id: [1, 2], name: "Array id" })
db.people.updateOne({ _id: "maya@example.com" }, { $set: { _id: "maya@new.example" } })
Result
{ acknowledged: true, insertedId: 'maya@example.com' }
MongoServerError: E11000 duplicate key error collection: ch16.people index: _id_ dup key: { _id: "maya@example.com" }
MongoServerError: The '_id' value cannot be of type array
MongoServerError: Plan executor error during update :: caused by :: Performing an update on the path '_id' would modify the immutable field '_id'
What’s happening
  1. A string _id works: the reply's insertedId is the email we chose.
  2. Inserting the same _id again fails with E11000 duplicate key error, naming the collection (ch16.people), the index (_id_, the automatic one) and the duplicate value. E11000 is the error code you'll see for any unique-index violation, not just _id.
  3. An array _id is rejected outright: The '_id' value cannot be of type array.
  4. Trying to change _id with an update fails: Performing an update on the path '_id' would modify the immutable field '_id'. To "rename" a key you insert a new document and delete the old one (in a transaction if it matters).
  5. Choose natural keys carefully: an email address looks unique but people change emails, and an immutable _id makes that painful. A surrogate ObjectId plus a unique index on email is usually the safer design.
AdvancedEdge case: ObjectId vs its hex string
AdvancedObjectIds sort roughly by creation time

Because the timestamp is the leading 4 bytes, sorting by _id approximates insertion order, and you can build an ObjectId from a date to use in range queries:

mongosh
ObjectId.createFromTime(Math.floor(new Date("2026-01-01T00:00:00Z").getTime() / 1000))
db.people.countDocuments({ _id: { $gte: ObjectId.createFromTime(Math.floor(new Date("2026-01-01T00:00:00Z").getTime() / 1000)) } })
Result
ObjectId('6955b9000000000000000000')
1
What’s happening
  1. ObjectId.createFromTime(seconds) builds an ObjectId whose timestamp is 2026-01-01 and whose other 8 bytes are zero, the smallest possible id for that second: ObjectId('6955b9000000000000000000') (0x6955b900 is 1,767,225,600 seconds).
  2. Every ObjectId generated after that moment compares greater than it, so { _id: { $gte: … } } means "created on or after 1 January 2026". Liam's id qualifies. Maya's string _id doesn't: strings and ObjectIds are different types, so it is not compared at all. The count is 1.
  3. Treat this ordering as approximate: ids created in the same second by different processes have no defined order, and clients with skewed clocks produce out-of-order ids. For exact ordering, store and index a real date.
AdvancedDesign note: why there is no AUTO_INCREMENT

#mongosh and Compass

Added

mongosh is the MongoDB Shell: a command-line REPL built on Node.js, so everything you type is JavaScript, with MongoDB helpers on top (db, use, show, it). It replaced the legacy mongo shell, which was deprecated in MongoDB 5.0 and removed from the server packages in 6.0. If an old tutorial runs mongo, the command today is mongosh.

Compass is the official desktop GUI. It browses databases and documents, samples a collection to show its schema (field types and how often each appears), builds aggregation pipelines stage by stage with previews, draws explain() plans visually, manages indexes and validation rules, and has mongosh embedded in a panel. The Atlas web UI has the same kind of Data Explorer for cloud clusters.

Mental model: mongosh is the terminal (scriptable, precise, what you use in interviews and runbooks); Compass is the dashboard (great for exploring unfamiliar data and reading query plans). This site uses mongosh 2.13 for every example. Compass is a GUI and can't run in this sandbox, so nothing on this page shows Compass output.

mongosh
db
db.getName()
use ch16
show collections
db.customers.countDocuments()
Result
ch16
ch16
already on db ch16
customers
people
types
2
What’s happening
  1. db on its own prints the current database's name, ch16 (the shell was started with mongosh ch16; without a name it starts in test).
  2. db.getName() is the method form of the same thing — useful inside scripts.
  3. use ch16 switches databases and prints already on db ch16. With another name it would print switched to db <name>, even if that database doesn't exist yet (see the previous topic: it appears on first write).
  4. show collections lists the collections in the current database: customers, people and types, created by the earlier topics on this page.
  5. db.customers.countDocuments() runs a real query: 2 documents. show dbs (not run here, since it would list other chapters' databases) prints every database and its size on disk.
AdvancedIt's JavaScript: variables, loops and cursors

Because the shell is a JavaScript REPL, you can script it:

mongosh
for (let i = 1; i <= 25; i++) db.many.insertOne({ _id: i, square: i * i })

const cursor = db.many.find({ _id: { $gt: 20 } })
cursor.next()
cursor.hasNext()
cursor.toArray().length
db.many.find()
it
Result
{ acknowledged: true, insertedId: 25 }

{ _id: 21, square: 441 }
true
4
[
  { _id: 1, square: 1 },    { _id: 2, square: 4 },
  { _id: 3, square: 9 },    { _id: 4, square: 16 },
  { _id: 5, square: 25 },   { _id: 6, square: 36 },
  { _id: 7, square: 49 },   { _id: 8, square: 64 },
  { _id: 9, square: 81 },   { _id: 10, square: 100 },
  { _id: 11, square: 121 }, { _id: 12, square: 144 },
  { _id: 13, square: 169 }, { _id: 14, square: 196 },
  { _id: 15, square: 225 }, { _id: 16, square: 256 },
  { _id: 17, square: 289 }, { _id: 18, square: 324 },
  { _id: 19, square: 361 }, { _id: 20, square: 400 }
]
Type "it" for more
[
  { _id: 21, square: 441 },
  { _id: 22, square: 484 },
  { _id: 23, square: 529 },
  { _id: 24, square: 576 },
  { _id: 25, square: 625 }
]
What’s happening
  1. The for loop is ordinary JavaScript calling insertOne 25 times (an insertMany would be one round trip instead of 25; this is only to show the loop). Like any REPL, the shell prints the value of the last statement that ran, so you see only the 25th insert's result, insertedId: 25.
  2. find() returns a cursor, not an array. cursor.next() pulls the first document, { _id: 21, square: 441 }; hasNext() says more are waiting; toArray() drains the rest (4 documents, _id 22 to 25).
  3. Typed directly at the prompt, a cursor is printed in batches of 20, followed by Type "it" for more. Typing it iterates the same cursor and shows the remaining 5 documents.
  4. In application code there is no it: you iterate the cursor (for await in Node) or call toArray() on a bounded result.
  5. The batch size is configurable with config.set("displayBatchSize", 50), which mongosh saves in its config file.
AdvancedShell syntax vs Extended JSON in scripts

For automation, --eval runs one command and exits, and a .js file can be passed as an argument. The shell prints results in shell syntax (ObjectId('…'), ISODate('…'), unquoted keys), which is not JSON. Add --json to get Extended JSON instead:

Shell
docker compose exec -T mongo mongosh --quiet ch16 --eval 'db.types.findOne({}, { views: 1, createdAt: 1 })'
docker compose exec -T mongo mongosh --quiet --json=relaxed ch16 --eval 'db.types.findOne({}, { views: 1, createdAt: 1 })'
docker compose exec -T mongo mongosh --quiet --json=canonical ch16 --eval 'db.types.findOne({}, { views: 1 })'
Result
{
  _id: 1,
  views: Long('9007199254740993'),
  createdAt: ISODate('2025-01-05T09:00:00.000Z')
}
{
  "_id": 1,
  "views": 9007199254740992,
  "createdAt": {
    "$date": "2025-01-05T09:00:00Z"
  }
}
{
  "_id": {
    "$numberInt": "1"
  },
  "views": {
    "$numberLong": "9007199254740993"
  }
}
What’s happening
  1. The first command prints shell syntax: Long('9007199254740993') and ISODate(…). Great for reading, but jq can't parse it.
  2. --json=relaxed prints Relaxed Extended JSON: dates become { "$date": "…" } and numbers become plain JSON numbers. Look closely at views: 9007199254740992, not …993. Relaxed mode turned the 64-bit integer into a JSON number, and anything that parses JSON as doubles loses the last digit.
  3. --json=canonical keeps every type explicit — { "$numberLong": "9007199254740993" }, { "$numberInt": "1" } — so nothing is lost. Use canonical (or mongoexport/mongodump) when the output feeds another program.
  4. Inside the shell, EJSON.stringify(doc) and EJSON.parse(text) do the same conversions, and printjson(doc) prints one document from a script.
AdvancedLegacy helpers still run, with warnings
AdvancedEveryday mongosh conveniences
What does this session print?
mongosh
use shop_tmp
db.getName()
db.getCollectionNames()
Show answer

switched to db shop_tmp, then shop_tmp, then [].

What’s happening
  1. use shop_tmp only changes which database the shell's db variable points at. It succeeds even though shop_tmp doesn't exist, and prints switched to db shop_tmp.
  2. db.getName() reports the new current database, shop_tmp.
  3. getCollectionNames() returns an empty array: nothing has been written, so the database has no collections and won't appear in show dbs until the first insert.

#SQL to MongoDB: the vocabulary

Added

If you know SQL, most of MongoDB is a translation exercise. The concepts line up closely; the biggest differences are that joins are usually replaced by embedding (and $lookup when you do need one), GROUP BY-style work happens in the aggregation pipeline, and the schema lives in your application (optionally enforced by a validator) instead of in CREATE TABLE.

SQL (MySQL)MongoDB
databasedatabase
tablecollection
rowdocument
columnfield
primary key (any column)_id (always, automatically indexed)
AUTO_INCREMENTObjectId generated by the client
indexindex (single, compound, multikey, text, geo, wildcard…)
JOINembedded documents, or $lookup in an aggregation
foreign keya stored reference (customerId), not enforced
CREATE TABLE with columns and typesnothing required; optional $jsonSchema validator
SELECT … FROM t WHERE …db.t.find(filter, projection)
ORDER BY / LIMIT / OFFSET.sort() / .limit() / .skip()
COUNT(*)countDocuments(filter)
GROUP BY, HAVINGaggregate([{ $group }, { $match }])
INSERTinsertOne / insertMany
UPDATE … SETupdateOne / updateMany with $set, $inc…
DELETEdeleteOne / deleteMany
viewview (a stored aggregation pipeline)
transactiontransaction (session, startTransaction, commitTransaction)

The mental model: SQL describes rows in many tables; MongoDB describes documents in one collection. A SQL WHERE is a predicate written as text; a MongoDB filter is a document that looks like the documents it matches — { city: "Toronto" } means "documents whose city is Toronto".

The examples below use the site's sample data in both databases: the six customers and eight orders from the sample schema in MySQL, and the same rows loaded into MongoDB customers and orders collections (same ids, each order with its items embedded).

SQL
SELECT name, city
FROM customers
WHERE city = 'Toronto'
ORDER BY name;
Result
+-------+---------+
| name  | city    |
+-------+---------+
| Noah  | Toronto |
| Rohit | Toronto |
+-------+---------+
mongosh
db.customers.find(
  { city: "Toronto" },
  { _id: 0, name: 1, city: 1 }
).sort({ name: 1 })
Result
[
  { name: 'Noah', city: 'Toronto' },
  { name: 'Rohit', city: 'Toronto' }
]
What’s happening
  1. FROM customers becomes db.customers: the collection you call the method on.
  2. WHERE city = 'Toronto' becomes the filter document { city: "Toronto" }, the first argument to find. Equality is the default: a bare value means "equals".
  3. The SELECT name, city column list becomes the projection, the second argument: 1 includes a field. _id: 0 is needed because MongoDB returns _id unless you exclude it — there is no equivalent in SQL, where you get only the columns you list.
  4. ORDER BY name becomes .sort({ name: 1 }) (1 ascending, -1 descending), chained on the cursor.
  5. Both return Noah then Rohit. The SQL result is a table of rows; the MongoDB result is an array of documents.
AdvancedGROUP BY in MySQL
SQL
SELECT status, COUNT(*) AS orders
FROM orders
GROUP BY status
ORDER BY status;
Result
+-----------+--------+
| status    | orders |
+-----------+--------+
| pending   |      1 |
| paid      |      2 |
| shipped   |      4 |
| cancelled |      1 |
+-----------+--------+
AdvancedGROUP BY as a $group pipeline
mongosh
db.orders.aggregate([
  { $group: { _id: "$status", orders: { $sum: 1 } } },
  { $sort: { _id: 1 } }
])
Result
[
  { _id: 'cancelled', orders: 1 },
  { _id: 'paid', orders: 2 },
  { _id: 'pending', orders: 1 },
  { _id: 'shipped', orders: 4 }
]
What’s happening
  1. An aggregation is an array of stages; documents flow through them in order, like rows through the clauses of a SQL query.
  2. $group with _id: "$status" is GROUP BY status: the $ in "$status" means "the value of the field status", and the group key is always called _id in the output.
  3. orders: { $sum: 1 } adds 1 per document in the group — that is COUNT(*).
  4. $sort: { _id: 1 } is meant to be ORDER BY status, and the counts agree (cancelled 1, paid 2, pending 1, shipped 4) — but look at the order. MySQL sorted pending, paid, shipped, cancelled because status is an ENUM, and ENUMs sort by their position in the column definition. MongoDB stores plain strings and sorts them alphabetically. Chapter 19 covers the pipeline properly (How a pipeline works).
AdvancedA JOIN in MySQL
SQL
SELECT c.name, o.id AS order_id, o.status
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE c.name = 'Maya';
Result
+------+----------+---------+
| name | order_id | status  |
+------+----------+---------+
| Maya |        3 | shipped |
| Maya |        8 | shipped |
+------+----------+---------+
AdvancedThe same join with $lookup
mongosh
db.customers.aggregate([
  { $match: { name: "Maya" } },
  { $lookup: { from: "orders", localField: "_id", foreignField: "customerId", as: "orders" } },
  { $project: { _id: 0, name: 1, "orders._id": 1, "orders.status": 1 } }
])
Result
[
  {
    name: 'Maya',
    orders: [ { _id: 3, status: 'shipped' }, { _id: 8, status: 'shipped' } ]
  }
]
What’s happening
  1. $match is the WHERE: only Maya's document continues down the pipeline.
  2. $lookup is the join: for each incoming customer it finds orders documents whose customerId equals the customer's _id, and puts them into a new array field called orders (as my notes say, "the new field will be an array"). It is a left outer join: a customer with no orders would get orders: [].
  3. $project trims the output to the name and each order's id and status.
  4. SQL returns two flat rows with Maya repeated; MongoDB returns one document with an array of two orders. If the orders had been embedded in the customer document, as in the first topic of this chapter, no join would be needed at all — which is why MongoDB schemas embed by default.
AdvancedNULL in MySQL
SQL
SELECT name FROM customers WHERE city IS NULL;
Result
+-------+
| name  |
+-------+
| Sofia |
+-------+
Advancednull and missing in MongoDB
mongosh
db.customers.find({ city: null }, { _id: 0, name: 1 })
Result
[ { name: 'Sofia' } ]
What’s happening
  1. Sofia has no city. In MySQL that's a NULL column, and you must write IS NULL because city = NULL is never true (three-valued logic).
  2. In MongoDB she was stored with city: null, and the filter { city: null } is a plain equality that works.
  3. The surprise is that { city: null } also matches documents with no city field at all. MongoDB has two kinds of "nothing" — null and missing — and a null equality query matches both. Use { city: { $exists: false } } or { city: { $type: "null" } } to tell them apart ($exists and $type).
AdvancedPitfall: references are not foreign keys

#MongoDB versions and Atlas

Added

MongoDB ships a major release once a year (7.0, 8.0, 9.0) and each major is supported for five years. Between majors there are minor releases (8.2, 8.3) that bring features sooner; starting with 9.0, minor releases go only to Atlas clusters on auto-upgrade, while self-managed servers get one annual Feature Update release (9.5) between majors. Patch releases (9.0.4) are bug and security fixes within a series. This site runs MongoDB 9.0.4 (9.0.0 was released on 28 September 2026); 8.0 is still very common in production.

It comes in three forms:

  • Community Edition — free, self-managed. Since October 2018 it is licensed under the SSPL (Server Side Public License), which allows normal use but restricts offering MongoDB itself as a hosted service; that's why some clouds offer compatible look-alikes (Amazon DocumentDB, Azure Cosmos DB's MongoDB API) rather than MongoDB itself.
  • Enterprise Advanced — the commercial self-managed build: LDAP and Kerberos authentication, auditing, encryption at rest with external key management, the in-memory storage engine, and support.
  • Atlas — MongoDB's managed cloud service on AWS, Google Cloud and Azure: clusters with backups, monitoring and scaling handled for you, a free M0 tier, plus Atlas-only services such as Atlas Search, Atlas Vector Search, Data Federation and Online Archive. Atlas can't run in this sandbox, so Atlas features on this site are described, not demonstrated.

The mental model: Community is the engine, Enterprise is the engine with the security kit and a support contract, and Atlas is the engine as a service — you rent the car with a driver.

mongosh
db.version()
db.adminCommand({ getParameter: 1, featureCompatibilityVersion: 1 }).featureCompatibilityVersion
db.hello().setName
Result
9.0.4
{ version: '9.0' }
rs0
What’s happening
  1. db.version() is the server binary's version: 9.0.4.
  2. The feature compatibility version (FCV) is a separate setting: which on-disk and protocol features the deployment is allowed to use. Here it is 9.0. (The full adminCommand reply also carries ok: 1 and cluster-time fields; .featureCompatibilityVersion picks out the part we want.) After upgrading the binaries you run on old FCV for a while (so you can still downgrade), then raise it with db.adminCommand({ setFeatureCompatibilityVersion: "9.0", confirm: true }).
  3. db.hello().setName is rs0: this sandbox is a one-member replica set, not a standalone server. That matters because transactions and change streams need a replica set.
  4. Upgrades go one major at a time: to reach 9.0 you must be on 8.0 or 8.3 first, so a 7.0 cluster upgrades to 8.0, raises its FCV, and only then moves to 9.0.
AdvancedVersions that changed how MongoDB is used

The versions you'll hear about in interviews, and what they changed:

VersionYearWhat it brought (the parts people still mention)
3.22015WiredTiger becomes the default storage engine (document-level concurrency, compression)
3.62017$expr, $jsonSchema validation, arrayFilters and $[], change streams
4.02018Multi-document ACID transactions on replica sets
4.22019Transactions across shards, updates with aggregation pipelines, wildcard indexes, client-side field-level encryption, $merge
4.42020Refinable shard keys, hedged reads, $unionWith
5.02021Time-series collections, Stable API, live resharding; legacy mongo shell deprecated
6.02022Legacy shell removed (mongosh only); big sorts and groups spill to disk by default; Queryable Encryption preview
7.02023Queryable Encryption generally available, compound wildcard indexes
8.02024Large performance work; updateOne/replaceOne gain a sort option; Queryable Encryption range queries
9.02026Per-operation memory limit (1 GB or 20% of RAM), $where/$function/$accumulator un-deprecated on a WebAssembly engine, Queryable Encryption prefix/suffix/substring queries GA, constraint validation level
AdvancedOlder behaviour you'll still meet

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.