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.
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.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.
Choose Complete, then keep these defaults on the next screens:
C:\Program Files\MongoDB\Server\8.3\ are fine.The server installer doesn't include the shell. winget is the quickest way to add it:
winget install MongoDB.ShellThen close PowerShell and open a new window so mongosh is on your PATH.
Open Services, find MongoDB, and check that it says Running. From an Administrator prompt you can also start and stop it:
net start MongoDBmongosh$ 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>winget install MongoDB.Server installs the server too. Afterwards, check that a MongoDB service exists in Services and is running.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/brewThis installs the server, mongosh, and the database tools:
brew update
brew install mongodb-community@8.3This starts it now and at every login:
brew services start mongodb-community@8.3Data 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.
mongosh$ 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>mongod, open System Settings → Privacy & Security, click Open Anyway next to the message about mongod, and run the brew services start line again.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 --dearmorThis 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.listmongodb-org pulls in the server, mongosh, and the database tools:
sudo apt update
sudo apt install -y mongodb-orgUnlike PostgreSQL, the package doesn't start the server. This starts it now and at every boot:
sudo systemctl enable --now mongod
systemctl is-active mongodactivemongosh$ 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>docker run -d -p 27017:27017 mongo:8.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 see | What it means | Fix |
|---|---|---|
| MongoNetworkError: connect ECONNREFUSED 127.0.0.1:27017 | The 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 found | The 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 start | Usually 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 database | A 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 mongod | Gatekeeper didn't recognise the developer. | System Settings → Privacy & Security → Open Anyway, then start the service again. |
| Running inside WSL | MongoDB doesn't support the server inside Windows Subsystem for Linux. | Install the server on the Windows side and run mongosh from PowerShell. |
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.
# 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$ 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
21 + 1 works, and so does const n = 5. That's what makes the shell handy for quick scripts.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.
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 // quittest> 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 collectionsshow collections printed nothing because shop is still empty. It appears in show dbs after the first write, in the next step.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).
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")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
usersinsertOne 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.
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" })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()
5find(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.
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" })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" })
2updateOne 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.
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()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.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.
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 })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' } ]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.
// 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 })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' }
}
]modifiedCount: 0. $addToSet saw that guitar was already in Dave's list and left it alone.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.
// 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" } })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
}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".
// 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] } } }
])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 } ]$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.
// 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 } }
])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 }
]$lookup gives him an empty array rather than dropping him.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.
// 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()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()
truesparse: 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.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.
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 })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' ]
}
]
}
}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.
// 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 })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 }
]
}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.
// 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" })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 }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.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.
// 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({})-- 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" }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.
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 })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' } ]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.
// 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 }
])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 }
]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.
# 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$ 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.--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.