Welcome to the ultimate MongoDB and Mongoose guide — built step-by-step for both beginners and intermediate developers looking to master database development in the Node.js ecosystem.
In this hands-on series, we’ll explore everything from the foundations of MongoDB to real-world application development using Mongoose, including setup, schema design, validations, middleware, transactions, performance tuning, deployment, and much more.
Whether you’re building your first backend app or scaling a production-grade API, this guide will walk you through:
- Core MongoDB operations using the shell
- Creating schemas and models with Mongoose
- Handling relationships and validations
- Writing clean, production-ready CRUD APIs
- Performance optimization & best practices
- Full backend integration with Express.js
- Deployment-ready strategies and tips
No fluff. Just practical, code-first explanations with real examples and structured sections.
Prerequisites:
- Basic knowledge of JavaScript
- Node.js installed on your machine
- A terminal and code editor (e.g., VS Code)
Pro Tip: Bookmark this guide and revisit as you scale your backend knowledge. You’ll find clear explanations, code snippets, and mini challenges throughout.
Let’s dive into the world of fast, flexible, and scalable backend development with MongoDB + Mongoose
Installation Video
Youtube video (for Complete Installation help):
The MongoDB Shell
The MongoDB Shell (mongosh
) is an interactive JavaScript interface to communicate with your MongoDB server. It lets you query, update, and manage your databases and collections.
Starting the Shell
If MongoDB is installed locally, open your terminal and run:
mongosh
Command | Description |
---|---|
show dbs |
List all databases |
use <dbname> |
Switch to or create a database |
db.createCollection() |
Create a new collection |
db.<collection>.insertOne() |
Add a document |
db.<collection>.find() |
Retrieve documents |
db.<collection>.updateOne() |
Modify document |
db.<collection>.deleteOne() |
Remove document |
How MongoDB Stores Data — BSON
MongoDB stores data in a format called BSON (Binary JSON).
What is BSON?
- BSON stands for Binary JavaScript Object Notation.
- It is a binary-encoded serialization format.
- BSON extends the JSON format with additional data types such as:
Date
Binary
Decimal128
ObjectId
Why BSON and not JSON?
Feature | JSON | BSON |
---|---|---|
Format | Text-based | Binary |
Performance | Slower for reading/writing | Faster due to binary format |
Data Types | Limited types | Richer types (Date, Binary, etc.) |
Size | Lightweight | Slightly heavier (due to metadata) |
Key Point
While you write and read data using JSON-like syntax, MongoDB stores it in BSON behind the scenes to optimize for speed and flexibility.
Example
// JSON-style document
{
name: "Bhupesh",
age: 24,
isAdmin: false,
joinedAt: new Date(),
_id: ObjectId("60ab1234567890abcdef1234")
}
When stored, this is automatically converted to BSON by MongoDB.
Documents and Collections in MongoDB
MongoDB is a NoSQL database that stores data in a flexible, JSON-like format.
What is a Document?
- A Document is the basic unit of data in MongoDB.
- It’s similar to a row in relational databases.
- It is written in JSON-like syntax but stored as BSON.
- Documents can have nested structures (arrays, objects).
Example:
{
_id: ObjectId("64f9d0b45ef65d..."),
name: "Bhupesh",
age: 24,
skills: ["JavaScript", "Node.js", "MongoDB"],
isAdmin: false
}
What is a Collection?
- A Collection is a group of documents.
- It is similar to a table in relational databases.
- Documents in a collection don’t need to have the same structure.
Example:
Users Collection:
{ name: "Bhupesh", age: 24 }
{ name: "Alice", age: 30, city: "Toronto" }
{ name: "Dev", skills: ["Python", "SQL"] }
Key difference from SQL
MongoDB (NoSQL) | SQL (Relational DB) |
---|---|
Document | Row |
Collection | Table |
Field (key) | Column |
BSON format | Tabular format |
Schema-less | Fixed schema |
Conclusion:
MongoDB’s flexibility allows each document in a collection to store different fields and structures, making it ideal for modern applications with evolving data needs.
Insert in MongoDB using insertOne()
The insertOne()
method is used to insert a single document into a MongoDB collection.
Syntax:
db.collection.insertOne(document)
Example (Mongo Shell):
db.users.insertOne({
name: "Bhupesh",
age: 25,
email: "bhupesh@example.com",
isAdmin: true
});
This will insert one document into the users collection.
Example using Node.js with MongoDB Driver:
const { MongoClient } = require("mongodb");
async function run() {
const uri = "mongodb://localhost:27017";
const client = new MongoClient(uri);
try {
await client.connect();
const db = client.db("myAppDB");
const users = db.collection("users");
const result = await users.insertOne({
name: "Alice",
age: 30,
city: "Toronto"
});
console.log("Inserted ID:", result.insertedId);
} finally {
await client.close();
}
}
run().catch(console.error);
Notes:
- Automatically creates the collection if it doesn’t exist.
- Adds a unique _id field if not specified.
- Returns an object containing insertedId and acknowledged: true
Insert Multiple Documents using insertMany()
The insertMany()
method is used to insert multiple documents into a MongoDB collection in one go.
Syntax:
db.collection.insertMany([document1, document2, ...])
Example (Mongo Shell):
db.users.insertMany([
{ name: "Bhupesh", age: 25 },
{ name: "Alice", age: 30 },
{ name: "John", age: 22 }
]);
This will insert all the objects into the users collection.
Find Documents in MongoDB
The find()
method is used to retrieve documents from a MongoDB collection. You can query all documents or filter using criteria.
Syntax:
db.collection.find(query, projection)
Example 1: Find All Documents
db.users.find()
Returns all documents from the users collection.
Example 2: Find with Condition
db.users.find({ age: { $gt: 25 } })
Returns users whose age is greater than 25.
Example 3: Find Specific Fields Only
db.users.find({ age: { $gt: 25 } }, { name: 1, _id: 0 })
Returns only the name field (excluding _id).
Example 4: Find One Document
db.users.findOne({ name: "Bhupesh" })
Returns the first matching document.
MongoDB Query Operators
Query operators in MongoDB are used to filter documents based on specific conditions.
Comparison Operators
Operator | Description | Example |
---|---|---|
$eq |
Equals | { age: { $eq: 25 } } |
$ne |
Not equal | { status: { $ne: "inactive" }} |
$gt |
Greater than | { age: { $gt: 30 } } |
$lt |
Less than | { age: { $lt: 50 } } |
$gte |
Greater than or equal to | { score: { $gte: 90 } } |
$lte |
Less than or equal to | { score: { $lte: 60 } } |
Logical Operators
Operator | Description | Example |
---|---|---|
$and |
Matches documents that satisfy all clauses | { $and: [ { age: { $gt: 18 } }, { age: { $lt: 60 } } ] } |
$or |
Matches documents that satisfy any clause | { $or: [ { age: 20 }, { name: "Bhupesh" } ] } |
$not |
Inverts the effect of a query expression | { age: { $not: { $gt: 18 } } } |
$nor |
Matches none of the expressions | { $nor: [ { status: "inactive" }, { age: { $lt: 18 } } ] } |
Element Operators
Operator | Description | Example |
---|---|---|
$exists |
Checks if the field exists | { email: { $exists: true } } |
$type |
Checks the BSON type of a field | { age: { $type: "int" } } |
Array Operators
Operator | Description | Example |
---|---|---|
$in |
Matches any value in the array | { age: { $in: [20, 25, 30] } } |
$nin |
Matches none of the values in the array | { status: { $nin: ["inactive", "banned"] } } |
$all |
Matches arrays that contain all specified elements | { tags: { $all: ["node", "js"] } } |
$size |
Matches arrays with a specific number of elements | { tags: { $size: 3 } } |
$elemMatch |
Matches documents that contain an array element that matches all criteria | { scores: { $elemMatch: { $gt: 80, $lt: 100 } } } |
Example Usage
db.users.find({
$and: [
{ age: { $gt: 18 } },
{ status: { $ne: "inactive" } }
]
});
UPDATE in MongoDB
MongoDB provides methods to update documents in a collection.
updateOne()
Updates a single document that matches the filter.
db.users.updateOne(
{ name: "Bhupesh" },
{ $set: { age: 26 } }
);
This will update the age field of the first document where name is “Bhupesh”
updateMany()
Updates all documents that match the filter.
db.users.updateMany(
{ country: "Canada" },
{ $set: { isActive: true } }
);
This will update all users from Canada by adding or updating the isActive field.
Operator | Description | Example |
---|---|---|
$set |
Sets the value of a field | { $set: { age: 30 } } |
$inc |
Increments a field by a value | { $inc: { points: 10 } } |
$unset |
Removes a field from a document | { $unset: { tempField: "" } } |
$rename |
Renames a field | { $rename: { oldName: "newName" } } |
Nesting in MongoDB
MongoDB supports nested documents (also called embedded documents)—documents inside documents. This helps represent complex relationships directly within the document itself.
Example: Nested Document
const user = {
name: "Bhupesh",
age: 25,
address: {
street: "123 King St",
city: "Toronto",
country: "Canada"
}
};
db.users.insertOne(user);
Here, address is a nested object inside the user document.
Accessing Nested Fields
Use dot notation to query or update nested fields.
// Find users in Toronto
db.users.find({ "address.city": "Toronto" });
// Update the country of a nested address
db.users.updateOne(
{ name: "Bhupesh" },
{ $set: { "address.country": "USA" } }
);
Nesting Arrays of Documents
MongoDB also allows arrays of nested documents:
const blog = {
title: "My Blog Post",
comments: [
{ user: "Alice", text: "Great post!" },
{ user: "Bob", text: "Thanks for sharing." }
]
};
db.blogs.insertOne(blog);
Querying Nested Arrays
You can also query nested arrays of documents:
db.blogs.find({ "comments.user": "Alice" });
Nesting in MongoDB helps reduce the need for joins and keeps related data together. Use it wisely for better performance and data organization!
DELETE in MongoDB
In MongoDB, you can delete documents from a collection using two main methods:
deleteOne()
Deletes the first document that matches the query criteria.
db.users.deleteOne({ name: "Bhupesh" });
If multiple documents match, only the first one will be deleted.
deleteMany()
Deletes all documents that match the query criteria.
db.users.deleteMany({ age: { $lt: 18 } });
Deletes all users under the age of 18.
Delete All Documents (Use Carefully)
db.users.deleteMany({});
This will remove all documents from the users collection.
Deleting by _id
db.users.deleteOne({ _id: ObjectId("60f6f6f6f6f6f6f6f6f6f6f6") });
You must use ObjectId() when deleting by _id.
MongoDB also supports soft deletes (e.g., marking as deleted with a flag), but you must implement this manually using a field like isDeleted: true.
What is Mongoose?
Mongoose is an Object Data Modeling (ODM) library for MongoDB and Node.js.
It provides a straightforward, schema-based solution to model your application data.
Why Use Mongoose?
- Simplifies interaction with MongoDB using models.
- Allows you to define schemas with field types and validations.
- Provides middleware hooks and custom methods.
- Supports relationships using population.
- Handles complex querying and data manipulation easily.
Installation
npm install mongoose
Basic Setup Example
const mongoose = require('mongoose');
// Connect to MongoDB
mongoose.connect('mongodb://localhost:27017/myApp')
.then(() => console.log('Connected to MongoDB'))
.catch(err => console.log(err));
Define a Schema and Model
const userSchema = new mongoose.Schema({
name: String,
email: String,
age: Number
});
const User = mongoose.model('User', userSchema);
Create and Save a Document
const user = new User({
name: "Bhupesh",
email: "bhupesh@example.com",
age: 25
});
user.save()
.then(doc => console.log("Saved:", doc))
.catch(err => console.log(err));
Common Operations
- User.find()
- User.findById()
- User.findOne()
- User.updateOne()
- User.deleteOne()
Installation and Setup of Mongoose
Mongoose helps Node.js apps connect and interact with MongoDB in a structured and efficient way.
Step 1: Install Mongoose
Use npm to install Mongoose in your Node.js project:
npm install mongoose
Step 2: Create Project Files
Your folder structure might look like this:
myApp/
├── node_modules/
├── index.js
├── package.json
Step 3: Connect to MongoDB
// index.js
const mongoose = require('mongoose');
// Connect to local MongoDB
mongoose.connect("mongodb://127.0.0.1:27017/myDatabase")
.then(() => console.log("✅ Connected to MongoDB"))
.catch((err) => console.error("❌ Connection error", err));
Replace myDatabase with your actual database name.
Mongoose Schema
A Mongoose Schema defines the structure of the documents within a MongoDB collection. It specifies the fields, data types, validations, and even default values.
Create a Basic Schema
const mongoose = require("mongoose");
const userSchema = new mongoose.Schema({
name: String,
email: String,
age: Number
});
Add Validations and Options
const userSchema = new mongoose.Schema({
name: {
type: String,
required: true, // Mandatory field
trim: true // Removes whitespace
},
email: {
type: String,
required: true,
unique: true, // No duplicate emails
lowercase: true // Automatically converts to lowercase
},
age: {
type: Number,
min: 0, // Minimum age validation
default: 18 // Default value
},
createdAt: {
type: Date,
default: Date.now
}
});
Convert Schema to Model
const User = mongoose.model("User", userSchema);
Now, User can be used to create, read, update, and delete users in the users collection.
Notes
- A schema is just a blueprint; it needs to be compiled into a model.
- Once compiled, the model gives you access to powerful Mongoose methods like find(), save(), deleteOne(), etc.
- You can also define custom methods, virtuals, and middleware on schemas.
Mongoose Models
A Model is a wrapper for a Mongoose schema. It provides an interface to interact with documents in a MongoDB collection.
How to Create a Model
After defining a schema, use mongoose.model()
to create a model:
const mongoose = require('mongoose');
// Define Schema
const userSchema = new mongoose.Schema({
name: String,
email: String,
age: Number
});
// Create Model
const User = mongoose.model("User", userSchema);
- “User” is the name of the model.
- Mongoose will create a collection called users (lowercased and pluralized).
Task | Method |
---|---|
Create |
new User() + .save()
|
Read |
find() , findOne()
|
Update |
updateOne() , findByIdAndUpdate()
|
Delete |
deleteOne() , findByIdAndDelete()
|
Insert in Mongoose
Inserting data into MongoDB using Mongoose is simple and efficient using the Model instance or Model.create() method.
Method 1: Using new Model()
and .save()
const mongoose = require('mongoose');
// Define schema
const userSchema = new mongoose.Schema({
name: String,
email: String,
age: Number
});
// Create model
const User = mongoose.model("User", userSchema);
// Create a new user
const newUser = new User({
name: "Bhupesh",
email: "bhupesh@example.com",
age: 25
});
// Save the user to the database
newUser.save()
.then(result => console.log("✅ Saved:", result))
.catch(err => console.log("❌ Error:", err));
Method 2: Using Model.create()
User.create({
name: "Alice",
email: "alice@example.com",
age: 30
})
.then(result => console.log("✅ User created:", result))
.catch(err => console.log("❌ Error creating user:", err));
Method 3: Insert Many Documents at Once
User.insertMany([
{ name: "John", email: "john@example.com", age: 28 },
{ name: "Jane", email: "jane@example.com", age: 22 }
])
.then(result => console.log("✅ Users inserted:", result))
.catch(err => console.log("❌ Insertion error:", err));
Tips
- Use .save() when you need to manipulate or validate the instance before saving.
- Use .create() or .insertMany() for quicker, cleaner inserts.
- Mongoose handles schema validations automatically before insertion.
Operation Buffering in Mongoose
Operation Buffering in Mongoose allows you to run database operations before a successful connection is established to MongoDB.
What Does That Mean?
You can perform operations (like .save()
or .find()
) immediately after defining your model—even before the connection to the database is fully established. Mongoose queues those operations and executes them once the connection is ready.
Example
const mongoose = require('mongoose');
const userSchema = new mongoose.Schema({ name: String });
const User = mongoose.model("User", userSchema);
// Save user before connecting
const newUser = new User({ name: "Bhupesh" });
newUser.save().then(() => console.log("✅ Saved even before connection!"));
// Connect afterward
mongoose.connect("mongodb://127.0.0.1:27017/demoDB")
.then(() => console.log("📡 Connected to MongoDB"))
.catch(err => console.error("❌ Connection error:", err));
Why It’s Useful
- Makes development smoother.
- Prevents crashes from unintentional timing issues.
- Helps with serverless platforms or apps with delayed DB readiness.
When to Avoid
- In production apps where you want full control over database timing.
- When you’re handling connections manually.
Find in Mongoose
Mongoose provides several methods to retrieve documents from a MongoDB collection. The most commonly used methods are:
find()
findOne()
findById()
1. find()
Returns all documents that match the query as an array.
User.find({ age: { $gte: 18 } })
.then(users => console.log(users))
.catch(err => console.error(err));
2. findOne()
Returns the first document that matches the query.
User.findOne({ name: "Bhupesh" })
.then(user => console.log(user))
.catch(err => console.error(err));
Returns the first user with the name “Bhupesh”.
3. findById()
Used to find a document using its _id.
User.findById("66507e7a342823aa9f34b215")
.then(user => console.log(user))
.catch(err => console.error(err));
Make sure to pass a valid ObjectId as a string.
Tip: Use Projections
You can specify which fields to return:
User.find({}, "name email")
Only returns the name and email fields for all users.
Tip: Use Filters and Sorts
User.find({ age: { $gt: 18 } })
.sort({ name: 1 }) // ascending
.limit(5) // limit results
.skip(10); // skip first 10
Update in Mongoose
Mongoose provides several methods to update documents in a MongoDB collection.
Common Update Methods
Method | Description |
---|---|
updateOne() |
Updates the first matched document |
updateMany() |
Updates all matched documents |
findByIdAndUpdate() |
Finds a document by _id and updates it |
findOneAndUpdate() |
Finds one doc matching query & updates |
updateOne()
User.updateOne(
{ name: "Bhupesh" },
{ $set: { age: 26 } }
)
.then(res => console.log("Updated:", res))
.catch(err => console.error(err));
updateMany()
User.updateMany(
{ age: { $lt: 18 } },
{ $set: { minor: true } }
)
.then(res => console.log("Updated:", res))
.catch(err => console.error(err));
findByIdAndUpdate()
User.findByIdAndUpdate(
"66507e7a342823aa9f34b215",
{ age: 30 },
{ new: true } // returns updated document
)
.then(user => console.log("Updated User:", user))
.catch(err => console.error(err));
findOneAndUpdate()
User.findOneAndUpdate(
{ email: "bhupesh@example.com" },
{ $inc: { age: 1 } },
{ new: true }
)
.then(user => console.log("Updated:", user));
Update Options
Option | Description |
---|---|
new: true |
Returns updated doc instead of old version |
upsert |
Creates new doc if no match found |
runValidators: true |
Enforces schema validations during update |
Tip
Always use $set when updating fields to avoid replacing the entire document.
{ $set: { name: "Alice" } }
Delete in Mongoose
Mongoose provides multiple methods to remove documents from a MongoDB collection:
deleteOne()
Deletes the first document that matches the query.
User.deleteOne({ name: "Bhupesh" })
.then(result => console.log("✅ Deleted:", result))
.catch(err => console.error("❌ Error:", err));
deleteMany()
Deletes all documents that match the query.
User.deleteMany({ age: { $lt: 18 } })
.then(result => console.log("✅ Deleted minors:", result))
.catch(err => console.error("❌ Error:", err));
findByIdAndDelete()
Finds a document by _id and deletes it.
User.findByIdAndDelete("66507e7a342823aa9f34b215")
.then(deletedUser => console.log("✅ Deleted User:", deletedUser))
.catch(err => console.error("❌ Error:", err));
findOneAndDelete()
Deletes the first matching document and returns it.
User.findOneAndDelete({ email: "test@example.com" })
.then(deleted => console.log("✅ Deleted:", deleted))
.catch(err => console.error("❌ Error:", err));
Notes
- All methods return a promise.
- findOneAndDelete() and findByIdAndDelete() return the deleted document.
- deleteOne() and deleteMany() return a result object with deletedCount.
Schema Validations in Mongoose
Mongoose allows you to enforce data integrity through schema-based validations. This ensures that your documents meet certain criteria before being saved to the database.
Example Schema with Validations
const mongoose = require('mongoose');
const userSchema = new mongoose.Schema({
name: {
type: String,
required: true, // Must be provided
minlength: 3, // At least 3 characters
maxlength: 50 // Max 50 characters
},
email: {
type: String,
required: true,
unique: true, // No duplicates allowed
match: /.+@.+..+/ // Must match email pattern
},
age: {
type: Number,
min: 18, // Minimum age
max: 100 // Maximum age
},
isAdmin: {
type: Boolean,
default: false
}
});
Common Validation Options
Option | Description |
---|---|
required |
Field must be present |
min / max
|
For numbers: min and max values |
minlength / maxlength
|
For strings: length limits |
match |
Must match a regex (e.g., email) |
enum |
Must be one of a set of allowed values |
default |
Value to use if none is provided |
unique |
Enforces uniqueness in MongoDB (index) |
Custom Validation Function
const productSchema = new mongoose.Schema({
price: {
type: Number,
validate: {
validator: (val) => val > 0,
message: 'Price must be greater than zero!'
}
}
});
Notes
- Validations run on .save() and .create() by default.
- Use { runValidators: true } when updating documents.
- Unique is a MongoDB index, not a validation. Handle duplication errors manually if needed.
Mongoose SchemaType Options
Mongoose provides a set of SchemaType options that define how data is stored, validated, and handled inside MongoDB collections.
These options can be applied to individual fields in a schema.
Common SchemaType Options
Option | Description |
---|---|
type |
Defines the data type (String, Number, Date, etc.) |
required |
Makes the field mandatory |
default |
Sets a default value if none is provided |
unique |
Creates a unique index (Note: not a validation) |
enum |
Restricts values to a fixed set (like categories or status) |
min / max
|
For numbers (min and max values); For dates (earliest/latest) |
minlength / maxlength
|
For strings (length restrictions) |
match |
Validates string against a regular expression |
validate |
Provides a custom validator function |
select |
Include or exclude field from query results (e.g. select: false ) |
immutable |
Once set, the value cannot be changed after document creation |
get / set
|
Custom getter/setter functions for transforming data |
Validations During Update & Error Handling in Mongoose
By default, Mongoose does NOT run schema validations during updateOne()
, updateMany()
, or findByIdAndUpdate()
unless explicitly told to do so.
Enabling Validations on Update
Use the { runValidators: true }
option to enforce schema rules when updating.
Example
User.findByIdAndUpdate(
"66507e7a342823aa9f34b215",
{ age: 15 }, // Fails if age < 18 (based on schema)
{ runValidators: true, new: true }
)
.then(updatedUser => console.log(updatedUser))
.catch(err => console.error("❌ Validation Error:", err.message));
Why It Matters?
Without runValidators, this update would bypass validation rules like:
- min, max
- enum, match
- Custom validators
Mongoose Middleware (Hooks)
Mongoose Middleware, also known as hooks, allow you to run custom logic before or after certain operations like save, remove, update, or validate.
Why Use Middleware?
- Logging
- Data validation or formatting
- Timestamps
- Cascading deletes
- Sending emails or notifications
Types of Middleware
Type | Triggered On |
---|---|
pre |
Before the operation |
post |
After the operation |
Middleware Applies To:
save
validate
remove
updateOne
deleteOne
find
findOne
findOneAndUpdate
findOneAndDelete
Example: Pre-save Hook
userSchema.pre("save", function (next) {
console.log("📦 Before saving:", this.name);
// modify data if needed
this.name = this.name.trim();
next();
});
Example: Post-save Hook
userSchema.post("save", function (doc, next) {
console.log("✅ User saved:", doc.name);
next();
});
Example: Pre-find Hook
userSchema.pre("find", function () {
console.log("🔍 Running find query...");
this.select("-password"); // exclude password from results
});
Example: Pre-remove Hook
userSchema.pre("remove", function (next) {
console.log(`⚠️ User ${this.name} is being removed`);
next();
});
Real-World Use Cases
- Hashing passwords before save
- Soft deletes (marking deleted instead of removing)
- Creating audit logs
- Formatting timestamps
Mongoose Middleware is a powerful way to plug in logic around lifecycle events of your documents.
Mongoose Instance Methods & Static Methods
Mongoose allows you to add custom methods to your models in two ways:
- Instance Methods: Run on individual documents.
- Static Methods: Run on the Model (i.e., the entire collection).
Instance Methods
These methods are defined on the document instance.
Example:
const userSchema = new mongoose.Schema({
name: String,
email: String
});
userSchema.methods.sayHello = function () {
return `Hi, I’m ${this.name}`;
};
const User = mongoose.model("User", userSchema);
const user = new User({ name: "Bhupesh", email: "bhupesh@example.com" });
console.log(user.sayHello()); // Output: Hi, I’m Bhupesh
Use Cases
- Formatting user data
- Checking user roles or permissions
- Document-specific logic
Static Methods
These methods are attached to the Model class, not the document.
Example:
userSchema.statics.findByEmail = function (email) {
return this.findOne({ email });
};
const User = mongoose.model("User", userSchema);
User.findByEmail("bhupesh@example.com")
.then(user => console.log(user))
.catch(err => console.error(err));
Use Cases
- Custom queries on the collection
- Aggregation helpers
- Global filters (e.g., find all active users)
Note
- Use regular functions (not arrow functions) to access this.
- this in instance methods → refers to the document.
- this in static methods → refers to the model.
Model methods help organize and encapsulate business logic close to your data.
Mongoose Relationships (ref
& .populate()
)
In MongoDB (and Mongoose), relationships between documents are created using the ref
option. This allows documents to reference other documents—similar to a foreign key in SQL.
Defining Relationships with ref
Let’s say we have two models: User
and Post
.
Example: One-to-Many (User → Posts)
// models/User.js
const mongoose = require("mongoose");
const userSchema = new mongoose.Schema({
name: String,
email: String
});
module.exports = mongoose.model("User", userSchema);
// models/Post.js
const mongoose = require("mongoose");
const postSchema = new mongoose.Schema({
title: String,
content: String,
user: {
type: mongoose.Schema.Types.ObjectId,
ref: "User" // 👈 references the User model
}
});
module.exports = mongoose.model("Post", postSchema);
Using .populate()
The .populate() method replaces the referenced ObjectId with the actual document.
Post.find()
.populate("user") // replaces `user` ObjectId with full user object
.then(posts => {
console.log(posts[0].user.name); // Access user's name
});
Populate Specific Fields
Post.find()
.populate("user", "name email -_id") // include name & email, exclude _id
.then(console.log);
Example: One-to-One
const profileSchema = new mongoose.Schema({
bio: String,
user: {
type: mongoose.Schema.Types.ObjectId,
ref: "User"
}
});
Example: Many-to-Many
You can also have an array of references:
const courseSchema = new mongoose.Schema({
name: String,
students: [{
type: mongoose.Schema.Types.ObjectId,
ref: "User"
}]
});
Notes
- Always use .populate() on the query, not on the schema.
- You can chain multiple .populate() calls for deeply nested population.
- Relationships are manual in MongoDB—no cascading updates or deletes by default.
Mongoose ref and .populate() give you powerful control over document linking without losing MongoDB’s flexibility.
Indexes in Mongoose
Indexes in MongoDB improve the performance of read operations by allowing the database to locate data faster.
Mongoose lets you define indexes directly in your schema.
Why Use Indexes?
- Improve query speed
- Support uniqueness constraints
- Optimize sorting, filtering, and pagination
- Enable geospatial or text search
Defining Indexes in a Schema
const userSchema = new mongoose.Schema({
name: {
type: String,
index: true // simple index
},
email: {
type: String,
unique: true // creates a unique index
}
});
Compound Index
userSchema.index({ name: 1, email: -1 }); // ascending + descending
Checking Indexes in MongoDB
User.collection.getIndexes({ full: true }).then(console.log);
Creating Indexes Manually
userSchema.index({ age: 1 });
User.createIndexes(); // triggers index creation
Notes
- Index creation is asynchronous.
- Indexes can slow down write operations, so use them wisely.
- MongoDB automatically creates an _id index for every document.
Dropping Indexes (if needed)
User.collection.dropIndex("name_1");
Indexes are essential for scaling MongoDB apps and keeping queries fast.
Mongoose Virtuals
Virtuals are properties that don’t get persisted in MongoDB but are computed dynamically when you access them.
They’re useful for:
- Derived values (e.g., full name from first + last name)
- Relationships (for population)
- Formatting data
Defining a Virtual
const userSchema = new mongoose.Schema({
firstName: String,
lastName: String
});
userSchema.virtual("fullName").get(function () {
return `${this.firstName} ${this.lastName}`;
});
Now you can do:
const user = new User({ firstName: "Bhupesh", lastName: "Kumar" });
console.log(user.fullName); // "Bhupesh Kumar"
Setting a Virtual (with Setter)
userSchema.virtual("info").set(function (v) {
const parts = v.split(" ");
this.firstName = parts[0];
this.lastName = parts[1];
});
Usage:
const u = new User();
u.info = "John Doe";
console.log(u.firstName); // "John"
Virtuals in JSON
By default, virtuals don’t appear in .toJSON() or .toObject(). To include them:
const schema = new mongoose.Schema({ ... }, {
toJSON: { virtuals: true },
toObject: { virtuals: true }
});
Virtuals ≠ MongoDB Fields
Virtuals are:
- Not stored in the DB
- Only computed in JS code
- Useful for display, derived data, or linking logic
Real-Life Uses
- Full names, initials
- isAdult from age
- Slugs (/posts/my-awesome-title)
- Counting embedded items
Virtuals help keep your DB lean while your app logic stays rich and expressive.
Aggregation Framework in Mongoose
Aggregation is a powerful way to transform and analyze data in MongoDB. It’s used to group, filter, sort, project, and compute complex results.
Mongoose provides a .aggregate()
method to use MongoDB’s aggregation pipeline.
Basic Syntax
Model.aggregate([
{ $match: { age: { $gte: 18 } } },
{ $group: { _id: "$country", total: { $sum: 1 } } },
{ $sort: { total: -1 } }
]).then(result => console.log(result));
Operator | Description |
---|---|
$match |
Filters documents (like WHERE) |
$group |
Groups data by a field |
$project |
Includes/excludes fields |
$sort |
Sorts documents |
$limit |
Limits result count |
$skip |
Skips n documents |
$count |
Counts number of documents |
$sum , $avg , $min , $max
|
Math operators for groups |
Example: Average Age by Country
User.aggregate([
{ $group: { _id: "$country", avgAge: { $avg: "$age" } } }
]).then(console.log);
Real-World Use Cases
- Report generation (sales per region)
- Filtering + transforming dashboards
- Grouping comments by user
- Creating charts and summaries
Notes
- Aggregations bypass Mongoose schema rules.
- Always validate data before running aggregations.
- Aggregation is read-only—use for querying, not updating.
Aggregation helps you unlock deep insights from your data without writing complex backend logic.
Lean Queries in Mongoose
By default, Mongoose returns full Mongoose documents with lots of features like getters, setters, virtuals, and instance methods. But sometimes, you just want plain JavaScript objects for performance.
That’s where .lean()
comes in.
What is .lean()
?
Calling .lean()
tells Mongoose to skip the document enhancement and return plain JSON objects directly from MongoDB.
Example: Using .lean()
User.find({ isActive: true }).lean().then(users => {
console.log(typeof users[0]); // "object"
console.log(users[0] instanceof mongoose.Document); // false
});
Why use .lean()
?
Feature | Benefit |
---|---|
Faster queries | No conversion to Mongoose document |
Lower memory usage | Especially useful in large queries |
Better for APIs | Lightweight JSON responses |
When NOT to Use .lean()
- If you need to use virtuals, getters/setters, or instance methods
- If you rely on schema validation logic
Tip: .lean() with .populate()
Works fine! You can still populate referenced docs:
Post.find().populate("author").lean().then(posts => {
console.log(posts[0].author.name);
});
Compare Performance
// Without lean (Mongoose doc)
const normal = await User.find();
// With lean (plain object)
const fast = await User.find().lean();
Using .lean() gives you a huge performance boost for read-heavy applications, especially APIs and dashboards.
Mongoose Transactions (ACID)
MongoDB supports multi-document ACID transactions on replica sets and sharded clusters. Mongoose enables you to use this feature with sessions to ensure operations are all-or-nothing.
Why Use Transactions?
- Ensure atomicity (either all changes happen or none)
- Maintain data integrity
- Handle multi-document updates safely
Starting a Transaction in Mongoose
const session = await mongoose.startSession();
session.startTransaction();
try {
await User.create([{ name: "Alice" }], { session });
await Post.create([{ title: "Hello", author: "Alice" }], { session });
await session.commitTransaction(); // ✅ Commit changes
} catch (err) {
await session.abortTransaction(); // ❌ Rollback on error
console.error(err);
} finally {
session.endSession(); // 🧹 Always clean up
}
With async/await
const runTransaction = async () => {
const session = await mongoose.startSession();
try {
await session.withTransaction(async () => {
const user = await User.create([{ name: "John" }], { session });
const post = await Post.create([{ title: "Welcome", author: user[0]._id }], { session });
});
} catch (err) {
console.error("Transaction failed:", err);
} finally {
session.endSession();
}
};
Requirements
- MongoDB 4.0+ and replica set enabled (even a single-node replica set in dev)
- Use { session } in each query involved in the transaction
Real-World Use Cases
- Creating a user + default profile
- Placing an order + updating inventory
- Booking system: lock seat + confirm payment
Transactions are key for building reliable, enterprise-grade applications with MongoDB.
Mongoose Error Handling
Robust error handling is essential for building reliable applications. Mongoose provides a variety of error types to help identify and respond to issues.
Common Mongoose Errors
Error Type | Description |
---|---|
ValidationError |
Schema validation failed |
CastError |
Invalid ObjectId or data type |
MongoServerError |
Duplicate key, indexing issues, etc. |
DocumentNotFoundError |
When using .orFail() and no document is found |
DisconnectedError |
Mongoose lost connection to MongoDB |
Example: Basic Try-Catch Block
try {
const user = await User.create({ email: "invalid" });
} catch (err) {
console.error("Something went wrong:", err.message);
}
Handling ValidationError
try {
await User.create({ name: "" }); // name is required
} catch (err) {
if (err.name === "ValidationError") {
console.error("Validation failed:", err.errors);
}
}
Handling Invalid ObjectId (CastError)
try {
const user = await User.findById("invalid_id");
} catch (err) {
if (err.name === "CastError") {
console.error("Invalid ID format!");
}
}
Handling Duplicate Key (e.g., email already exists)
try {
await User.create({ email: "test@example.com" }); // email is unique
} catch (err) {
if (err.code === 11000) {
console.error("Duplicate key error:", err.keyValue);
}
}
Tips for Production
- Avoid leaking sensitive error info to users.
- Log full error stacks to your server logs.
- Use asyncHandler or error-handling middleware in Express.
Optional: Using .orFail()
await User.findById(userId).orFail(new Error("User not found"));
Proper error handling makes your app more secure, stable, and easier to debug.
Plugins in Mongoose
Plugins are reusable pieces of schema logic in Mongoose. They allow you to DRY (Don’t Repeat Yourself) your code by abstracting repeated patterns into reusable functions.
Why Use Plugins?
- Add timestamps automatically
- Enforce soft deletes
- Add pagination or search functionality
- Encrypt fields like passwords
- Audit log tracking
Creating a Simple Plugin
// plugins/timestampPlugin.js
module.exports = function timestampPlugin(schema) {
schema.add({
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date }
});
schema.pre("save", function (next) {
this.updatedAt = Date.now();
next();
});
};
Using the Plugin in a Schema
const mongoose = require("mongoose");
const timestampPlugin = require("./plugins/timestampPlugin");
const blogSchema = new mongoose.Schema({
title: String,
content: String
});
blogSchema.plugin(timestampPlugin); // 👈 Apply plugin here
const Blog = mongoose.model("Blog", blogSchema);
Plugin Name | Feature |
---|---|
mongoose-paginate-v2 |
Adds pagination to models |
mongoose-unique-validator |
Validates uniqueness before saving |
mongoose-delete |
Soft delete functionality |
mongoose-autopopulate |
Auto-populates refs |
mongoose-slug-generator |
Generates URL slugs from titles |
Install via npm:
npm install mongoose-paginate-v2
Use in schema:
const paginate = require("mongoose-paginate-v2");
schema.plugin(paginate);
Best Practices
- Keep plugins modular and reusable
- Prefix custom plugins with mongoose- if publishing
- Avoid plugin conflicts (e.g., two plugins modifying the same hook)
Mongoose plugins supercharge your schemas by keeping code clean, DRY, and powerful.
Mongoose Performance Optimization Tips
As your app scales, it’s essential to make your MongoDB and Mongoose operations as efficient as possible. Here are practical tips to improve performance.
1. Use .lean()
for Read-Only Queries
User.find({ isActive: true }).lean();
Returns plain JS objects (not full Mongoose documents)
Faster and less memory usage
2. Index Your Queries
const userSchema = new mongoose.Schema({
email: { type: String, index: true }
});
- Add indexes on frequently queried fields
- Use explain() to check query performance
3. Select Only Needed Fields
User.find().select("name email");
- Avoid fetching entire documents
- Reduces network and memory usage
4. Pagination Instead of .skip() on Large Datasets
- .skip() can be slow with large offsets
- Prefer range-based pagination using _id or timestamps
User.find({ _id: { $gt: lastId } }).limit(10);
5. Use bulkWrite for Batch Operations
Model.bulkWrite([
{ updateOne: { filter: { _id }, update: { $set: { field: value } } } },
{ deleteOne: { filter: { _id } } }
]);
- Ideal for large-scale updates/deletes
- Much faster than running individual operations
6. Avoid Unnecessary Middleware & Virtuals
Disable unused features if performance matters
const schema = new mongoose.Schema({...}, { virtuals: false });
7. Monitor Query Performance
Use .explain() to see query execution stats
User.find({ email: "a@example.com" }).explain("executionStats");
8. Use Connection Pooling in Production
mongoose.connect(MONGO_URI, {
maxPoolSize: 10, // Controls concurrent operations
});
Bonus: Enable Mongoose Debugging
mongoose.set("debug", true);
- Logs queries to console
- Useful for development
Optimizing Mongoose is about writing smarter queries, reducing load, and using MongoDB’s full power.
Mongoose Deployment & Production Tips
Before going live with your Node.js + Mongoose app, make sure your deployment is secure, stable, and optimized.
1. Use Environment Variables
Never hardcode credentials or database URLs.
# .env file
MONGO_URI=mongodb+srv://user:pass@cluster.mongodb.net/mydb
require("dotenv").config();
mongoose.connect(process.env.MONGO_URI);
2. Use Connection Options
Fine-tune performance and reliability with options.
mongoose.connect(process.env.MONGO_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
maxPoolSize: 10, // Connection pool
serverSelectionTimeoutMS: 5000 // Fail fast on bad DB
});
3. Enable Monitoring
- Use services like:
- MongoDB Atlas performance tab
- New Relic, Datadog, PM2 Metrics
mongoose.set("debug", true) (development only)
4. Use Replica Set for Production
- Enables transactions
- Improves availability and fault tolerance
Even in dev:
mongod --replSet rs0
Then run:
rs.initiate()
5. Error Handling Middleware (Express)
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).send("Something went wrong!");
});
- Prevents crashes
- Centralizes error responses
6. Secure Your Database
- Don’t expose MongoDB publicly
- Whitelist IPs in MongoDB Atlas
- Use SSL/TLS for connections
7. Backup & Disaster Recovery
- Set up automatic backups (Atlas supports this)
- Test your restore process
8. Restart Strategies
- Use process managers like:
- PM2
- Docker containers + health checks
- Auto-restart on crashes
9. Use Linting & Logging
- Lint your code with ESLint
- Log errors and queries for audit
npm install morgan
10. Testing Before Shipping
- Use jest, supertest, or mocha to test routes and DB logic
- Test all edge cases before deploying
Solid Mongoose apps are built not just on code, but on strong deployment practices and monitoring systems.
Final Thoughts
Congratulations on making it to the end of this comprehensive guide on MongoDB and Mongoose!
By now, you’ve covered:
- MongoDB fundamentals (CRUD, BSON, documents, collections)
- Mongoose setup and schema modeling
- Data validations and error handling
- Middleware, plugins, transactions, and performance tips
- Real-world backend structure using Express + MongoDB
- Deployment best practices and production strategies
What’s Next?
Now that you’ve got a strong foundation:
- Start building your own backend projects with MongoDB
- Try out more advanced features like aggregation pipelines, virtuals, and MongoDB Atlas Triggers
- Experiment with caching, rate limiting, and authorization layers
Stay Connected
If this guide helped you, feel free to:
- ⭐ Bookmark & Share it with fellow developers
- 🗣 Drop your thoughts, suggestions, or questions in the comments
- 🛠️ Follow me for upcoming full-stack guides and project tutorials
💬 “The best way to learn is to build.” So go ahead — spin up a project, explore real datasets, and turn this knowledge into something awesome!
Keep building. Keep learning. See you in the next one! 👋