Learn Mongoose 9 step by step. Define schemas and models, validate data, run pre and post middleware, and join documents with populate.

Bishal Biswas
Self Learner
What you will learn
By the end of this lesson you will know:
- What an ODM is and when Mongoose is worth using
- How to define a schema and turn it into a model
- How Mongoose validates data before it reaches MongoDB
- How middleware (pre and post hooks) lets you run code around saves and queries
- How populate fills in referenced documents for you
Before you start: this lesson assumes you’ve read Node.js Driver and Connection Pooling and Embedding vs Referencing.
What is an ODM?
ODM stands for Object Document Mapper. It is a library that sits between your code and the database and gives your documents a fixed shape, rules and helper methods.
The plain mongodb driver is happy to save anything. A typo like prcie: 49 goes straight into the database. Mongoose adds a layer that says “a product must have a name, and its price must be a number of at least 0” and stops bad data early.
Think of it like this: The plain driver is a blank notebook. Mongoose is a printed form with labelled boxes. You can still write what you need, but the form makes it hard to forget a box or write a word where a number should go.
| Plain driver | Mongoose |
|---|---|
| No built-in shape for documents | Schemas describe every field |
| You write validation yourself | Built-in validators (required, min, enum, …) |
| Raw documents | Model instances with methods |
Manual $lookup or extra queries for references |
populate() helper |
| A bit faster, less overhead | A bit slower, more convenience |
Which version?
The current major version of Mongoose is 9.x (released November 2025). Mongoose 8.x is still maintained, but new projects should start on 9. Mongoose 9 uses the MongoDB Node.js driver 7 under the hood and needs Node.js 20.19 or newer.
npm install mongoose
Note: The biggest change in Mongoose 9 is that pre middleware no longer receives a
next()callback. You write hooks asasyncfunctions instead. If you copy older tutorials that callnext(), they will break. This lesson uses the modern style.
Connecting
Mongoose keeps one default connection (with its own driver pool) for the whole app. Connect once at startup:
// db.js
import mongoose from "mongoose";
export async function connectDb() {
await mongoose.connect(process.env.MONGODB_URI ?? "mongodb://127.0.0.1:27017/shop", {
maxPoolSize: 20 // driver pool options work here too
});
console.log("Mongoose connected to", mongoose.connection.name);
}
// → Mongoose connected to shop
The database name (shop) comes from the end of the URI. To close the connection at shutdown, call await mongoose.disconnect().
Schemas: describing your documents
A schema lists the fields a document can have, their types and their rules. Let’s describe a product from our shop:
// models/Product.js
import mongoose from "mongoose";
const productSchema = new mongoose.Schema(
{
name: { type: String, required: true, trim: true },
category: {
type: String,
required: true,
enum: ["stationery", "electronics", "kitchen"]
},
price: { type: Number, required: true, min: 0 },
stock: { type: Number, default: 0, min: 0 },
tags: [String],
rating: { type: Number, min: 1, max: 5 }
},
{ timestamps: true } // adds createdAt and updatedAt automatically
);
Let’s read it line by line:
typesays what kind of value the field holds.required: truemeans the field must be present.trim: trueremoves spaces at the start and end of text.enumlists the only allowed values.minandmaxset number limits.defaultfills in a value when none is given.tags: [String]means “an array of strings”.{ timestamps: true }asks Mongoose to managecreatedAtandupdatedAt.
Models: the tool you actually use
A model is a class built from a schema. It is linked to one collection and has all the CRUD methods.
// models/Product.js (continued)
export const Product = mongoose.model("Product", productSchema);
Mongoose turns the name "Product" into the collection name products (lowercase and plural). That matches our running example.
Now use it:
import { connectDb } from "./db.js";
import { Product } from "./models/Product.js";
await connectDb();
// Create
const mug = await Product.create({
name: " Coffee Mug ",
category: "kitchen",
price: 249,
stock: 60,
tags: ["ceramic", "gift"],
rating: 4.8
});
console.log(mug.name, mug.createdAt);
// → Coffee Mug 2026-10-25T08:00:00.000Z (spaces trimmed, timestamp added)
// Read
const cheap = await Product.find({ price: { $lt: 100 } }).sort({ price: 1 });
console.log(cheap.map((p) => p.name));
// → [ 'Gel Pen', 'Notebook' ]
// Update
const updated = await Product.findOneAndUpdate(
{ name: "Gel Pen" },
{ $inc: { stock: -10 } },
{ returnDocument: "after" }
);
console.log(updated.stock);
// → 490
// Delete
await Product.deleteOne({ name: "USB Cable" });
Filters and update operators are exactly the MongoDB ones you already know. Mongoose just adds checks and conveniences around them.
Tip: In Mongoose 9, prefer
returnDocument: "after"over the oldernew: trueoption, which is now deprecated.
lean(): when you only need plain data
Every result from find() is a full Mongoose document with methods and change tracking. That costs memory. For read-only API responses, add .lean() to get plain JavaScript objects:
const list = await Product.find({ category: "stationery" }).lean();
// → [{ _id: ..., name: 'Notebook', price: 49, ... }, ...] (plain objects, faster)
Validation
Validation runs automatically when you call save() or create(). Let’s try to save a broken product:
try {
await Product.create({ name: "Broken Pen", category: "toys", price: -5 });
} catch (err) {
console.log(err.name);
console.log(Object.keys(err.errors));
console.log(err.errors.price.message);
}
Output:
ValidationError
[ 'category', 'price' ]
Path `price` (-5) is less than minimum allowed value (0).
Nothing was written to MongoDB. Mongoose caught both mistakes before sending the insert.
Custom validators
You can write your own rule with validate:
const customerSchema = new mongoose.Schema({
name: { type: String, required: true },
email: {
type: String,
required: true,
lowercase: true,
validate: {
validator: (v) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v),
message: (props) => `${props.value} is not a valid email`
}
},
city: String,
isMember: { type: Boolean, default: false },
joinedAt: { type: Date, default: Date.now }
});
export const Customer = mongoose.model("Customer", customerSchema);
Validation on updates
Here is a trap. By default, validators do not run on updateOne, updateMany or findOneAndUpdate. Turn them on with runValidators:
await Product.updateOne(
{ name: "Notebook" },
{ $set: { price: -1 } },
{ runValidators: true }
);
// → throws ValidationError: price is less than minimum allowed value (0)
Warning: Mongoose validation only protects writes that go through Mongoose. Another script using the plain driver or mongosh can still write bad data. For a hard guarantee, also add JSON Schema validation on the collection.
Middleware: pre and post hooks
Middleware (also called hooks) is code that runs automatically before (pre) or after (post) an action, such as save or find.
Think of it like this: Middleware is the security check at an airport. Every passenger (document) goes through it before boarding (saving), and you only had to set up the gate once.
In models/Product.js, put the hook code below between the schema definition and the mongoose.model(...) line. Order matters, as you’ll see in a moment.
A pre(‘save’) hook
Let’s keep a slug field in sync with the product name, so URLs look like /products/coffee-mug:
productSchema.add({ slug: { type: String, index: true } });
productSchema.pre("save", async function () {
// "this" is the document being saved
if (this.isModified("name")) {
this.slug = this.name.toLowerCase().replace(/\s+/g, "-");
}
});
Notice three things:
- We use a normal
function, not an arrow function, sothispoints to the document. - The hook is
asyncand has nonextparameter (Mongoose 9 style). this.isModified("name")makes sure we only rebuild the slug when the name changes.
To stop a save, just throw an error inside the hook:
productSchema.pre("save", async function () {
if (this.category === "electronics" && this.price < 100) {
throw new Error("Electronics must cost at least 100");
}
});
Note: Add hooks to the schema before calling
mongoose.model(...). Hooks added afterwards are ignored.
A post(‘save’) hook
Post hooks run after the action finishes and receive the saved document. They are good for logging or sending events:
productSchema.post("save", function (doc) {
console.log(`Saved product ${doc.name} (${doc._id})`);
});
// → Saved product Coffee Mug (6718a2f1c3b4e5d6f7a8b9c0)
Query middleware
Hooks can also run around queries. Here this is the query, not a document. A classic use is soft delete: hide archived products from every find:
productSchema.add({ archived: { type: Boolean, default: false } });
productSchema.pre(/^find/, function () {
// runs before find, findOne, findOneAndUpdate, ...
this.where({ archived: { $ne: true } });
});
Now Product.find() automatically skips archived products.
Warning:
savehooks run fordoc.save()andModel.create(), but not forupdateOne(),findOneAndUpdate()orinsertMany(). Those methods have their own hooks (pre("updateOne"),pre("findOneAndUpdate"),pre("insertMany")). Pick the hook that matches the method you actually call.
Populate: following references
In the Embedding vs Referencing lesson, orders stored a customerId. With Mongoose you declare that link with ref:
// models/Order.js
import mongoose from "mongoose";
const orderSchema = new mongoose.Schema(
{
customerId: { type: mongoose.Schema.Types.ObjectId, ref: "Customer", required: true },
items: [
{
productId: { type: mongoose.Schema.Types.ObjectId, ref: "Product" },
name: String,
qty: { type: Number, min: 1 },
price: Number
}
],
total: Number,
status: { type: String, enum: ["pending", "shipped", "delivered"], default: "pending" }
},
{ timestamps: true }
);
export const Order = mongoose.model("Order", orderSchema);
Now populate() replaces the ID with the real customer document:
const order = await Order.findOne({ status: "pending" })
.populate("customerId", "name email city") // only these customer fields
.lean();
console.log(order.customerId);
// → { _id: ObjectId("..."), name: 'Asha Roy', email: 'asha@example.com', city: 'Kolkata' }
You can populate inside arrays too:
await Order.find().populate("items.productId", "name stock");
Tip:
populate()runs an extra query per path behind the scenes. That is fine for a few documents, but on big lists it can be slow. For heavy reporting, an aggregation with$lookup(or embedding the data you always need) is usually faster.
Instance methods and statics
You can add your own helpers to a schema:
// on a single document
productSchema.methods.isInStock = function () {
return this.stock > 0;
};
// on the model itself
productSchema.statics.findByCategory = function (category) {
return this.find({ category });
};
// usage
const pen = await Product.findOne({ name: "Gel Pen" });
console.log(pen.isInStock()); // → true
const kitchen = await Product.findByCategory("kitchen");
Common mistakes
- Using arrow functions in hooks or methods. Arrow functions don’t have their own
this, sothis.isModifiedcrashes. Usefunction () {}. - Calling
next()in Mongoose 9. Pre hooks no longer getnext. Make the hookasyncandthrowto stop it. - Expecting validation on updates. Pass
{ runValidators: true }to update methods, or validators are skipped. - Defining hooks after
mongoose.model(). Register all hooks, methods and statics first, then create the model. - Populating huge lists. Each populate is another query. Use
.lean(), select few fields, or use$lookupfor big reports.
Practice
- Write a
Customerschema whereemailis required and lowercase, andisMemberdefaults tofalse. - Add a
pre("save")hook to theOrderschema that calculatestotalfromitems(qty × price). - Fetch the 5 newest orders and populate only the customer’s
name.
Show a possible answer
// 1.
const customerSchema = new mongoose.Schema({
name: { type: String, required: true },
email: { type: String, required: true, lowercase: true },
city: String,
isMember: { type: Boolean, default: false }
});
const Customer = mongoose.model("Customer", customerSchema);
// 2. (register before mongoose.model("Order", orderSchema))
orderSchema.pre("save", async function () {
this.total = this.items.reduce((sum, item) => sum + item.qty * item.price, 0);
});
// 3.
const latest = await Order.find()
.sort({ createdAt: -1 })
.limit(5)
.populate("customerId", "name")
.lean();
Key takeaways
- Mongoose is an ODM: schemas give documents a shape, models give you CRUD methods.
- Built-in validators (
required,min,enum, customvalidate) stop bad data before it is written. UserunValidatorsfor updates. - Middleware runs code before or after saves and queries. In Mongoose 9, write pre hooks as
asyncfunctions withoutnext(). populate()followsreflinks for you, but costs extra queries.- Use
.lean()for fast, read-only results, and back up Mongoose rules with collection-level schema validation.
Next lesson: Migrations and Schema Evolution
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready