MongoDB โ Complete Beginner Tutorial
6 modules ยท 18 examples ยท Click โถ Run on any example to see the result
๐ Sample Collection โ db.students โ used throughout examples
| name | age | city | grade | score | active |
|---|---|---|---|---|---|
| Alice | 20 | Cape Town | A | 95 | true |
| Bob | 17 | Johannesburg | B | 82 | true |
| Charlie | 22 | Cape Town | A | 91 | true |
| Diana | 16 | Durban | C | 68 | false |
| Edward | 19 | Johannesburg | B | 79 | true |
| Fiona | 21 | Cape Town | A | 88 | true |
| George | 18 | Durban | D | 55 | false |
๐ก How to use this page
Read the explanation, study the query, then hit โถ Run to see the result.
Documents & Collections
MongoDB stores data as documents โ flexible JSON-like objects. Documents live inside collections (roughly equivalent to SQL tables). A database holds many collections. Unlike SQL, you do not need to define a schema before inserting data.
insertOne() โ Adding a Single Document
insertOne() adds one document to a collection. If the collection does not exist, MongoDB creates it automatically. Every document gets a unique _id field โ MongoDB generates an ObjectId automatically if you do not provide one.
// Switch to (or create) the school database
use school
// Insert a single student document
db.students.insertOne({
name: "Alice",
age: 20,
city: "Cape Town",
grade: "A",
score: 95,
active: true,
courses: ["Python", "MongoDB", "Web Dev"]
})
// Insert a document WITH a custom _id
db.students.insertOne({
_id: "student_bob",
name: "Bob",
age: 17,
grade: "B",
score: 82
})
// Check how many documents are in the collection
db.students.countDocuments()MongoDB creates databases and collections lazily โ they only appear on disk after you insert the first document. You can run use mydb and db.myCollection.insertOne({}) and both the database and collection are created instantly.
insertMany() โ Adding Multiple Documents
insertMany() takes an array of documents and inserts them all in one operation. This is much faster than calling insertOne() in a loop. By default, if one document fails (e.g. duplicate _id), the rest are not inserted.
use school
db.students.insertMany([
{
name: "Charlie",
age: 22,
city: "Cape Town",
grade: "A",
score: 91,
active: true
},
{
name: "Diana",
age: 16,
city: "Durban",
grade: "C",
score: 68,
active: false
},
{
name: "Edward",
age: 19,
city: "Johannesburg",
grade: "B",
score: 79,
active: true
},
{
name: "Fiona",
age: 21,
city: "Cape Town",
grade: "A",
score: 88,
active: true
},
{
name: "George",
age: 18,
city: "Durban",
grade: "D",
score: 55,
active: false
}
])
// Confirm total count
db.students.countDocuments()Pass { ordered: false } as a second argument to insertMany() to continue inserting remaining documents even if some fail: db.col.insertMany([...], { ordered: false }). Without this, a failure stops all remaining inserts.
find() โ Retrieving Documents
find() returns all documents in a collection (with an optional filter). findOne() returns only the first match. The second argument is a projection โ it controls which fields to return (1 = include, 0 = exclude).
use school
// Get ALL documents
db.students.find()
// Get all โ but only name and grade (exclude _id)
db.students.find(
{}, // filter: {} = all docs
{ name: 1, grade: 1, _id: 0 } // projection
)
// findOne() โ returns first match only
db.students.findOne({ name: "Alice" })
// Count all documents
db.students.countDocuments()
// Count with a filter
db.students.countDocuments({ grade: "A" })find() returns a cursor โ not an array. In mongosh it auto-iterates and displays results. In Node.js you need .toArray() to convert it: const docs = await db.collection("students").find().toArray(). Use projection to avoid sending unnecessary data over the network.
Querying Documents
MongoDB queries are JSON objects passed to find(). Simple equality checks use { field: value }. For comparisons and logical operations, you use query operators โ special keywords starting with $. These let you build powerful, precise queries.
Comparison Operators
Query operators start with $. The comparison operators let you filter by range: $gt (greater than), $gte (โฅ), $lt (less than), $lte (โค), $eq (equals), $ne (not equals), $in (value in array), $nin (not in array).
use school
// Equal to โ shorthand (no operator needed)
db.students.find({ city: "Cape Town" })
// Greater than
db.students.find({ score: { $gt: 85 } })
// Greater than or equal to AND less than
db.students.find({
score: { $gte: 70, $lt: 90 }
})
// Not equal
db.students.find({ grade: { $ne: "A" } })
// $in โ matches any value in the array
db.students.find({
grade: { $in: ["A", "B"] }
})
// $nin โ does NOT match any in array
db.students.find({
city: { $nin: ["Durban"] }
})You can combine multiple operators on the same field: { score: { $gte: 70, $lte: 90 } } โ this reads as "score >= 70 AND score <= 90". MongoDB applies all operators as AND conditions within one field by default.
Logical Operators โ $and, $or, $not
$and requires ALL conditions to match. $or requires at least ONE to match. $not negates a condition. When you list multiple fields in one query object, MongoDB implicitly ANDs them โ $and is only needed for more complex cases.
use school
// Implicit AND: age >= 18 AND grade = "A"
db.students.find({
age: { $gte: 18 },
grade: "A"
})
// Explicit $and (useful when same field repeated)
db.students.find({
$and: [
{ score: { $gte: 80 } },
{ score: { $lte: 95 } }
]
})
// $or: city is Cape Town OR Durban
db.students.find({
$or: [
{ city: "Cape Town" },
{ city: "Durban" }
]
})
// Combining $or and other conditions:
// Active students from Cape Town OR score > 90
db.students.find({
active: true,
$or: [
{ city: "Cape Town" },
{ score: { $gt: 90 } }
]
})
// $not โ inverse of a condition
db.students.find({
score: { $not: { $lt: 70 } }
})Implicit AND (just listing fields in the filter object) is far more readable and should be your default. Only use explicit $and when you need two conditions on the same field, like { $and: [{ score: { $gt: 50 } }, { score: { $lt: 90 } }] }.
Element & Array Operators
$exists checks whether a field is present. $type checks the field's BSON type. $elemMatch matches documents where at least one array element satisfies a condition. $size matches arrays of a specific length.
use school
// $exists โ field must exist (and not be null)
db.students.find({ courses: { $exists: true } })
// $exists: false โ field must NOT exist
db.students.find({ courses: { $exists: false } })
// Field must be a specific BSON type
// Type 2 = String, 16 = int32, 8 = boolean
db.students.find({ active: { $type: "bool" } })
// Array field: find where courses contains "Python"
db.students.find({ courses: "Python" })
// $elemMatch โ at least one array element matches
db.students.find({
scores: {
$elemMatch: { $gte: 90, $lt: 100 }
}
})
// $size โ array has exactly 3 elements
db.students.find({ courses: { $size: 3 } })
// Dot notation โ query nested object fields
db.students.find({ "address.city": "Cape Town" })When you query an array field with a plain value like { courses: "Python" }, MongoDB checks if that value exists ANYWHERE in the array โ no $elemMatch needed for simple equality. $elemMatch is only needed when matching multiple conditions on the same array element.
CRUD โ Update & Delete
Update operators let you modify specific fields without rewriting the entire document. $set changes a field, $inc increments a number, $push adds to an array, $unset removes a field. Delete with deleteOne() or deleteMany().
updateOne() and updateMany()
updateOne() updates the first document that matches the filter. updateMany() updates all matching documents. Always use an update operator like $set โ without one, you would replace the entire document with just the update object.
use school
// $set โ update specific fields only
db.students.updateOne(
{ name: "Alice" }, // filter
{ $set: { score: 98, grade: "A+" } } // update
)
// Verify the change
db.students.findOne(
{ name: "Alice" },
{ name: 1, score: 1, grade: 1, _id: 0 }
)
// $inc โ increment a number
db.students.updateOne(
{ name: "Bob" },
{ $inc: { score: 5 } } // score += 5
)
// $unset โ remove a field entirely
db.students.updateOne(
{ name: "George" },
{ $unset: { active: "" } }
)
// $push โ add to an array
db.students.updateOne(
{ name: "Alice" },
{ $push: { courses: "Data Science" } }
)
// updateMany โ update ALL matching documents
db.students.updateMany(
{ score: { $lt: 60 } },
{ $set: { status: "needs-support" } }
)Never do updateOne(filter, { score: 99 }) โ this REPLACES the entire document with just { score: 99 }, deleting all other fields! Always use an update operator: { $set: { score: 99 } }. The $set operator only touches the fields you list.
upsert, replaceOne() and findOneAndUpdate()
upsert: true creates a new document if no match is found. replaceOne() replaces the entire matched document (no $ operators needed). findOneAndUpdate() returns the document before (or after) updating โ useful for atomic operations.
use school
// upsert: true โ insert if not found
db.students.updateOne(
{ name: "Zanele" }, // filter โ no match
{ $set: {
name: "Zanele",
age: 23,
grade: "A",
score: 92
}},
{ upsert: true } // CREATE if not found
)
// Confirm Zanele was created
db.students.findOne({ name: "Zanele" }, { _id: 0 })
// replaceOne โ replaces entire document
db.students.replaceOne(
{ name: "Zanele" },
{ name: "Zanele Dlamini", age: 23, grade: "A", score: 94 }
)
// findOneAndUpdate โ returns UPDATED document
db.students.findOneAndUpdate(
{ name: "Zanele Dlamini" },
{ $inc: { score: 1 } },
{ returnDocument: "after" }
)findOneAndUpdate() is atomic โ the find and update happen in a single operation with no risk of another process changing the document in between. This makes it perfect for counters, tokens, and any operation where you need the value AND want to update it safely.
deleteOne() and deleteMany()
deleteOne() removes the first document matching the filter. deleteMany() removes all matches. There is no "recycle bin" in MongoDB โ deletions are permanent. Always test your filter with find() before running deleteMany().
use school
// Check what you are about to delete FIRST
db.students.find(
{ active: false },
{ name: 1, active: 1, _id: 0 }
)
// deleteOne โ remove first inactive student
db.students.deleteOne({ active: false })
// Confirm what's left
db.students.countDocuments({ active: false })
// deleteMany โ remove ALL inactive students
db.students.deleteMany({ active: false })
// Confirm all gone
db.students.countDocuments({ active: false })
// Delete by _id (safest โ guaranteed unique)
db.students.deleteOne({
_id: ObjectId("6501a2b3c4d5e6f7a8b9c0d1")
})
// โ ๏ธ Delete ALL documents in a collection
// db.students.deleteMany({}) // BE CAREFUL!Golden rule: always run the same filter with find() before deleteMany(). If find({ active: false }) returns what you expect, then deleteMany({ active: false }) is safe. Never run deleteMany({}) unless you truly want to empty the entire collection.
Sorting, Limiting & Projection
After filtering, you can sort results, limit how many are returned, and skip a number for pagination. Projection controls which fields come back โ only ask for what you need. These methods chain onto find() and work together seamlessly.
sort() โ Ordering Results
sort() takes an object where 1 means ascending (AโZ, 0โ9) and -1 means descending (ZโA, 9โ0). You can sort by multiple fields โ MongoDB processes them left to right.
use school
// Sort by score ascending (lowest first)
db.students.find(
{},
{ name: 1, score: 1, _id: 0 }
).sort({ score: 1 })
// Sort by score descending (highest first)
db.students.find(
{},
{ name: 1, score: 1, _id: 0 }
).sort({ score: -1 })
// Sort by grade AโZ, then score descending
db.students.find(
{},
{ name: 1, grade: 1, score: 1, _id: 0 }
).sort({ grade: 1, score: -1 })
// Find top scorer in Cape Town
db.students.find(
{ city: "Cape Town" },
{ name: 1, score: 1, _id: 0 }
).sort({ score: -1 }).limit(1)sort() is always applied AFTER the filter but BEFORE limit() and skip(). MongoDB processes the pipeline in this order: filter โ sort โ skip โ limit โ project. Understanding this order prevents subtle bugs in pagination.
limit(), skip() & Pagination
limit() caps the number of results returned. skip() skips a number of documents from the start of the result set. Together they implement pagination: page 1 is skip(0).limit(n), page 2 is skip(n).limit(n), and so on.
use school
// limit() โ only return first 3 results
db.students.find(
{},
{ name: 1, score: 1, _id: 0 }
).sort({ score: -1 }).limit(3)
// skip() โ skip first 3, get next 3
db.students.find(
{},
{ name: 1, score: 1, _id: 0 }
).sort({ score: -1 }).skip(3).limit(3)
// Pagination helper function
const pageSize = 2
// Page 1
db.students.find({}, { name: 1, _id: 0 })
.sort({ name: 1 })
.skip(0 * pageSize)
.limit(pageSize)
// Page 2
db.students.find({}, { name: 1, _id: 0 })
.sort({ name: 1 })
.skip(1 * pageSize)
.limit(pageSize)
// Page 3
db.students.find({}, { name: 1, _id: 0 })
.sort({ name: 1 })
.skip(2 * pageSize)
.limit(pageSize)skip() can be slow on large collections because MongoDB must scan through all the skipped documents. For high-performance pagination on large datasets, use "cursor-based pagination" instead: filter by { _id: { $gt: lastSeenId } }.limit(n).
Projection โ Choosing Fields
Projection is the second argument to find(). Use 1 to include a field, 0 to exclude. You cannot mix 1s and 0s (except for _id). By default _id is always included โ use _id: 0 to exclude it.
use school
// Include only name and score
db.students.find(
{},
{ name: 1, score: 1 } // _id still included!
).limit(3)
// Include name and score, EXCLUDE _id
db.students.find(
{},
{ name: 1, score: 1, _id: 0 }
).limit(3)
// Exclude specific fields (include everything else)
db.students.find(
{},
{ _id: 0, active: 0, courses: 0 }
).limit(2)
// Combine filter + projection + sort + limit
db.students.find(
{ score: { $gte: 80 } },
{ name: 1, score: 1, grade: 1, _id: 0 }
).sort({ score: -1 }).limit(3)Always use projection in production code โ only return the fields your application actually needs. Sending entire documents over the network when you only need 2 fields wastes bandwidth and slows your API. This is especially important on documents with large embedded arrays.
Aggregation Pipeline
The aggregation pipeline processes documents through a series of stages. Each stage transforms the data and passes results to the next stage. It is MongoDB's answer to SQL's GROUP BY, HAVING, and JOINs โ and it is extremely powerful.
$match and $group
$match filters documents (like find()). $group groups documents by a field and computes aggregate values like $sum, $avg, $min, $max, and $count. The _id in $group is the field you group by โ set it to null to aggregate all documents.
use school
// Count students per city
db.students.aggregate([
{
$group: {
_id: "$city",
count: { $sum: 1 }
}
}
])
// Average score per grade (only grade A and B)
db.students.aggregate([
{ $match: { grade: { $in: ["A", "B"] } } },
{
$group: {
_id: "$grade",
avgScore: { $avg: "$score" },
maxScore: { $max: "$score" },
minScore: { $min: "$score" },
total: { $sum: 1 }
}
},
{ $sort: { _id: 1 } }
])
// Overall stats across ALL students
db.students.aggregate([
{
$group: {
_id: null,
avgScore: { $avg: "$score" },
maxScore: { $max: "$score" },
total: { $sum: 1 }
}
}
])$match should almost always be the FIRST stage in a pipeline. Placing it first allows MongoDB to use indexes and reduce the number of documents that flow into subsequent stages โ making your pipeline much faster.
$project, $sort, $limit in a Pipeline
$project in aggregation reshapes documents โ similar to projection in find() but more powerful. You can rename fields, compute new ones, and conditionally include values. Chain it with $sort and $limit for ranked reports.
use school
// Full pipeline: filter โ group โ project โ sort โ limit
db.students.aggregate([
// Stage 1: only active students
{ $match: { active: true } },
// Stage 2: group by city
{
$group: {
_id: "$city",
students: { $sum: 1 },
avgScore: { $avg: "$score" },
topScore: { $max: "$score" }
}
},
// Stage 3: reshape the output
{
$project: {
_id: 0,
city: "$_id",
students: 1,
avgScore: { $round: ["$avgScore", 1] },
topScore: 1
}
},
// Stage 4: sort by avgScore descending
{ $sort: { avgScore: -1 } },
// Stage 5: top 3 cities only
{ $limit: 3 }
])Think of an aggregation pipeline like a factory assembly line: raw materials (documents) go in one end, pass through processing stages, and refined output comes out the other. Each stage does one job. This makes debugging easy โ comment out stages one at a time to see what each one produces.
$lookup โ Joining Collections
$lookup is MongoDB's equivalent of SQL JOIN. It combines documents from two collections. You specify which collection to join, which local field to match against, which foreign field to match, and what to call the resulting array.
use school
// Collections:
// students: { name, grade, score, courseIds: [1, 2] }
// courses: { _id, title, teacher }
// Join students with their courses
db.students.aggregate([
// Only look at passing students
{ $match: { score: { $gte: 70 } } },
// Join with courses collection
{
$lookup: {
from: "courses", // collection to join
localField: "courseIds", // field in students
foreignField: "_id", // field in courses
as: "enrolledCourses" // output array name
}
},
// Only show relevant fields
{
$project: {
_id: 0,
name: 1,
score: 1,
enrolledCourses: { $map: {
input: "$enrolledCourses",
as: "c",
in: "$$c.title"
}}
}
}
])$lookup is powerful but expensive. In MongoDB, it is often better to EMBED related data in a single document rather than joining across collections โ especially for data that is always read together. Only use $lookup when the related data is truly independent and large.
Indexes & Schema Design
Indexes make queries fast by letting MongoDB jump directly to relevant documents instead of scanning every document. Without indexes, every find() does a full collection scan โ fine for small data, catastrophic at scale. Schema design (embed vs reference) defines how you structure your documents for maximum performance.
Creating and Managing Indexes
createIndex() creates a new index. A single-field index speeds up queries filtering or sorting on that field. A compound index covers multiple fields. The unique option rejects duplicate values. Use explain() to see if MongoDB uses your index.
use school
// Single-field index โ speeds up queries on 'score'
db.students.createIndex({ score: 1 })
// Compound index โ covers {city, score} queries
db.students.createIndex({ city: 1, score: -1 })
// Unique index โ no two students can share an email
db.students.createIndex(
{ email: 1 },
{ unique: true }
)
// TTL index โ auto-delete sessions after 3600 seconds
db.sessions.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: 3600 }
)
// List all indexes on a collection
db.students.getIndexes()
// Drop an index
db.students.dropIndex({ score: 1 })
// explain() โ see if MongoDB uses an index
db.students.find({ score: { $gt: 80 } })
.explain("executionStats")Do not over-index. Every index slows down insertOne(), updateOne(), and deleteOne() because MongoDB must update the index too. Add indexes for fields you actually query or sort on frequently. A good rule: if a query runs slow in production, add an index. Do not guess upfront.
Schema Design โ Embed vs Reference
MongoDB is schema-flexible but your document structure decisions have massive performance implications. The golden rule: data you always read together should live together (embed). Data that is large, changes independently, or is shared across documents should be referenced.
// โโ EMBEDDING โ best when data belongs together โโ
// Good: embed address inside user (always read together)
db.users.insertOne({
name: "Alice",
email: "alice@example.com",
address: { // embedded document
street: "10 Main Road",
city: "Cape Town",
zip: "8001"
},
preferences: { // another embedded document
theme: "dark",
language: "en"
}
})
// Read user + address in ONE query โ no join needed
db.users.findOne({ email: "alice@example.com" })
// โโ REFERENCING โ best for large / shared data โโ
// Products collection
db.products.insertOne({
_id: ObjectId("prod1"),
name: "Laptop",
price: 12999
})
// Orders reference products by ID
db.orders.insertOne({
userId: ObjectId("user1"),
createdAt: new Date(),
items: [
{ productId: ObjectId("prod1"), qty: 1, price: 12999 },
{ productId: ObjectId("prod2"), qty: 2, price: 299 }
],
total: 13597
})
// Query order โ then $lookup products if needed
db.orders.find({ userId: ObjectId("user1") })Embed when: the data is always read with the parent, the embedded data is small and bounded, the embedded data belongs to one document. Reference when: the data is large or unbounded (could grow to thousands of items), the data is shared across many documents, or you need to query the sub-documents independently.
You finished the MongoDB tutorial!
You now know how to insert, query, update, delete, sort, paginate, aggregate, and index MongoDB documents. These are the skills you need to build real back-end APIs with Node.js or Python.