Mongoose Middleware: The Subtle Difference Between Document and Query Hooks
Mongoose middleware looks uniform but behaves differently on documents vs queries. This post shows why your post('save') hook skips findOneAndUpdate, how to filter soft deletes across all find variants, and the migration path from deprecated remove() to deleteOne document middleware.
24 Nov 2025, 15:02 UTC

The problem: hooks that don't fire when you expect them to
You add a post('save') hook to send a welcome email. It works when you call user.save(), but nothing happens when you run User.findOneAndUpdate(). You add a pre('find') hook to filter soft-deleted records. It works for User.find() but not for User.findOne(). The middleware API looks uniform, yet the behavior shifts depending on whether you're on a document or a query.
The thesis: Mongoose middleware is powerful because it intercepts the exact operation you name, but the distinction between document middleware (runs on a single document instance) and query middleware (runs on the Query object before the command reaches MongoDB) changes what this refers to, what data you can access, and whether the hook fires at all.
Document middleware: the instance lifecycle
Document hooks — save, validate, init, and the deprecated remove — are attached to a schema and fire when you call the corresponding method on a document instance. Inside a document pre-hook, this is the document. You can read or mutate fields, call this.isModified('password'), and decide whether to proceed.
A common pattern is hashing a password only when it actually changes:
const userSchema = new Schema({ email: String, passwordHash: String });
userSchema.pre('save', async function (next) {
if (!this.isModified('passwordHash')) return next();
const hash = await bcrypt.hash(this.passwordHash, 12);
this.passwordHash = hash;
next();
});
userSchema.post('save', function (doc, next) {
// fire-and-forget audit log
eventEmitter.emit('user:saved', { id: doc._id });
next();
});
Key points: the pre-hook must call next() or return a promise; forgetting either hangs the operation indefinitely. The post-hook receives the saved document as its first argument and also calls next() to continue the chain. Errors thrown in pre-hooks skip remaining pre-hooks and the wrapped operation, then flow to post-hooks with an error argument.
Query middleware: intercepting the command
Query hooks — find, findOne, updateOne, deleteOne, countDocuments, etc. — bind to the Query object. Here this is the query, not a document. You inspect filters with this.getQuery(), updates with this.getUpdate(), and modify options via this.setOptions().
Soft-delete filtering is the classic example:
userSchema.pre('find', function () {
this.where({ deletedAt: null });
});
userSchema.pre('findOne', function () {
this.where({ deletedAt: null });
});
userSchema.pre('countDocuments', function () {
this.where({ deletedAt: null });
});
Each query method needs its own hook registration; pre('find') does not automatically cover findOne. Also, pre-hooks on updateOne or findOneAndUpdate run before the update is sent but do not have access to the updated document — only the query conditions and update payload.
Worked example: password hash + audit log + soft delete
Putting it together in a single schema shows how the two middleware families coexist:
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const userSchema = new mongoose.Schema({
email: { type: String, unique: true },
passwordHash: String,
deletedAt: Date
});
// Document middleware
userSchema.pre('save', async function (next) {
if (!this.isModified('passwordHash')) return next();
this.passwordHash = await bcrypt.hash(this.passwordHash, 12);
next();
});
userSchema.post('save', function (doc, next) {
console.log('[audit] user saved', doc._id);
next();
});
// Query middleware for soft delete
userSchema.pre(/^find/, function () {
this.where({ deletedAt: null });
});
// Migration from deprecated doc.remove()
userSchema.pre('deleteOne', { document: true, query: false }, function (next) {
this.deletedAt = new Date();
next();
});
const User = mongoose.model('User', userSchema);
Run it:
await mongoose.connect('mongodb://localhost:27017/demo');
const u = new User({ email: '[contact removed]', passwordHash: 'plain' });
await u.save(); // triggers doc pre('save') → post('save')
await User.findOne({ email: '[contact removed]' }); // query pre('findOne') adds deletedAt: null
await u.deleteOne(); // triggers doc pre('deleteOne') with this = document
Observe the console order: document pre → document post → query pre (for each find variant). The deleteOne hook uses the Mongoose 7+ signature { document: true, query: false } to run on the document instance, replacing the old pre('remove').
Trade-offs and pitfalls
- Lean queries bypass document middleware.
User.find().lean()returns plain objects;post('init')andpost('save')never fire. If you need hooks, don't uselean()or move logic to query middleware. - Arrow functions break
thisbinding. In document middleware,pre('save', () => { ... })loses the document reference. Usefunction() {}or bind explicitly. - Registration order matters. Hooks run in the order they are added. Plugins that call
schema.pre()before your code will execute first. - Circular model references. Calling
this.model('Other')inside middleware can trigger compilation cycles. Use lazyrequireormongoose.model('Other')after all schemas are registered. - Async pre-hooks without
next()or a returned promise hang forever. Always return the promise or callnext().
How to verify your hooks
- Enable debug logging:
mongoose.set('debug', true)to see the generated MongoDB commands and confirm query middleware mutates the filter. - Write a minimal script that creates a document, calls
save(), then runsfind()anddeleteOne(), logging entry/exit of each hook. - Throw an error in a pre-hook and catch the rejected promise; confirm the post-hook receives the error as its first argument.
- Run
Model.find().lean()with apost('init')hook; the hook should not fire.
Closing checklist
Before shipping middleware-heavy code:
- Map every hook to its middleware type (document vs query vs aggregate).
- Confirm
thisis what you expect in each hook. - Ensure every async pre-hook returns a promise or calls
next(). - Test lean queries separately — they skip document hooks entirely.
- Migrate any
pre('remove')topre('deleteOne', { document: true, query: false }).
Middleware gives you a clean interception layer, but only when you respect the boundary between a document instance and a query object. Treat them as two separate APIs that happen to share the pre/post vocabulary.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.