Back to Database Instructions
v 8.3
What is MongoDB?

MongoDB is a document database server. Instead of tables with fixed columns, it stores JSON-like documents in collections, and each document can have its own shape, including arrays and nested objects. You query it from mongosh, the MongoDB Shell, with JavaScript-style method calls such as db.users.find({ city: "London" }). This guide installs the free Community edition, version 8.3.

Two programs, one install: mongod is the server, and mongosh is the shell you type into. On Linux and macOS one package gives you both; on Windows the shell is a separate install.
Select your OS:
Installing MongoDB 8.3 on Windows Windows
1

Download the installer

MongoDB 8.3 supports 64-bit Windows 11 and Windows Server 2022. On the MongoDB Download Center, choose version 8.3, platform Windows, and package msi, then run it.

2

Run the installer

Choose Complete, then keep these defaults on the next screens:

  • Install MongoD as a Service: leave it ticked, running as Network Service. The server then starts with Windows.
  • Data and log directories: the defaults under C:\Program Files\MongoDB\Server\8.3\ are fine.
  • Install MongoDB Compass: optional. Compass is a graphical browser for your data.
3

Install mongosh

The server installer doesn't include the shell. winget is the quickest way to add it:

winget install MongoDB.Shell

Then close PowerShell and open a new window so mongosh is on your PATH.

4

Check the service is running

Open Services, find MongoDB, and check that it says Running. From an Administrator prompt you can also start and stop it:

net start MongoDB
5

Connect

mongosh
Expected Output
$ mongosh Current Mongosh Log ID: 6ab543db8e2eb6b57eee51fc Connecting to: mongodb://127.0.0.1:27017/?directConnection=true&serverSelectionTimeoutMS=2000&appName=mongosh+2.12.0 Using MongoDB: 8.3.11 Using Mongosh: 2.12.0 test>
Not using the wizard? winget install MongoDB.Server installs the server too. Afterwards, check that a MongoDB service exists in Services and is running.
Installing MongoDB 8.3 on macOS macOS
1

Add MongoDB's Homebrew tap

MongoDB isn't in Homebrew's main catalogue because of its license, so add MongoDB's own tap and mark it as trusted (once per machine). MongoDB 8.3 needs macOS 14 or later.

brew tap mongodb/brew brew trust mongodb/brew
2

Install

This installs the server, mongosh, and the database tools:

brew update brew install mongodb-community@8.3
3

Start the server

This starts it now and at every login:

brew services start mongodb-community@8.3

Data goes in /opt/homebrew/var/mongodb on Apple Silicon (/usr/local/var/mongodb on Intel), and the config is mongod.conf in the matching etc folder.

4

Connect

mongosh
Expected Output
$ mongosh Current Mongosh Log ID: 6ab543db8e2eb6b57eee51fc Connecting to: mongodb://127.0.0.1:27017/?directConnection=true&serverSelectionTimeoutMS=2000&appName=mongosh+2.12.0 Using MongoDB: 8.3.11 Using Mongosh: 2.12.0 test>
If macOS blocks mongod, open System Settings → Privacy & Security, click Open Anyway next to the message about mongod, and run the brew services start line again.
Installing MongoDB 8.3 on Linux Linux
1

Add the signing key (Ubuntu)

Ubuntu doesn't package MongoDB, so add MongoDB's repository. MongoDB 8.3 supports Ubuntu 24.04, 22.04, and 20.04 on x86_64 and ARM64. The 8.3 packages are signed with the 8.0 key:

sudo apt install -y gnupg curl curl -fsSL https://pgp.mongodb.com/server-8.0.asc | \ sudo gpg -o /usr/share/keyrings/mongodb-server-8.0.gpg --dearmor
2

Add the repository

This is for 24.04 (noble); on 22.04 change noble to jammy:

echo "deb [ arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-8.0.gpg ] https://repo.mongodb.org/apt/ubuntu noble/mongodb-org/8.3 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-8.3.list
3

Install

mongodb-org pulls in the server, mongosh, and the database tools:

sudo apt update sudo apt install -y mongodb-org
4

Start the server

Unlike PostgreSQL, the package doesn't start the server. This starts it now and at every boot:

sudo systemctl enable --now mongod systemctl is-active mongod
Expected Output
active
5

Connect

mongosh
Expected Output
$ mongosh Current Mongosh Log ID: 6ab543db8e2eb6b57eee51fc Connecting to: mongodb://127.0.0.1:27017/?directConnection=true&serverSelectionTimeoutMS=2000&appName=mongosh+2.12.0 Using MongoDB: 8.3.11 Using Mongosh: 2.12.0 test>
Debian, RHEL, Rocky, Amazon Linux: MongoDB publishes the same kind of repository; the Linux install page has the lines for each. Fedora and Arch aren't supported, so run it in Docker there: docker run -d -p 27017:27017 mongo:8.
If the first connection fails

Most first-day problems are one of these. ECONNREFUSED always means the server isn't running; it's never a problem with the shell.

What you seeWhat it meansFix
MongoNetworkError: connect ECONNREFUSED 127.0.0.1:27017The shell works, but no server is listening.Linux: sudo systemctl start mongod. macOS: brew services start mongodb-community@8.3. Windows: start the MongoDB service.
mongosh: command not foundThe shell isn't installed or isn't on your PATH.Windows: winget install MongoDB.Shell and open a new window. Elsewhere, reinstall and open a new terminal.
mongod fails to startUsually a permissions problem on the data directory, or the port is already in use.Read the last lines of /var/log/mongodb/mongod.log (Linux); the reason is spelled out there.
Access control is not enabled for the databaseA startup warning, not an error: there are no users or passwords yet.Fine while the server only listens on 127.0.0.1. The Advanced lesson Users and Access Control shows how to turn it on.
macOS blocks mongodGatekeeper didn't recognise the developer.System Settings → Privacy & Security → Open Anyway, then start the service again.
Running inside WSLMongoDB doesn't support the server inside Windows Subsystem for Linux.Install the server on the Windows side and run mongosh from PowerShell.
Beginner
Your first steps: connect, meet databases, collections, and documents, then insert, find, update, and delete
Step 1 — Connecting with mongosh

mongosh is the MongoDB Shell. Type mongosh in a terminal and it connects to the server on your own machine and gives you a prompt.

A MongoDB install has a server, mongod, which runs in the background, owns a data directory, and listens on port 27017, and a client that connects to it. mongosh is the interactive client. It's a full JavaScript environment, so everything you type is JavaScript: queries are method calls like db.users.find(), and you can use variables, loops, and functions. Your own programs connect the same way through an official driver for their language.

A connection is described by a connection string, a URL such as mongodb://localhost:27017/shop: the scheme, the host and port, and optionally the database to start in. Plain mongosh means mongodb://127.0.0.1:27017/test. The prompt shows the current database, test> to begin with. test is just a default name and doesn't exist until you write to it. A remote or password-protected server adds a username and options to the same URL, for example mongodb://app:secret@db.example.com:27017/shop?authSource=admin.

mongod
The MongoDB server process; it owns the data files and listens on port 27017.
mongosh
The MongoDB Shell, an interactive JavaScript client for talking to a server.
Connection string
A URL like mongodb://host:27017/db that tells a client where and how to connect.
# Connect to the server on this machine, port 27017, database "test" mongosh # Name the server and starting database explicitly mongosh "mongodb://localhost:27017/shop" # Run one expression and exit, without opening the shell mongosh --quiet --eval "db.version()" # Leave the shell (Ctrl+D also works) exit
Terminal Output
$ mongosh Current Mongosh Log ID: 6ab543db8e2eb6b57eee51fc Connecting to: mongodb://127.0.0.1:27017/?directConnection=true&serverSelectionTimeoutMS=2000&appName=mongosh+2.12.0 Using MongoDB: 8.3.11 Using Mongosh: 2.12.0 test> db.version() 8.3.11 test> db.getName() test test> 1 + 1 2
It's JavaScript. 1 + 1 works, and so does const n = 5. That's what makes the shell handy for quick scripts.
Step 2 — Essential Shell Helpers

A few commands in mongosh aren't JavaScript: show, use, it, and exit are shortcuts built into the shell.

Most of what you type is JavaScript, but a handful of shell helpers are special words the shell understands directly. show dbs lists databases with their size on disk, use shop switches to a database, and show collections lists the collections in the current one. db is a variable that always holds the current database, so db on its own prints its name and db.users refers to the users collection in it. There's no need to create either first: MongoDB creates databases and collections the first time you write to them.

The shell has built-in help at every level. help lists the helpers, db.help() lists database methods, and db.users.help() lists collection methods. When a query returns more than 20 documents, mongosh prints the first batch of 20 and waits. Type it (short for iterate) to see the next batch. The three databases you'll see in show dbs, admin, config, and local, belong to MongoDB itself; leave them alone.

Shell helper
A mongosh shortcut that is not JavaScript, such as show dbs or use.
db
The shell variable that holds the current database.
Batch
The 20 documents mongosh prints at a time; type it for the next batch.
show dbs // list databases and their size on disk use shop // switch to (or start using) the shop database db // print the current database's name show collections // list collections in the current database help // list shell helpers db.users.help() // list methods you can call on a collection it // show the next batch of results cls // clear the screen exit // quit
Terminal Output
test> show dbs admin 40.00 KiB config 60.00 KiB local 40.00 KiB test> use shop switched to db shop shop> db shop shop> show collections
show collections printed nothing because shop is still empty. It appears in show dbs after the first write, in the next step.
Step 3 — Databases, Collections, and Documents

A MongoDB server holds databases, a database holds collections, and a collection holds documents: JSON-like records with fields and values.

The data is nested three levels deep. A database is a separate container, usually one per application. Inside it, a collection plays the part of a table, and a document plays the part of a row. A document is a set of field–value pairs written like a JavaScript object: { name: "Alice", age: 34 }. Unlike rows in a table, two documents in the same collection don't need the same fields, and a value can itself be an array or another document. So a user can carry a list of interests or a nested address without extra tables.

Every document has an _id field that uniquely identifies it in its collection. If you don't supply one, MongoDB generates an ObjectId: a 12-byte value that's unique across machines and starts with the time it was created. Documents are stored as BSON, a binary form of JSON with more types: separate integer and floating-point numbers, a real date type, Decimal128 for money, and ObjectId itself. The shell shows these as ISODate(...), ObjectId(...), and so on. db.createCollection() makes an empty collection, which is only needed when you want to set options such as validation (covered at the Advanced level).

Collection
A group of documents in a database; MongoDB's equivalent of a table.
Document
A record made of field-value pairs; MongoDB's equivalent of a row.
ObjectId
The default _id value: 12 bytes, unique, and starting with its creation time.
use shop // The first insert creates the database and the collection db.users.insertOne({ name: "Alice", email: "alice@example.com", age: 34, city: "London", interests: ["chess", "hiking"], joined: new Date("2024-03-15") }) show collections db.users.findOne() // An explicit, empty collection db.createCollection("logs")
Terminal Output
test> use shop switched to db shop shop> db.users.insertOne({ | name: "Alice", | email: "alice@example.com", | age: 34, | city: "London", | interests: ["chess", "hiking"], | joined: new Date("2024-03-15") | }) { acknowledged: true, insertedId: ObjectId('6ab5d4d6d304c0df9ebb07ee') } shop> show collections users shop> db.users.findOne() { _id: ObjectId('6ab5d4d6d304c0df9ebb07ee'), name: 'Alice', email: 'alice@example.com', age: 34, city: 'London', interests: [ 'chess', 'hiking' ], joined: ISODate('2024-03-15T00:00:00.000Z') } shop> db.createCollection("logs") { ok: 1 } shop> show collections logs users
Your ObjectIds will be different. They're generated from the current time and a random value, so no two are ever the same.
Step 4 — Insert Documents

insertOne adds one document and insertMany adds an array of them. MongoDB fills in _id for any document that doesn't have one.

insertOne(doc) stores a single document and returns its insertedId. insertMany([doc, doc, ...]) stores several in one round trip, which is much faster than a loop of insertOne calls, and returns every generated id. The documents don't need identical fields: notice below that Dave has no email at all. In MongoDB a missing field and a field set to null are different things, and queries can tell them apart.

The one rule every document must obey is a unique _id. Insert a document whose _id already exists and the server rejects it with a duplicate key error (code 11000). By default insertMany is ordered: it stops at the first error, and documents after the failure are not inserted. Pass { ordered: false } to insert everything that's valid and report the failures at the end. Either way, documents inserted before the error stay inserted, because each document insert is atomic on its own.

insertMany
Inserts an array of documents in one call; ordered by default.
Duplicate key error
Error 11000: a document reused a value that must be unique, such as _id.
Ordered insert
An insertMany that stops at the first error; ordered: false continues past it.
db.users.insertMany([ { name: "Bob", email: "bob@example.com", age: 27, city: "Paris", interests: ["cycling"], joined: new Date("2024-06-01") }, { name: "Carol", email: "carol@example.com", age: 45, city: "London", interests: ["chess", "cooking"], joined: new Date("2023-11-20") }, { name: "Dave", age: 19, city: "Berlin", interests: [], joined: new Date("2025-01-09") }, { name: "Erin", email: "erin@example.com", age: 52, city: "Paris", interests: ["hiking", "photography"], joined: new Date("2022-08-30") } ]) // You can choose your own _id, but it must be unique db.logs.insertOne({ _id: 1, msg: "first" }) db.logs.insertOne({ _id: 1, msg: "again" })
Terminal Output
shop> db.users.insertMany([ | { name: "Bob", email: "bob@example.com", age: 27, city: "Paris", interests: ["cycling"], | joined: new Date("2024-06-01") }, | { name: "Carol", email: "carol@example.com", age: 45, city: "London", interests: ["chess", "cooking"], | joined: new Date("2023-11-20") }, | { name: "Dave", age: 19, city: "Berlin", interests: [], | joined: new Date("2025-01-09") }, | { name: "Erin", email: "erin@example.com", age: 52, city: "Paris", interests: ["hiking", "photography"], | joined: new Date("2022-08-30") } | ]) { acknowledged: true, insertedIds: { '0': ObjectId('6ab5d4dc8834f9acbf1214b0'), '1': ObjectId('6ab5d4dc8834f9acbf1214b1'), '2': ObjectId('6ab5d4dc8834f9acbf1214b2'), '3': ObjectId('6ab5d4dc8834f9acbf1214b3') } } shop> db.logs.insertOne({ _id: 1, msg: "first" }) { acknowledged: true, insertedId: 1 } shop> db.logs.insertOne({ _id: 1, msg: "again" }) Uncaught MongoServerError: E11000 duplicate key error collection: shop.logs index: _id_ dup key: { _id: 1 } shop> db.users.countDocuments() 5
Step 5 — Find Documents

find(filter, projection) returns the matching documents. Chain .sort(), .limit(), and .skip() to order and page through them.

find() takes up to two documents. The first is the filter, which describes what to match. {} matches everything, and { city: "London" } matches documents whose city equals London. The second is the projection, which picks the fields to return: { name: 1, age: 1 } includes just those (plus _id unless you add _id: 0), and { interests: 0 } returns everything except that field. findOne() returns the first match as a single document, not a list.

find() returns a cursor, a pointer into the results that fetches documents in batches as you read them, which is why a huge collection doesn't flood your memory. Cursor methods shape the results: .sort({ age: -1 }) sorts descending (1 is ascending), .limit(2) caps the count, and .skip(2) skips some first, which is how pages are built. Without .sort(), documents come back in whatever order is cheapest, so never rely on it. countDocuments(filter) counts matches without returning them.

Filter
A document describing which documents to match; {} matches all.
Projection
A document choosing which fields to return: 1 includes, 0 excludes.
Cursor
A handle on query results that fetches them in batches as you iterate.
db.users.find({}, { _id: 0, name: 1, city: 1 }) db.users.find({ city: "London" }, { _id: 0, name: 1, age: 1 }) // Oldest two db.users.find({}, { _id: 0, name: 1, age: 1 }).sort({ age: -1 }).limit(2) // Page 2 with 2 per page db.users.find({}, { _id: 0, name: 1 }).sort({ name: 1 }).skip(2).limit(2) db.users.findOne({ name: "Dave" }, { _id: 0 }) db.users.countDocuments({ city: "Paris" })
Terminal Output
shop> db.users.find({}, { _id: 0, name: 1, city: 1 }) [ { name: 'Alice', city: 'London' }, { name: 'Bob', city: 'Paris' }, { name: 'Carol', city: 'London' }, { name: 'Dave', city: 'Berlin' }, { name: 'Erin', city: 'Paris' } ] shop> db.users.find({ city: "London" }, { _id: 0, name: 1, age: 1 }) [ { name: 'Alice', age: 34 }, { name: 'Carol', age: 45 } ] shop> db.users.find({}, { _id: 0, name: 1, age: 1 }).sort({ age: -1 }).limit(2) [ { name: 'Erin', age: 52 }, { name: 'Carol', age: 45 } ] shop> db.users.find({}, { _id: 0, name: 1 }).sort({ name: 1 }).skip(2).limit(2) [ { name: 'Carol' }, { name: 'Dave' } ] shop> db.users.findOne({ name: "Dave" }, { _id: 0 }) { name: 'Dave', age: 19, city: 'Berlin', interests: [], joined: ISODate('2025-01-09T00:00:00.000Z') } shop> db.users.countDocuments({ city: "Paris" }) 2
Step 6 — Update and Delete

updateOne and updateMany change documents with update operators such as $set. deleteOne and deleteMany remove them.

An update takes a filter and an update document made of operators. $set sets fields, $inc adds to a number, and $unset removes a field. The operator matters: { $set: { city: "Madrid" } } changes one field and leaves the rest alone. updateOne changes the first match and updateMany changes every match. The result reports matchedCount (how many the filter found) and modifiedCount (how many actually changed). If they differ, the value was already what you set. replaceOne swaps a whole document for a new one, keeping only its _id.

deleteOne removes the first matching document and deleteMany removes them all. The dangerous one is deleteMany({}): an empty filter matches everything, so it empties the collection without asking. db.collection.drop() removes the collection itself, including its indexes. As with SQL, the safe habit is to run the filter with find() first and look at what it matches, then turn it into an update or delete.

Update operator
A $-prefixed instruction such as $set, $inc, or $unset.
matchedCount / modifiedCount
How many documents the filter found vs how many actually changed.
drop()
Removes a whole collection, including its documents and indexes.
db.users.updateOne({ name: "Bob" }, { $set: { city: "Madrid" } }) db.users.updateOne({ name: "Erin" }, { $inc: { age: 1 } }) // Every Paris user gets a flag db.users.updateMany({ city: "Paris" }, { $set: { region: "EU-West" } }) // Remove a field from every document that has it db.users.updateMany({}, { $unset: { region: "" } }) db.logs.deleteOne({ _id: 1 }) db.logs.drop()
Terminal Output
shop> db.users.updateOne({ name: "Bob" }, { $set: { city: "Madrid" } }) { acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 } shop> db.users.updateOne({ name: "Erin" }, { $inc: { age: 1 } }) { acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 } shop> db.users.updateMany({ city: "Paris" }, { $set: { region: "EU-West" } }) { acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 } shop> db.users.updateMany({}, { $unset: { region: "" } }) { acknowledged: true, insertedId: null, matchedCount: 5, modifiedCount: 1, upsertedCount: 0 } shop> db.logs.deleteOne({ _id: 1 }) { acknowledged: true, deletedCount: 1 } shop> db.logs.drop() true shop> db.users.find({}, { _id: 0, name: 1, age: 1, city: 1 }) [ { name: 'Alice', age: 34, city: 'London' }, { name: 'Bob', age: 27, city: 'Madrid' }, { name: 'Carol', age: 45, city: 'London' }, { name: 'Dave', age: 19, city: 'Berlin' }, { name: 'Erin', age: 53, city: 'Paris' } ]
deleteMany({}) empties the whole collection. An empty filter matches every document. Check your filter with find() first.
Intermediate
Query operators, arrays and embedded documents, upserts, the aggregation pipeline, and joins
Query Operators

Comparison, logical, and element operators let a filter say greater than, one of, either, and has this field.

Beyond simple equality, a filter uses query operators, which start with a dollar sign and sit inside the field's value. Comparison: $gt, $gte, $lt, $lte, and $ne (not equal), so { age: { $gte: 30, $lt: 50 } } is a range. Set membership: $in and $nin match any, or none, of a list. Putting several fields in one filter means and; for or, use $or with an array of conditions: { $or: [ { city: "Rome" }, { age: { $lt: 20 } } ] }.

Two situations need care. First, a missing field and a null field are different: { email: null } matches both documents where email is null and documents with no email field, while { email: { $exists: false } } matches only the missing ones. Second, text patterns use regular expressions: { name: /^c/i } means starts with c, ignoring case. A regex anchored with ^ can use an index; one that isn't has to scan every value. For real word search, MongoDB has text indexes, and Atlas has a full search engine.

Query operator
A $-prefixed condition inside a filter, such as $gt, $in, or $or.
$exists
Matches documents that have (true) or lack (false) a field.
Regular expression
A text pattern such as /^c/i used to match string values.
db.users.find({ age: { $gte: 30, $lt: 50 } }, { _id: 0, name: 1, age: 1 }) db.users.find({ city: { $in: ["Paris", "Madrid"] } }, { _id: 0, name: 1, city: 1 }) db.users.find({ $or: [{ city: "Berlin" }, { age: { $gt: 50 } }] }, { _id: 0, name: 1 }) // Missing field vs null db.users.find({ email: { $exists: false } }, { _id: 0, name: 1 }) // Names starting with "c", any case db.users.find({ name: /^c/i }, { _id: 0, name: 1 })
Terminal Output
shop> db.users.find({ age: { $gte: 30, $lt: 50 } }, { _id: 0, name: 1, age: 1 }) [ { name: 'Alice', age: 34 }, { name: 'Carol', age: 45 } ] shop> db.users.find({ city: { $in: ["Paris", "Madrid"] } }, { _id: 0, name: 1, city: 1 }) [ { name: 'Bob', city: 'Madrid' }, { name: 'Erin', city: 'Paris' } ] shop> db.users.find({ $or: [{ city: "Berlin" }, { age: { $gt: 50 } }] }, { _id: 0, name: 1 }) [ { name: 'Dave' }, { name: 'Erin' } ] shop> db.users.find({ email: { $exists: false } }, { _id: 0, name: 1 }) [ { name: 'Dave' } ] shop> db.users.find({ name: /^c/i }, { _id: 0, name: 1 }) [ { name: 'Carol' } ]
Arrays and Embedded Documents

Documents can hold arrays and nested documents. Dot notation reaches inside them, and $push, $addToSet, and $pull edit arrays in place.

Arrays are first-class. A filter on an array field matches if any element matches, so { interests: "chess" } finds everyone with chess among their interests. $all requires every listed value, $size matches an exact length, and $elemMatch requires one element to meet several conditions at once, which matters for arrays of documents. Embedded documents are reached with dot notation: { "address.city": "London" } looks inside the address field. The quotes are required whenever a field name contains a dot.

Arrays have their own update operators, so you never have to read, modify, and write back the whole list. $push appends a value, $addToSet appends only if the value isn't already there, $pull removes every matching value, and $pop removes the first or last element. $push with $each adds several values at once. These operators are atomic on a single document, so two users updating the same list at the same moment can't overwrite each other's changes.

Dot notation
A path like "address.city" that reaches inside embedded documents and arrays.
$elemMatch
Requires a single array element to satisfy all of several conditions.
$addToSet
Adds a value to an array only if it is not already present.
// Any element matches db.users.find({ interests: "chess" }, { _id: 0, name: 1 }) // Must contain both db.users.find({ interests: { $all: ["chess", "cooking"] } }, { _id: 0, name: 1 }) // Empty arrays db.users.find({ interests: { $size: 0 } }, { _id: 0, name: 1 }) // Edit arrays in place db.users.updateOne({ name: "Dave" }, { $push: { interests: "guitar" } }) db.users.updateOne({ name: "Dave" }, { $addToSet: { interests: "guitar" } }) db.users.updateOne({ name: "Alice" }, { $pull: { interests: "hiking" } }) // An embedded document, queried with dot notation db.users.updateOne({ name: "Carol" }, { $set: { address: { street: "1 High St", city: "London", zip: "N1 9GU" } } }) db.users.find({ "address.city": "London" }, { _id: 0, name: 1, address: 1 })
Terminal Output
shop> db.users.find({ interests: "chess" }, { _id: 0, name: 1 }) [ { name: 'Alice' }, { name: 'Carol' } ] shop> db.users.find({ interests: { $all: ["chess", "cooking"] } }, { _id: 0, name: 1 }) [ { name: 'Carol' } ] shop> db.users.find({ interests: { $size: 0 } }, { _id: 0, name: 1 }) [ { name: 'Dave' } ] shop> db.users.updateOne({ name: "Dave" }, { $push: { interests: "guitar" } }) { acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 } shop> db.users.updateOne({ name: "Dave" }, { $addToSet: { interests: "guitar" } }) { acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 0, upsertedCount: 0 } shop> db.users.updateOne({ name: "Alice" }, { $pull: { interests: "hiking" } }) { acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 } shop> db.users.updateOne({ name: "Carol" }, { $set: { address: { street: "1 High St", city: "London", zip: "N1 9GU" } } }) { acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 } shop> db.users.find({ "address.city": "London" }, { _id: 0, name: 1, address: 1 }) [ { name: 'Carol', address: { street: '1 High St', city: 'London', zip: 'N1 9GU' } } ]
Look at the second update: modifiedCount: 0. $addToSet saw that guitar was already in Dave's list and left it alone.
Upserts and More Update Operators

An upsert updates a matching document or inserts a new one if nothing matches. A few more operators cover counters, renames, and read-and-modify in one step.

Pass { upsert: true } to updateOne and it becomes insert-or-update. If the filter matches, the update is applied as usual. If it doesn't, MongoDB builds a new document from the filter's equality fields plus the update, and reports its id as upsertedId. $setOnInsert sets fields only when an upsert inserts, which is perfect for values like a creation date that should never change afterwards. Upserts are the natural way to keep a counter or a last seen record without first checking whether it exists.

Other useful operators: $mul multiplies a number, $min and $max only change a value if the new one is lower or higher, $rename renames a field, and $currentDate stores the server's current time. findOneAndUpdate updates a document and returns it in one atomic step, before the change by default or after it with { returnDocument: "after" }. That's how you hand out the next number in a sequence without two clients ever getting the same one.

Upsert
An update that inserts a new document when nothing matches ({ upsert: true }).
$setOnInsert
Sets fields only when an upsert creates a new document.
findOneAndUpdate
Updates one document and returns it atomically, before or after the change.
// First call inserts, second call updates db.visits.updateOne( { page: "/home" }, { $inc: { count: 1 }, $setOnInsert: { firstSeen: new Date("2025-01-01") } }, { upsert: true } ) db.visits.updateOne( { page: "/home" }, { $inc: { count: 1 }, $setOnInsert: { firstSeen: new Date("2025-01-01") } }, { upsert: true } ) db.visits.find({}, { _id: 0 }) // Update and get the new document back in one step db.counters.insertOne({ _id: "orderNo", seq: 1000 }) db.counters.findOneAndUpdate({ _id: "orderNo" }, { $inc: { seq: 1 } }, { returnDocument: "after" }) db.users.updateOne({ name: "Erin" }, { $rename: { city: "town" } }) db.users.updateOne({ name: "Erin" }, { $rename: { town: "city" } })
Terminal Output
shop> db.visits.updateOne( | { page: "/home" }, | { $inc: { count: 1 }, $setOnInsert: { firstSeen: new Date("2025-01-01") } }, | { upsert: true } | ) { acknowledged: true, insertedId: ObjectId('6ab5d4eef35faf305585e4b8'), matchedCount: 0, modifiedCount: 0, upsertedCount: 1 } shop> db.visits.updateOne( | { page: "/home" }, | { $inc: { count: 1 }, $setOnInsert: { firstSeen: new Date("2025-01-01") } }, | { upsert: true } | ) { acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 } shop> db.visits.find({}, { _id: 0 }) [ { page: '/home', count: 2, firstSeen: ISODate('2025-01-01T00:00:00.000Z') } ] shop> db.counters.insertOne({ _id: "orderNo", seq: 1000 }) { acknowledged: true, insertedId: 'orderNo' } shop> db.counters.findOneAndUpdate({ _id: "orderNo" }, { $inc: { seq: 1 } }, { returnDocument: "after" }) { _id: 'orderNo', seq: 1001 } shop> db.users.updateOne({ name: "Erin" }, { $rename: { city: "town" } }) { acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 } shop> db.users.updateOne({ name: "Erin" }, { $rename: { town: "city" } }) { acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 }
The Aggregation Pipeline

aggregate() runs documents through a pipeline of stages: filter, group, sort, and reshape, in that order or any other.

For anything beyond fetching documents (totals, averages, counts per category), MongoDB uses the aggregation pipeline. aggregate() takes an array of stages, and documents flow through them in order, each stage transforming what it receives. $match filters (it takes the same filter language as find), $group combines documents that share a key, $sort orders them, $project reshapes each document, and $count counts what's left. Put $match as early as possible, since everything after it then has less work to do, and it can use an index.

$group is the heart of most reports. Its _id is the grouping key: "$status" means one group per distinct status, and null means one group for everything. The other fields are accumulators that compute a value per group: { $sum: "$amount" } adds up amounts, { $sum: 1 } counts documents, and $avg, $min, $max, and $push do what they say. A dollar sign in front of a field name, as in "$amount", means the value of that field, not the text "amount".

Pipeline
An array of stages that documents pass through in order.
$group
Combines documents sharing a key (_id) and computes accumulators per group.
Accumulator
A per-group calculation such as $sum, $avg, $min, $max, or $push.
// Orders reference users by _id; look the ids up in the shell const alice = db.users.findOne({ name: "Alice" })._id const bob = db.users.findOne({ name: "Bob" })._id const carol = db.users.findOne({ name: "Carol" })._id const erin = db.users.findOne({ name: "Erin" })._id db.orders.insertMany([ { userId: alice, product: "Keyboard", amount: 49.99, status: "delivered", orderedAt: new Date("2025-01-10") }, { userId: alice, product: "Monitor", amount: 189.00, status: "shipped", orderedAt: new Date("2025-02-02") }, { userId: bob, product: "Mouse", amount: 19.50, status: "delivered", orderedAt: new Date("2025-01-15") }, { userId: carol, product: "Laptop", amount: 999.00, status: "pending", orderedAt: new Date("2025-02-20") }, { userId: carol, product: "Mouse", amount: 19.50, status: "delivered", orderedAt: new Date("2025-02-21") }, { userId: erin, product: "Monitor", amount: 189.00, status: "delivered", orderedAt: new Date("2025-01-28") } ]) // Revenue and order count per status, biggest first db.orders.aggregate([ { $group: { _id: "$status", orders: { $sum: 1 }, revenue: { $sum: "$amount" } } }, { $sort: { revenue: -1 } } ]) // Delivered orders only, one summary for all of them db.orders.aggregate([ { $match: { status: "delivered" } }, { $group: { _id: null, orders: { $sum: 1 }, average: { $avg: "$amount" } } }, { $project: { _id: 0, orders: 1, average: { $round: ["$average", 2] } } } ])
Terminal Output
shop> const alice = db.users.findOne({ name: "Alice" })._id shop> const bob = db.users.findOne({ name: "Bob" })._id shop> const carol = db.users.findOne({ name: "Carol" })._id shop> const erin = db.users.findOne({ name: "Erin" })._id shop> db.orders.insertMany([ | { userId: alice, product: "Keyboard", amount: 49.99, status: "delivered", orderedAt: new Date("2025-01-10") }, | { userId: alice, product: "Monitor", amount: 189.00, status: "shipped", orderedAt: new Date("2025-02-02") }, | { userId: bob, product: "Mouse", amount: 19.50, status: "delivered", orderedAt: new Date("2025-01-15") }, | { userId: carol, product: "Laptop", amount: 999.00, status: "pending", orderedAt: new Date("2025-02-20") }, | { userId: carol, product: "Mouse", amount: 19.50, status: "delivered", orderedAt: new Date("2025-02-21") }, | { userId: erin, product: "Monitor", amount: 189.00, status: "delivered", orderedAt: new Date("2025-01-28") } | ]) { acknowledged: true, insertedIds: { '0': ObjectId('6ab5d4f6a947ce3002680837'), '1': ObjectId('6ab5d4f6a947ce3002680838'), '2': ObjectId('6ab5d4f6a947ce3002680839'), '3': ObjectId('6ab5d4f6a947ce300268083a'), '4': ObjectId('6ab5d4f6a947ce300268083b'), '5': ObjectId('6ab5d4f6a947ce300268083c') } } shop> db.orders.aggregate([ | { $group: { _id: "$status", orders: { $sum: 1 }, revenue: { $sum: "$amount" } } }, | { $sort: { revenue: -1 } } | ]) [ { _id: 'pending', orders: 1, revenue: 999 }, { _id: 'delivered', orders: 4, revenue: 277.99 }, { _id: 'shipped', orders: 1, revenue: 189 } ] shop> db.orders.aggregate([ | { $match: { status: "delivered" } }, | { $group: { _id: null, orders: { $sum: 1 }, average: { $avg: "$amount" } } }, | { $project: { _id: 0, orders: 1, average: { $round: ["$average", 2] } } } | ]) [ { orders: 4, average: 69.5 } ]
Joining with $lookup and $unwind

$lookup pulls matching documents from another collection into each document. $unwind turns an array into one document per element.

MongoDB encourages you to embed data that's read together, but separate collections that refer to each other by _id are common too, like the orders above, each holding a userId. The $lookup stage is MongoDB's join. For each input document it finds the documents in another collection whose field matches (localField equal to foreignField), and puts them in a new array field (as). It's always an array, even when there's exactly one match, and it's empty when there's none, like a SQL left join.

$unwind deconstructs an array: a document with a three-element array becomes three documents, one per element. After a $lookup that finds one user per order, $unwind: "$user" turns the one-element array into a plain embedded document, so later stages can use "$user.name". Unwinding is also how you count or group by the elements of an array, for example how many users are interested in each hobby. By default $unwind drops documents whose array is empty; preserveNullAndEmptyArrays: true keeps them.

$lookup
A pipeline stage that joins in matching documents from another collection as an array.
$unwind
Turns each element of an array into its own document.
Reference
Storing another document's _id in a field, to be joined with $lookup.
// Each order with its customer's name db.orders.aggregate([ { $lookup: { from: "users", localField: "userId", foreignField: "_id", as: "user" } }, { $unwind: "$user" }, { $project: { _id: 0, product: 1, amount: 1, customer: "$user.name" } } ]) // Spend per customer, including customers with no orders db.users.aggregate([ { $lookup: { from: "orders", localField: "_id", foreignField: "userId", as: "orders" } }, { $project: { _id: 0, name: 1, orders: { $size: "$orders" }, spent: { $sum: "$orders.amount" } } }, { $sort: { spent: -1 } } ]) // How many users are interested in each hobby db.users.aggregate([ { $unwind: "$interests" }, { $group: { _id: "$interests", users: { $sum: 1 } } }, { $sort: { users: -1, _id: 1 } } ])
Terminal Output
shop> db.orders.aggregate([ | { $lookup: { from: "users", localField: "userId", foreignField: "_id", as: "user" } }, | { $unwind: "$user" }, | { $project: { _id: 0, product: 1, amount: 1, customer: "$user.name" } } | ]) [ { product: 'Keyboard', amount: 49.99, customer: 'Alice' }, { product: 'Monitor', amount: 189, customer: 'Alice' }, { product: 'Mouse', amount: 19.5, customer: 'Bob' }, { product: 'Laptop', amount: 999, customer: 'Carol' }, { product: 'Mouse', amount: 19.5, customer: 'Carol' }, { product: 'Monitor', amount: 189, customer: 'Erin' } ] shop> db.users.aggregate([ | { $lookup: { from: "orders", localField: "_id", foreignField: "userId", as: "orders" } }, | { $project: { _id: 0, name: 1, orders: { $size: "$orders" }, spent: { $sum: "$orders.amount" } } }, | { $sort: { spent: -1 } } | ]) [ { name: 'Carol', orders: 2, spent: 1018.5 }, { name: 'Alice', orders: 2, spent: 238.99 }, { name: 'Erin', orders: 1, spent: 189 }, { name: 'Bob', orders: 1, spent: 19.5 }, { name: 'Dave', orders: 0, spent: 0 } ] shop> db.users.aggregate([ | { $unwind: "$interests" }, | { $group: { _id: "$interests", users: { $sum: 1 } } }, | { $sort: { users: -1, _id: 1 } } | ]) [ { _id: 'chess', users: 2 }, { _id: 'cooking', users: 1 }, { _id: 'cycling', users: 1 }, { _id: 'guitar', users: 1 }, { _id: 'hiking', users: 1 }, { _id: 'photography', users: 1 } ]
Dave still appears in the second report, with 0 orders, because $lookup gives him an empty array rather than dropping him.
Advanced
Indexes, validation, data modeling, transactions, access control, types, reporting, and backups
Indexes and explain()

An index lets MongoDB jump straight to matching documents instead of scanning the whole collection. explain() shows which it did.

Without a suitable index, a query does a collection scan (COLLSCAN): it reads every document and tests each one. An index is a sorted structure (a B-tree) of one or more fields' values, pointing to the documents, so the server can find matches directly. That's an index scan (IXSCAN). Every collection has an index on _id automatically. createIndex({ userId: 1 }) adds one on userId (1 ascending, -1 descending), and { unique: true } makes the index enforce uniqueness, the right way to stop two users registering the same email.

explain("executionStats") runs the query and reports how. Look at the winning plan's stage, and compare totalDocsExamined with nReturned: examining 200,000 documents to return 200 is the sign of a missing index. A compound index such as { status: 1, orderedAt: -1 } serves queries that filter on status and sort by orderedAt, and also queries on status alone, but not queries on orderedAt alone. The field order matters. Indexes cost memory and slow down writes a little, so add them for the queries you actually run.

COLLSCAN
A collection scan: every document is read to find the matches.
IXSCAN
An index scan: the server uses an index to go straight to matches.
Compound index
An index on several fields; it serves queries on its leading fields.
// 200,000 documents to make the difference visible db.events.insertMany(Array.from({ length: 200000 }, (_, i) => ({ userId: i % 1000, kind: "click" }))) const before = db.events.find({ userId: 42 }).explain("executionStats") before.queryPlanner.winningPlan.stage before.executionStats.totalDocsExamined db.events.createIndex({ userId: 1 }) const after = db.events.find({ userId: 42 }).explain("executionStats") after.queryPlanner.winningPlan.inputStage.stage after.executionStats.totalDocsExamined // A unique index enforces a rule as well as speeding up lookups db.users.createIndex({ email: 1 }, { unique: true, sparse: true }) db.users.insertOne({ name: "Eve", email: "bob@example.com" }) db.users.getIndexes() // Clean up the test collection db.events.drop()
Terminal Output
shop> db.events.insertMany(Array.from({ length: 200000 }, (_, i) => ({ userId: i % 1000, kind: "click" }))).acknowledged true shop> const before = db.events.find({ userId: 42 }).explain("executionStats") shop> before.queryPlanner.winningPlan.stage COLLSCAN shop> before.executionStats.totalDocsExamined 200000 shop> db.events.createIndex({ userId: 1 }) userId_1 shop> const after = db.events.find({ userId: 42 }).explain("executionStats") shop> after.queryPlanner.winningPlan.inputStage.stage IXSCAN shop> after.executionStats.totalDocsExamined 200 shop> db.users.createIndex({ email: 1 }, { unique: true, sparse: true }) email_1 shop> db.users.insertOne({ name: "Eve", email: "bob@example.com" }) Uncaught MongoServerError: E11000 duplicate key error collection: shop.users index: email_1 dup key: { email: "bob@example.com" } shop> db.users.getIndexes() [ { v: 2, key: { _id: 1 }, name: '_id_' }, { v: 2, key: { email: 1 }, name: 'email_1', unique: true, sparse: true } ] shop> db.events.drop() true
sparse: true leaves documents without an email (Dave) out of the index, so the unique rule doesn't treat several missing emails as duplicates of each other.
Schema Validation

A collection can carry a $jsonSchema validator, so the server rejects documents with missing fields or wrong types.

MongoDB doesn't require a schema, but you can add one. A validator attached to a collection is checked on every insert and update, and documents that fail are rejected, giving back the guarantees a SQL table has, only where you want them. The usual form is $jsonSchema: required lists fields that must be present, and properties gives each field's bsonType (such as "string", "int", "double", "date") plus rules like minimum, enum, or pattern.

Set the validator with createCollection for a new collection, or with db.runCommand({ collMod: ... }) for an existing one. validationLevel: "moderate" applies the rules only to documents that already pass, which is useful when tightening an old collection. validationAction: "warn" logs violations instead of rejecting them, a good way to find out what a new rule would break. The error message is long but precise: it names each rule a document broke and the value it considered.

Validator
Rules attached to a collection that every insert and update must pass.
$jsonSchema
The validator format that describes required fields, types, and value rules.
bsonType
A field's required BSON type, such as string, int, double, or date.
db.createCollection("products", { validator: { $jsonSchema: { bsonType: "object", required: ["name", "price"], properties: { name: { bsonType: "string" }, price: { bsonType: ["double", "int"], minimum: 0 }, stock: { bsonType: "int", minimum: 0 } } } } }) db.products.insertOne({ name: "Keyboard", price: 49.99, stock: 12 }) // Missing price and a negative stock: rejected db.products.insertOne({ name: "Mouse", stock: -3 })
Terminal Output
shop> db.createCollection("products", { | validator: { $jsonSchema: { | bsonType: "object", | required: ["name", "price"], | properties: { | name: { bsonType: "string" }, | price: { bsonType: ["double", "int"], minimum: 0 }, | stock: { bsonType: "int", minimum: 0 } | } | } } | }) { ok: 1 } shop> db.products.insertOne({ name: "Keyboard", price: 49.99, stock: 12 }) { acknowledged: true, insertedId: ObjectId('6ab5d50ed48add71174939f3') } shop> db.products.insertOne({ name: "Mouse", stock: -3 }) Uncaught: MongoServerError: Document failed validation Additional information: { failingDocumentId: ObjectId('6ab5d50ed48add71174939f4'), details: { operatorName: '$jsonSchema', schemaRulesNotSatisfied: [ { operatorName: 'properties', propertiesNotSatisfied: [ { propertyName: 'stock', details: [ { operatorName: 'minimum', specifiedAs: { minimum: 0 }, reason: 'comparison failed', consideredValue: -3 } ] } ] }, { operatorName: 'required', specifiedAs: { required: [ 'name', 'price' ] }, missingProperties: [ 'price' ] } ] } }
Data Modeling: Embed or Reference

The big design question in MongoDB is whether related data lives inside one document or in separate collections linked by _id.

Embedding puts related data inside the document that owns it: an order holds its line items as an array, a user holds their address. One read returns everything, and a single-document update is atomic, so the order and its items can never be out of step. Embed when the data is read together, belongs to one parent, and stays bounded in size. A document can be at most 16 MB, so an ever-growing list, such as every page view a user has ever made, must not be embedded.

Referencing stores another document's _id instead, as the orders collection does with userId. Use it when the related data is shared by many documents (one product in thousands of orders), when it's large or unbounded, or when it's often read on its own. You join references with $lookup or a second query. A useful rule of thumb: design for how the data is read. Data that's always displayed together belongs together, and duplicating a small, rarely changing value, such as a product name on an order line, is often better than a join on every read.

Embedding
Storing related data inside the parent document, read and updated in one operation.
Referencing
Storing another document's _id and joining when needed.
16 MB limit
The maximum size of one document, which rules out embedding unbounded lists.
// Embedded: the line items live inside the order db.invoices.insertOne({ number: "INV-1001", customer: { name: "Alice", email: "alice@example.com" }, items: [ { sku: "KB-01", name: "Keyboard", qty: 1, price: 49.99 }, { sku: "CB-02", name: "USB cable", qty: 2, price: 5.00 } ] }) // One query answers "what's on this invoice, and what's it worth?" db.invoices.aggregate([ { $match: { number: "INV-1001" } }, { $unwind: "$items" }, { $group: { _id: "$number", lines: { $sum: 1 }, total: { $sum: { $multiply: ["$items.qty", "$items.price"] } } } } ]) // Update one embedded line in place with the positional $ operator db.invoices.updateOne({ number: "INV-1001", "items.sku": "CB-02" }, { $set: { "items.$.qty": 3 } }) db.invoices.findOne({ number: "INV-1001" }, { _id: 0, items: 1 })
Terminal Output
shop> db.invoices.insertOne({ | number: "INV-1001", | customer: { name: "Alice", email: "alice@example.com" }, | items: [ | { sku: "KB-01", name: "Keyboard", qty: 1, price: 49.99 }, | { sku: "CB-02", name: "USB cable", qty: 2, price: 5.00 } | ] | }) { acknowledged: true, insertedId: ObjectId('6ab5d512fac79193d7c64a77') } shop> db.invoices.aggregate([ | { $match: { number: "INV-1001" } }, | { $unwind: "$items" }, | { $group: { _id: "$number", lines: { $sum: 1 }, | total: { $sum: { $multiply: ["$items.qty", "$items.price"] } } } } | ]) [ { _id: 'INV-1001', lines: 2, total: 59.99 } ] shop> db.invoices.updateOne({ number: "INV-1001", "items.sku": "CB-02" }, { $set: { "items.$.qty": 3 } }) { acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 } shop> db.invoices.findOne({ number: "INV-1001" }, { _id: 0, items: 1 }) { items: [ { sku: 'KB-01', name: 'Keyboard', qty: 1, price: 49.99 }, { sku: 'CB-02', name: 'USB cable', qty: 3, price: 5 } ] }
Replica Sets and Transactions

Multi-document transactions need a replica set. A one-member replica set on your own machine is enough to learn with.

A replica set is a group of mongod servers holding copies of the same data: one primary takes writes, and secondaries copy them and take over if the primary fails. Production MongoDB always runs as a replica set, and Atlas does this for you. Some features only exist on a replica set, transactions among them. A standalone server, like the one this guide installed, rejects them. For learning, start mongod with --replSet rs0 (or add replication.replSetName: rs0 to mongod.conf), run rs.initiate() once, and you have a one-member set. The prompt then shows rs0 [direct: primary].

A single-document write is always atomic, which is why good embedding removes most of the need for transactions. When you must change several documents together, such as moving stock between two warehouses, start a session, call startTransaction(), do the work through the session's database handle, then commitTransaction() or abortTransaction(). Nothing is visible outside the transaction until it commits. Keep transactions short: by default MongoDB aborts any that run longer than 60 seconds.

Replica set
A group of servers holding copies of the same data, with one primary taking writes.
Primary
The replica set member that accepts writes; secondaries replicate from it.
Transaction
Several operations that commit together or not at all; needs a replica set.
// One-time setup: start mongod with --replSet rs0, then in mongosh: rs.initiate() use shop db.stock.insertMany([{ _id: "north", qty: 10 }, { _id: "south", qty: 0 }]) // Move 4 units from north to south, all or nothing const session = db.getMongo().startSession() const s = session.getDatabase("shop") session.startTransaction() s.stock.updateOne({ _id: "north" }, { $inc: { qty: -4 } }) s.stock.updateOne({ _id: "south" }, { $inc: { qty: 4 } }) session.commitTransaction() db.stock.find() // Abort: the change is thrown away session.startTransaction() s.stock.updateOne({ _id: "north" }, { $inc: { qty: -100 } }) session.abortTransaction() db.stock.findOne({ _id: "north" })
Terminal Output
rs0 [direct: primary] test> use shop switched to db shop rs0 [direct: primary] shop> db.stock.insertMany([{ _id: "north", qty: 10 }, { _id: "south", qty: 0 }]) { acknowledged: true, insertedIds: { '0': 'north', '1': 'south' } } rs0 [direct: primary] shop> const session = db.getMongo().startSession() rs0 [direct: primary] shop> const s = session.getDatabase("shop") rs0 [direct: primary] shop> session.startTransaction() rs0 [direct: primary] shop> s.stock.updateOne({ _id: "north" }, { $inc: { qty: -4 } }) { acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 } rs0 [direct: primary] shop> s.stock.updateOne({ _id: "south" }, { $inc: { qty: 4 } }) { acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 } rs0 [direct: primary] shop> session.commitTransaction() rs0 [direct: primary] shop> db.stock.find() [ { _id: 'north', qty: 6 }, { _id: 'south', qty: 4 } ] rs0 [direct: primary] shop> session.startTransaction() rs0 [direct: primary] shop> s.stock.updateOne({ _id: "north" }, { $inc: { qty: -100 } }) { acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 } rs0 [direct: primary] shop> session.abortTransaction() rs0 [direct: primary] shop> db.stock.findOne({ _id: "north" }) { _id: 'north', qty: 6 }
On the standalone server from the Installation tab, startTransaction() succeeds but the first write inside it fails with This MongoDB deployment does not support retryable writes. Despite the wording, the fix isn't in your connection string: transactions need a replica set.
Users and Access Control

By default MongoDB has no passwords. Create an admin user, switch authorization on, then give each application its own user with only the roles it needs.

A fresh install has access control disabled: anyone who can reach the port can read and delete everything, which is only safe because the server listens on 127.0.0.1. To lock it down, first create an administrator in the admin database, then enable authorization, by setting security.authorization: enabled in mongod.conf and restarting. The localhost exception makes this possible: on a server with authorization on and no users yet, a connection from the same machine may create the first user, and nothing else.

A user is created in one database, its authentication database, and is granted roles. Built-in roles cover most needs: read and readWrite on one database, dbAdmin for indexes and statistics, userAdminAnyDatabase to manage users, and root for everything. Give each application its own user with the smallest role that works, the principle of least privilege, so a bug or a leaked password in a reporting tool can't delete your data. Connect with mongosh -u name -p --authenticationDatabase shop, or put the username in the connection string.

Access control
Requiring every connection to log in, set by security.authorization.
Role
A named set of privileges, such as read, readWrite, or root.
Localhost exception
Lets a local connection create the first user when none exist yet.
// Server started with authorization enabled and no users yet. // Connected from the same machine (the localhost exception): use admin db.createUser({ user: "admin", pwd: "change-me", roles: ["root"] }) // Log in as admin, then create a read-only user for reports db.auth("admin", "change-me") use shop db.createUser({ user: "reporter", pwd: "report-pass", roles: [{ role: "read", db: "shop" }] }) db.sales.insertOne({ region: "north", total: 1200 }) // As reporter: mongosh -u reporter -p --authenticationDatabase shop db.sales.find({}, { _id: 0 }) db.sales.deleteMany({})
Terminal Output
-- connected from localhost, no users yet admin> use admin already on db admin admin> db.createUser({ user: "admin", pwd: "change-me", roles: ["root"] }) { ok: 1 } admin> db.auth("admin", "change-me") { ok: 1 } admin> use shop switched to db shop shop> db.createUser({ user: "reporter", pwd: "report-pass", roles: [{ role: "read", db: "shop" }] }) { ok: 1 } shop> db.sales.insertOne({ region: "north", total: 1200 }) { acknowledged: true, insertedId: ObjectId('6ab5d51e3550825bbff0ad16') } -- mongosh -u reporter -p --authenticationDatabase shop shop> db.sales.find({}, { _id: 0 }) [ { region: 'north', total: 1200 } ] shop> db.sales.deleteMany({}) Uncaught MongoServerError[Unauthorized]: not authorized on shop to execute command { delete: "sales", deletes: [ { q: {}, limit: 0 } ], ordered: true, lsid: { id: UUID("a8d169bc-f5ee-433d-a875-67dbfcfd205d") }, $db: "shop" }
Never open port 27017 to the internet without authorization. Automated scanners find open MongoDB servers within hours, and wipe or ransom the data.
Dates, ObjectIds, and Types

BSON has more types than JSON. Knowing dates, ObjectIds, and number types avoids the classic why doesn't this match? bugs.

Store dates as real Date values, not strings. new Date("2025-03-01") creates one, the shell shows it as ISODate(...), and it's stored in UTC. Real dates compare and sort correctly, work with range queries like { joined: { $gte: new Date("2024-01-01") } }, and can be grouped by month in a pipeline. A date stored as the string "2025-03-01" does none of that reliably. An ObjectId also carries its creation time: getTimestamp() reads it back, so _id order is roughly insertion order.

Numbers come in several types. The shell's default is a 64-bit floating-point double, which can't represent some decimals exactly (0.1 + 0.2 isn't quite 0.3), so money should use Decimal128, written NumberDecimal("19.99") in the shell. Whole numbers can be stored as NumberInt (32-bit) or NumberLong (64-bit) when a validator or another program expects integers. Types matter in matching: the number 42 and the string "42" are different values, and $type finds documents where a field has a given type.

ISODate
The shell's display of a BSON Date, stored in UTC.
Decimal128
An exact decimal type for money, written NumberDecimal("19.99").
$type
A query operator that matches fields of a given BSON type.
db.users.find({ joined: { $gte: new Date("2024-01-01") } }, { _id: 0, name: 1, joined: 1 }) // An ObjectId knows when it was made ObjectId("6ab543d4263d934188d384a2").getTimestamp() // Floating point vs exact decimals 0.1 + 0.2 db.prices.insertMany([{ item: "a", price: 0.1 }, { item: "b", price: NumberDecimal("0.1") }]) db.prices.aggregate([{ $group: { _id: { $type: "$price" }, total: { $sum: { $add: ["$price", "$price", "$price"] } } } }, { $sort: { _id: 1 } }]) // A number and a string are different values db.codes.insertMany([{ code: 42 }, { code: "42" }]) db.codes.find({ code: 42 }, { _id: 0 }) db.codes.find({ code: { $type: "string" } }, { _id: 0 })
Terminal Output
shop> db.users.find({ joined: { $gte: new Date("2024-01-01") } }, { _id: 0, name: 1, joined: 1 }) [ { name: 'Alice', joined: ISODate('2024-03-15T00:00:00.000Z') }, { name: 'Bob', joined: ISODate('2024-06-01T00:00:00.000Z') }, { name: 'Dave', joined: ISODate('2025-01-09T00:00:00.000Z') } ] shop> ObjectId("6ab543d4263d934188d384a2").getTimestamp() ISODate('2026-09-24T15:37:56.000Z') shop> 0.1 + 0.2 0.30000000000000004 shop> db.prices.insertMany([{ item: "a", price: 0.1 }, { item: "b", price: NumberDecimal("0.1") }]).acknowledged true shop> db.prices.aggregate([{ $group: { _id: { $type: "$price" }, total: { $sum: { $add: ["$price", "$price", "$price"] } } } }, { $sort: { _id: 1 } }]) [ { _id: 'decimal', total: Decimal128('0.3') }, { _id: 'double', total: 0.30000000000000004 } ] shop> db.codes.insertMany([{ code: 42 }, { code: "42" }]).acknowledged true shop> db.codes.find({ code: 42 }, { _id: 0 }) [ { code: 42 } ] shop> db.codes.find({ code: { $type: "string" } }, { _id: 0 }) [ { code: '42' } ]
Reporting with Aggregation

A few more stages turn raw documents into reports: computed fields, grouping by month, and bucketing values into ranges.

$addFields (also called $set in a pipeline) adds computed fields while keeping the rest of the document. Combined with expression operators it can do arithmetic ($multiply, $subtract), string work ($concat, $toUpper), conditionals ($cond), and date work. $dateToString formats a date, so grouping by { $dateToString: { format: "%Y-%m", date: "$orderedAt" } } gives one row per month, the most common sales report there is.

$bucket sorts documents into ranges: give it the field, the boundaries (for example ages 0, 30, 50, 120), and an output per bucket. Each boundary starts a bucket, and the last one only closes the final bucket. $facet runs several sub-pipelines over the same input in one pass and returns all their results together, which is how a search page shows result counts by category, price range, and brand at once. Aggregations can end with $out or $merge to write their results into a collection, which works as a stored report you refresh on a schedule.

$addFields
Adds computed fields to each document, keeping the existing ones.
$dateToString
Formats a date as text, often used to group by day or month.
$bucket
Groups documents into ranges defined by a list of boundaries.
// Revenue per month db.orders.aggregate([ { $group: { _id: { $dateToString: { format: "%Y-%m", date: "$orderedAt" } }, orders: { $sum: 1 }, revenue: { $sum: "$amount" } } }, { $sort: { _id: 1 } } ]) // Users by age band db.users.aggregate([ { $bucket: { groupBy: "$age", boundaries: [0, 30, 50, 120], output: { users: { $sum: 1 }, names: { $push: "$name" } } } } ]) // A computed field db.orders.aggregate([ { $addFields: { withTax: { $round: [{ $multiply: ["$amount", 1.2] }, 2] } } }, { $project: { _id: 0, product: 1, amount: 1, withTax: 1 } }, { $limit: 3 } ])
Terminal Output
shop> db.orders.aggregate([ | { $group: { _id: { $dateToString: { format: "%Y-%m", date: "$orderedAt" } }, | orders: { $sum: 1 }, revenue: { $sum: "$amount" } } }, | { $sort: { _id: 1 } } | ]) [ { _id: '2025-01', orders: 3, revenue: 258.49 }, { _id: '2025-02', orders: 3, revenue: 1207.5 } ] shop> db.users.aggregate([ | { $bucket: { groupBy: "$age", boundaries: [0, 30, 50, 120], | output: { users: { $sum: 1 }, names: { $push: "$name" } } } } | ]) [ { _id: 0, users: 2, names: [ 'Bob', 'Dave' ] }, { _id: 30, users: 2, names: [ 'Alice', 'Carol' ] }, { _id: 50, users: 1, names: [ 'Erin' ] } ] shop> db.orders.aggregate([ | { $addFields: { withTax: { $round: [{ $multiply: ["$amount", 1.2] }, 2] } } }, | { $project: { _id: 0, product: 1, amount: 1, withTax: 1 } }, | { $limit: 3 } | ]) [ { product: 'Keyboard', amount: 49.99, withTax: 59.99 }, { product: 'Monitor', amount: 189, withTax: 226.8 }, { product: 'Mouse', amount: 19.5, withTax: 23.4 } ]
Backup, Restore, Import, and Export

mongodump and mongorestore back up and restore whole databases. mongoexport and mongoimport move data to and from JSON and CSV.

These tools come in the MongoDB Database Tools package. It's included with the Linux mongodb-org package and Homebrew's formula, and it's a separate download on Windows. They run from your terminal, not inside mongosh. mongodump --db=shop writes a BSON dump, one .bson file per collection plus its indexes and options, into a dump/ folder. mongorestore loads it back, and --nsFrom/--nsTo let you restore into a different database name, which is the easy way to test a backup. --gzip compresses the dump.

mongoexport writes one collection as JSON (one document per line, or --jsonArray) or as CSV with --type=csv --fields=..., for spreadsheets and other programs. mongoimport reads JSON or CSV back in; with --headerline a CSV's first row becomes the field names. Export loses some type detail, since CSV has no dates or ObjectIds, so use mongodump for backups and export/import for exchanging data. As with any database, a backup you have never restored is only a hope: restore into a scratch database now and then.

mongodump
Writes a database or collection to BSON files for backup.
mongorestore
Loads a mongodump back into a server, optionally under a new name.
mongoexport
Writes a collection as JSON or CSV for use outside MongoDB.
# From your terminal, not inside mongosh mongodump --db=shop --out=dump # Restore into a different database to test the backup mongorestore --nsFrom='shop.*' --nsTo='shop_copy.*' dump/ # Export to CSV, and import a CSV mongoexport --db=shop --collection=users --type=csv --fields=name,email,age,city --out=users.csv mongoimport --db=shop --collection=newusers --type=csv --headerline --file=users.csv
Terminal Output
$ mongodump --port=57017 --db=shop --out=dump 2026-09-24T20:58:02.577-0500 writing `shop.orders` to `dump/shop/orders.bson` 2026-09-24T20:58:02.577-0500 writing `shop.users` to `dump/shop/users.bson` 2026-09-24T20:58:02.578-0500 writing `shop.codes` to `dump/shop/codes.bson` ... 11 more lines ... 2026-09-24T20:58:02.582-0500 done dumping `shop.counters` (1 document) 2026-09-24T20:58:02.582-0500 done dumping `shop.products` (1 document) $ mongorestore --port=57017 --nsFrom='shop.*' --nsTo='shop_copy.*' dump/ 2026-09-24T20:58:02.608-0500 preparing collections to restore from 2026-09-24T20:58:02.608-0500 don't know what to do with file `dump/shop/prelude.json`, skipping... 2026-09-24T20:58:02.608-0500 reading metadata for `shop_copy.invoices` from `dump/shop/invoices.metadata.json` ... 31 more lines ... 2026-09-24T20:58:02.749-0500 no indexes to restore for collection `shop_copy.invoices` 2026-09-24T20:58:02.771-0500 19 document(s) restored successfully. 0 document(s) failed to restore. $ mongoexport --port=57017 --db=shop --collection=users --type=csv --fields=name,email,age,city --out=users.csv 2026-09-24T20:58:02.798-0500 connected to: mongodb://localhost:57017/ 2026-09-24T20:58:02.802-0500 exported 5 records $ cat users.csv name,email,age,city Alice,alice@example.com,34,London Bob,bob@example.com,27,Madrid Carol,carol@example.com,45,London Dave,,19,Berlin Erin,erin@example.com,53,Paris $ mongoimport --port=57017 --db=shop --collection=newusers --type=csv --headerline --file=users.csv 2026-09-24T20:58:02.828-0500 connected to: mongodb://localhost:57017/ 2026-09-24T20:58:02.848-0500 5 document(s) imported successfully. 0 document(s) failed to import.
About the output: --port=57017 is the test server it was captured on; on a default install, leave it out and the tools use port 27017. The don't know what to do with file prelude.json line is a harmless message from recent tool versions.
MongoDB Knowledge Quiz
10 questions — click an option to answer
0 / 10 answered
Score: 0 / 0
0/10
0
Correct
0
Incorrect
0%
Score