Middleware
Learn how Mongoose middleware works — pre and post hooks for documents and queries — with real examples like password hashing and logging.
Middleware
Mongoose middleware — also called hooks — let you run functions automatically before or after certain operations. This is one of Mongoose's most powerful features. You can hash a password before saving a user, recalculate a total after an update, log every delete, or send a welcome email after a document is created.
Types of Middleware
Mongoose has four categories of middleware, based on what kind of operation they hook into:
| Type | Hooks into |
|---|---|
| Document middleware | save, validate, remove, init |
| Query middleware | find, findOne, updateOne, findOneAndUpdate, deleteOne, etc. |
| Aggregate middleware | aggregate() |
| Model middleware | insertMany() |
Each type can have a pre hook (runs before the operation) or a post hook (runs after).
The Lifecycle of save()
The clearest way to understand middleware is to see when each hook fires during a .save() call:
Validation and hooks run in a strict order — pre('validate') always runs before validation itself, and pre('save') always runs after validation passes but before the database write.
Pre Hooks
A pre hook runs before the operation. Define it on the schema, before creating the model.
Syntax
schema.pre('operationName', function (next) {
// do something
next(); // call next() to continue
});Pre and post hooks must be defined before you call mongoose.model(). If you define the model first and add hooks after, they will not work.
pre('save') — The Most Common Hook
Example — Hash password before saving
This is the classic Mongoose middleware example. Never store plain text passwords — hash them automatically before saving.
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const userSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true },
password: { type: String, required: true }
});
// pre-save hook — runs before every save()
userSchema.pre('save', async function (next) {
// 'this' refers to the document being saved
// Only hash the password if it was modified (or is new)
if (!this.isModified('password')) {
return next();
}
// Hash the password
this.password = await bcrypt.hash(this.password, 10);
next();
});
const User = mongoose.model('User', userSchema);// Usage — password is automatically hashed
const user = await User.create({
email: "teacher@school.com",
password: "mypassword123"
});
console.log(user.password); // "$2b$10$N9qo8uLOickgx2ZMRZoMye..." — hashed, not plain textisModified() — Avoid Re-hashing
this.isModified('password') checks if the password field was changed in this save operation. Without this check, every time you update the user — even just their email — the password would get re-hashed on top of the already-hashed value, breaking login.
// Without isModified check — BUG
userSchema.pre('save', async function (next) {
this.password = await bcrypt.hash(this.password, 10); // re-hashes every save!
next();
});
// With isModified check — CORRECT
userSchema.pre('save', async function (next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 10);
next();
});School System Example — Auto-Calculate Average Score
Let's add a pre-save hook to our Student schema that automatically calculates an averageScore field whenever grades change:
const studentSchema = new mongoose.Schema({
name: String,
grade: String,
grades: [
{
subject: String,
score: Number,
semester: Number
}
],
averageScore: {
type: Number,
default: 0
}
});
// Recalculate averageScore before every save
studentSchema.pre('save', function (next) {
if (this.isModified('grades')) {
if (this.grades.length === 0) {
this.averageScore = 0;
} else {
const total = this.grades.reduce((sum, g) => sum + g.score, 0);
this.averageScore = Math.round((total / this.grades.length) * 10) / 10;
}
}
next();
});
const Student = mongoose.model('Student', studentSchema);// Usage
const student = await Student.create({
name: "Ali Hassan",
grade: "10th",
grades: [
{ subject: "Math", score: 88, semester: 1 },
{ subject: "Physics", score: 92, semester: 1 },
{ subject: "English", score: 79, semester: 1 }
]
});
console.log(student.averageScore); // 86.3 — calculated automaticallyPost Hooks
A post hook runs after the operation completes. Useful for logging, sending notifications, or cleanup.
Syntax
schema.post('operationName', function (doc, next) {
// do something with the saved document
next();
});Example — Log after saving
studentSchema.post('save', function (doc, next) {
console.log(`Student saved: ${doc.name} (${doc._id})`);
next();
});Example — Send welcome notification after creation
studentSchema.post('save', async function (doc, next) {
// Only on the first save (creation), not on updates
if (this.wasNew) {
console.log(`Welcome email queued for ${doc.name}`);
// await sendWelcomeEmail(doc.email);
}
next();
});
// Track whether this was a new document
studentSchema.pre('save', function (next) {
this.wasNew = this.isNew;
next();
});Query Middleware
Query middleware hooks into query methods like find, findOne, updateOne, findOneAndUpdate, and deleteOne. The key difference from document middleware — this refers to the query, not the document.
pre('find') — Filter out soft-deleted documents
const studentSchema = new mongoose.Schema({
name: String,
grade: String,
deleted: { type: Boolean, default: false }
});
// Automatically exclude soft-deleted students from all find() queries
studentSchema.pre(/^find/, function (next) {
this.where({ deleted: { $ne: true } });
next();
});The /^find/ regex matches find, findOne, findById, findOneAndUpdate, and so on — any method starting with "find".
// This automatically becomes:
// Student.find({ grade: "10th", deleted: { $ne: true } })
const students = await Student.find({ grade: "10th" });Now every query automatically excludes soft-deleted students — without needing to remember to add the filter every time.
post('find') — Logging
studentSchema.post('find', function (docs) {
console.log(`Query returned ${docs.length} students`);
});pre('findOneAndUpdate') — Update timestamp
studentSchema.pre('findOneAndUpdate', function (next) {
this.set({ updatedAt: new Date() });
next();
});Document Middleware vs Query Middleware — Key Difference
This distinction trips up many Mongoose developers:
// Document middleware — 'this' is the DOCUMENT
studentSchema.pre('save', function (next) {
console.log(this.name); // works — 'this' is the student document
next();
});
// Query middleware — 'this' is the QUERY
studentSchema.pre('findOneAndUpdate', function (next) {
console.log(this.getQuery()); // the filter object
console.log(this.getUpdate()); // the update object
// this.name does NOT work here — 'this' is not a document
next();
});| Document middleware | Query middleware | |
|---|---|---|
| Triggers on | .save(), .create() | .find(), .updateOne(), .findOneAndUpdate(), etc. |
this refers to | The document | The query object |
| Runs validation | Yes | No (unless runValidators: true) |
updateOne() and findOneAndUpdate() do NOT trigger document middleware like pre('save'). If your password hashing or other save-time logic is in pre('save'), it will not run when you use updateOne(). Either use document-fetch-and-save patterns, or also add equivalent logic to query middleware.
Async Middleware with Errors
If a pre hook throws an error or calls next(error), the operation is aborted:
studentSchema.pre('save', async function (next) {
// Check if email domain is allowed
if (!this.email.endsWith('@school.com')) {
return next(new Error('Email must be a school.com address'));
}
next();
});try {
await Student.create({
name: "Ali Hassan",
email: "ali@gmail.com" // not @school.com
});
} catch (error) {
console.log(error.message); // "Email must be a school.com address"
}Using Async/Await Without next()
Modern Mongoose supports async functions without calling next() — just don't accept the next parameter:
// Old style — with next()
studentSchema.pre('save', function (next) {
this.name = this.name.trim();
next();
});
// Modern style — async/await, no next()
studentSchema.pre('save', async function () {
this.name = this.name.trim();
// no next() needed — Mongoose waits for the promise
});If the async function throws, Mongoose treats it as an error and aborts the operation — no need to call next(error) manually.
Real School System Example — Complete Middleware Setup
const mongoose = require('mongoose');
const studentSchema = new mongoose.Schema(
{
name: { type: String, required: true, trim: true },
email: { type: String, required: true, unique: true, lowercase: true },
grade: { type: String, enum: ["9th", "10th", "11th", "12th"] },
grades: [
{
subject: String,
score: Number,
semester: Number
}
],
averageScore: { type: Number, default: 0 },
deleted: { type: Boolean, default: false },
deletedAt: Date
},
{ timestamps: true }
);
// 1. Trim name automatically (in case trim option is missed elsewhere)
studentSchema.pre('save', function (next) {
if (this.isModified('name')) {
this.name = this.name.trim();
}
next();
});
// 2. Recalculate average score whenever grades change
studentSchema.pre('save', function (next) {
if (this.isModified('grades')) {
if (this.grades.length === 0) {
this.averageScore = 0;
} else {
const total = this.grades.reduce((sum, g) => sum + g.score, 0);
this.averageScore = Math.round((total / this.grades.length) * 10) / 10;
}
}
next();
});
// 3. Log every new student creation
studentSchema.post('save', function (doc) {
if (doc.isNew) {
console.log(`New student enrolled: ${doc.name}`);
}
});
// 4. Exclude soft-deleted students from all find queries
studentSchema.pre(/^find/, function (next) {
this.where({ deleted: { $ne: true } });
next();
});
// 5. Soft delete instead of hard delete
studentSchema.pre('deleteOne', { document: true, query: false }, async function (next) {
// Convert deleteOne on a document into a soft delete
this.deleted = true;
this.deletedAt = new Date();
await this.save();
next(new Error('Use soft delete instead')); // prevent actual deletion
});
module.exports = mongoose.model('Student', studentSchema);Quick Reference
| Hook | Type | this refers to | Common use |
|---|---|---|---|
pre('save') | Document | The document | Hash passwords, calculate fields, format data |
post('save') | Document | The saved document | Logging, sending notifications |
pre('find') / pre(/^find/) | Query | The query | Add default filters (soft delete) |
post('find') | Query | The results array | Logging, transforming results |
pre('findOneAndUpdate') | Query | The query | Auto-set updatedAt, validation |
pre('deleteOne') | Document or Query | Depends on options | Soft delete, cleanup |
Start with pre('save') for document-level logic and pre(/^find/) for query-level defaults like soft delete filters — these two cover the majority of real-world middleware needs. Add other hooks only when you have a specific need. Too much middleware scattered across schemas can make code hard to follow — keep each hook focused on one clear responsibility.