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 |
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.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.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() }) |