MongoDB Made Easy: A Beginner’s Guide to NoSQL

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! 👋

Leave a Reply