DocsHub
Mongoose

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:

TypeHooks into
Document middlewaresave, validate, remove, init
Query middlewarefind, findOne, updateOne, findOneAndUpdate, deleteOne, etc.
Aggregate middlewareaggregate()
Model middlewareinsertMany()

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:

student.save() called pre('validate') hooks run Schema validation runs(required, min, max, enum, etc.) post('validate') hooks run pre('save') hooks run Document written to MongoDB post('save') hooks run save() resolves

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 text

isModified() — 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 automatically

Post 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 middlewareQuery middleware
Triggers on.save(), .create().find(), .updateOne(), .findOneAndUpdate(), etc.
this refers toThe documentThe query object
Runs validationYesNo (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

HookTypethis refers toCommon use
pre('save')DocumentThe documentHash passwords, calculate fields, format data
post('save')DocumentThe saved documentLogging, sending notifications
pre('find') / pre(/^find/)QueryThe queryAdd default filters (soft delete)
post('find')QueryThe results arrayLogging, transforming results
pre('findOneAndUpdate')QueryThe queryAuto-set updatedAt, validation
pre('deleteOne')Document or QueryDepends on optionsSoft 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.

On this page