Mongoose Middleware — pre and post Hooks

Mongoose middleware — also called hooks — are functions that run automatically before or after specific Mongoose operations. They are the mechanism for encapsulating cross-cutting concerns directly in the model layer: hashing passwords before saving, normalising email addresses, logging deletions, invalidating caches, updating related documents, and enforcing audit trails. Understanding the four types of middleware (document, model, aggregate, query), when each fires, and the correct ways to call next() or return a value from async hooks is fundamental to writing robust Mongoose-powered applications.

Middleware Types and When They Run

Type Registered On Operations
Document middleware schema.pre/post('save', fn) save, validate, deleteOne (on doc), updateOne (on doc)
Query middleware schema.pre/post('find', fn) find, findOne, findOneAndUpdate, findOneAndDelete, updateOne, updateMany, deleteOne, deleteMany, count, countDocuments
Aggregate middleware schema.pre/post('aggregate', fn) aggregate()
Model middleware schema.pre/post('insertMany', fn) insertMany()

pre vs post Hooks

Hook When It Runs this Refers To Common Uses
pre(‘save’) Before document is saved to DB Document being saved Hash password, set defaults, validate business rules
post(‘save’) After document is saved Saved document Send email, emit event, update related documents
pre(‘find’) Before find query executes Query object Add filters (soft delete, tenant isolation)
post(‘find’) After find returns results Results array Transform results, audit logging
pre(‘findOneAndUpdate’) Before update executes Query object Set updatedAt, validate update data
post(‘findOneAndDelete’) After document deleted Deleted document Clean up related records, audit log
Note: Query middleware (pre('find'), pre('findOne'), etc.) runs for every matching query method, but each must be registered separately. Registering pre('find') does NOT cover findOne, findById, findOneAndUpdate. Use the /^find/ regex to match all find operations: schema.pre(/^find/, function() { ... }). This is the standard pattern for adding a global soft-delete filter that applies to all queries.
Tip: In async pre-hooks for document middleware, you can either use async function and return, or use a callback-style function and call next(). For Mongoose 6+, the async/return style is preferred: schema.pre('save', async function() { this.password = await hash(this.password, 12) }) — no next() needed. If you call next() in an async function, Mongoose may proceed before the async operation completes. Pick one style and be consistent.
Warning: In query middleware (pre('findOneAndUpdate')), this is the Query object, not the document. You cannot access the document being updated with this.field. To access the update data: this.getUpdate(). To access the filter: this.getFilter(). To access the document being updated, you must explicitly query for it: const doc = await this.model.findOne(this.getFilter()) — but this adds an extra query. Consider using document middleware (save) rather than query middleware when you need to inspect or modify the document.

Complete Middleware Examples

const bcrypt = require('bcryptjs');

// ── pre('save') — hash password before saving ─────────────────────────────
userSchema.pre('save', async function() {
    if (!this.isModified('password')) return;   // skip if not changed
    this.password = await bcrypt.hash(this.password, 12);
    this.passwordChangedAt = new Date();
});

// ── pre('save') — normalise email ─────────────────────────────────────────
userSchema.pre('save', function(next) {
    if (this.isModified('email')) {
        this.email = this.email.toLowerCase().trim();
    }
    next();
});

// ── post('save') — send welcome email after user registration ─────────────
userSchema.post('save', async function(doc) {
    if (doc.wasNew) {   // only on initial creation
        try {
            await emailQueue.add('welcome', { userId: doc._id, email: doc.email });
        } catch (err) {
            // Log error but don't fail the save — email is non-critical
            logger.error('Failed to queue welcome email', { userId: doc._id, err: err.message });
        }
    }
});

// Track if document is new (for post hook access)
userSchema.pre('save', function() {
    this.wasNew = this.isNew;
});

// ── pre(/^find/) — global soft-delete filter ──────────────────────────────
// Automatically exclude deleted documents from ALL find operations
taskSchema.pre(/^find/, function() {
    // 'this' is the Query object
    this.find({ deletedAt: { $exists: false } });
});

// To bypass the soft-delete filter (e.g. admin endpoint):
// Task.find({ status: 'deleted' }).setOptions({ bypassSoftDelete: true })
// Check in middleware: if (this.getOptions().bypassSoftDelete) return;

// ── pre('findOneAndUpdate') — auto-set updatedAt ──────────────────────────
taskSchema.pre('findOneAndUpdate', function() {
    this.set({ updatedAt: new Date() });
});

// ── pre('findOneAndUpdate') — validate update prevents invalid transitions ─
taskSchema.pre('findOneAndUpdate', async function() {
    const update = this.getUpdate();
    const newStatus = update.$set?.status || update.status;

    if (newStatus === 'completed') {
        const task = await this.model.findOne(this.getFilter()).lean();
        if (!task) throw new Error('Task not found');
        if (task.status === 'completed') {
            throw new Error('Task is already completed');
        }

        // Set completedAt automatically when completing
        this.set({ 'completedAt': new Date() });
    }
});

// ── post('findOneAndDelete') — clean up related data ─────────────────────
taskSchema.post('findOneAndDelete', async function(deletedTask) {
    if (!deletedTask) return;

    // Delete all attachments from storage
    for (const attachment of deletedTask.attachments || []) {
        await storageService.delete(attachment.url).catch(err =>
            logger.warn('Failed to delete attachment', { url: attachment.url, err: err.message })
        );
    }

    // Log deletion to audit trail
    await AuditLog.create({
        action:     'TASK_DELETED',
        resourceId: deletedTask._id,
        userId:     deletedTask.user,
        timestamp:  new Date(),
        data:       { title: deletedTask.title },
    }).catch(err => logger.error('Failed to create audit log', { err: err.message }));
});

// ── post('deleteMany') — bulk delete cleanup ──────────────────────────────
taskSchema.post('deleteMany', async function(result) {
    logger.info('Bulk delete completed', { deletedCount: result.deletedCount });
});

// ── pre('aggregate') — add soft-delete filter to all aggregations ─────────
taskSchema.pre('aggregate', function() {
    // Add $match at the start of the pipeline to exclude deleted tasks
    this.pipeline().unshift({ $match: { deletedAt: { $exists: false } } });
});

// ── User pre-remove — cascade delete user's tasks ─────────────────────────
userSchema.pre('deleteOne', { document: true, query: false }, async function() {
    const userId = this._id;

    // Soft-delete all tasks belonging to this user
    await Task.updateMany(
        { user: userId },
        { $set: { deletedAt: new Date() } }
    );

    logger.info('Cascade soft-deleted user tasks', { userId });
});

How It Works

Step 1 — Hooks Form a Chain Connected by next()

Multiple pre-hooks for the same operation execute in registration order, each calling next() to pass control to the next hook. If any hook calls next(err) with an error argument, subsequent hooks are skipped and the error propagates to the calling code. If you forget to call next() in a callback-style hook, Mongoose hangs indefinitely — the operation never completes. Always call next() or use the async/return style.

Step 2 — Document Middleware this Is the Document

In schema.pre('save'), this refers to the specific Mongoose document being saved. You can access all its fields, call this.isModified(), set new values with this.field = value, and call this.save() (carefully — this would cause infinite recursion). Document middleware fires for doc.save() and Model.create() but NOT for findByIdAndUpdate() or updateOne().

Step 3 — Query Middleware this Is the Query Object

In schema.pre('findOneAndUpdate'), this is a Mongoose Query object. Use this.getFilter() to see the query filter, this.getUpdate() to see the update document, and this.set() to add extra fields to the update. This is useful for auto-setting updatedAt or appending audit fields without requiring every calling function to include them manually.

Step 4 — The pre(/^find/) Regex Matches All Find Operations

Registering a pre-hook with a regex like /^find/ matches all Mongoose operations whose name starts with “find”: find, findOne, findById, findOneAndUpdate, findOneAndDelete. This is the standard way to add global query modifiers — soft-delete filters, tenant isolation, permission scoping — that apply universally without having to modify every individual query in every service and controller.

Step 5 — post Hooks Receive the Operation’s Result

Post hooks receive the result of the operation as their first argument. post('save', function(doc) {}) receives the saved document. post('findOneAndDelete', function(deletedDoc) {}) receives the document that was deleted (or null if not found). This is the correct place for side effects that depend on the operation’s result: sending emails after a user is created, cleaning up files after a document is deleted, emitting events for real-time updates.

Real-World Example: Complete User Model Hooks

// Complete middleware setup for User model

// 1. Hash password only when changed
userSchema.pre('save', async function() {
    if (!this.isModified('password')) return;
    this.password = await bcrypt.hash(this.password, 12);
    if (!this.isNew) this.passwordChangedAt = new Date();
});

// 2. Normalise email
userSchema.pre('save', function() {
    if (this.isModified('email')) this.email = this.email.toLowerCase().trim();
});

// 3. Track isNew for post hook
userSchema.pre('save', function() { this._wasNew = this.isNew; });

// 4. After creation: queue welcome email
userSchema.post('save', async function(doc) {
    if (doc._wasNew) {
        await emailQueue.add('welcome', { userId: doc._id.toString(), name: doc.name, email: doc.email });
    }
});

// 5. Auto-set updatedAt on query updates
userSchema.pre('findOneAndUpdate', function() {
    this.set({ updatedAt: new Date() });
});

// 6. Soft delete cascade — when user account is soft-deleted
userSchema.pre('findOneAndUpdate', async function() {
    const update = this.getUpdate();
    if (update.$set?.isActive === false || update.isActive === false) {
        const userId = this.getFilter()._id;
        if (userId) {
            // Cascade: deactivate all sessions
            await Session.deleteMany({ userId });
            logger.info('Cleared sessions on account deactivation', { userId });
        }
    }
});

Common Mistakes

Mistake 1 — Registering pre(‘find’) expecting it to cover findOne

❌ Wrong — findOne not covered:

schema.pre('find', function() {
    this.find({ deletedAt: { $exists: false } });
});
// Task.findById(id) — uses findOne — NOT covered! Returns deleted tasks!

✅ Correct — use regex to cover all find variants:

schema.pre(/^find/, function() {
    this.find({ deletedAt: { $exists: false } });
});

Mistake 2 — Forgetting to call next() in callback-style hooks

❌ Wrong — Mongoose hangs indefinitely:

schema.pre('save', function(next) {
    this.email = this.email.toLowerCase();
    // Missing: next() — save never completes!
});

✅ Correct — always call next() or use async return style:

// Option A: call next()
schema.pre('save', function(next) { this.email = this.email.toLowerCase(); next(); });

// Option B: async/return (preferred in Mongoose 6+)
schema.pre('save', async function() { this.email = this.email.toLowerCase(); });

Mistake 3 — Using pre(‘save’) for logic that also needs to run on findByIdAndUpdate

❌ Wrong — password hash only runs on save(), not on findByIdAndUpdate():

userSchema.pre('save', async function() {
    if (this.isModified('password')) this.password = await bcrypt.hash(this.password, 12);
});
// Then in service:
await User.findByIdAndUpdate(id, { password: newPlainPassword });
// Plain text password stored — pre('save') never ran!

✅ Correct — hash in the service layer before calling update:

const hashed = await bcrypt.hash(newPassword, 12);
await User.findByIdAndUpdate(id, { password: hashed, passwordChangedAt: new Date() });

Quick Reference

Hook this Value Common Use
pre('save') Document Hash password, normalise data
post('save') Saved document Send email, emit events
pre(/^find/) Query Add soft-delete filter
pre('findOneAndUpdate') Query Auto-set updatedAt
post('findOneAndDelete') Deleted document Cleanup files, audit log
pre('aggregate') Aggregate Add pipeline stage
pre('deleteOne', { document: true }) Document Cascade delete
Get query filter Query ctx this.getFilter()
Get update data Query ctx this.getUpdate()
Add to update Query ctx this.set({ updatedAt: new Date() })

🧠 Test Yourself

A pre('findOneAndUpdate') hook should automatically set updatedAt to the current time on every update. How is this done correctly inside the hook?