Dev
Your Mongoose pre('save') Hook Is Being Silently Skipped by Half Your Update Calls
Dev.toUnited States · NORTH AMERICA
This is one of the more consequential Mongoose gotchas because of exactly what tends to live inside a pre('save') hook, password hashing, slug generation, validation logic, and exactly which methods s...
This is one of the more consequential Mongoose gotchas because of exactly what tends to live inside a pre('save') hook, password hashing, slug generation, validation logic, and exactly which methods silently bypass it without ever telling you.
The Setup
// models/User.ts
const userSchema = new Schema({
email: String,
password: String,
});
userSchema.pre('save', async function (next) {
if (this.isModified('password')) {
this.password = await bcrypt.hash(this.password, 12);
}
next();
});
export const User = model('User', userSchema);
Create a new user with User.create() or new User(data).save(), and this works exactly as intended. The password arrives hashed, every time, without the rest of your codebase needing to remember to call bcrypt.hash manually anywhere. It feels like a solved problem.
Where It Actually Breaks
// A password reset flow
export async function resetPassword(userId: string, newPassword: string) {
await User.findByIdAndUpdate(userId, { password: newPassword });
}
This runs without error. It updates the user's password field in the database. It also never calls pre('save'), which means the password gets stored in plain text, directly in your database, with no error, no warning, and a function name, findByIdAndUpdate, that reads as a completely reasonable, idiomatic way to update a user record.
findByIdAndUpdate, findOneAndUpdate, updateOne, and updateMany all operate directly against MongoDB's update commands. They don't instantiate a full Mongoose document and call .save() on it internally, they issue an update operation straight to the database. Mongoose's document middleware, including pre('save'), only runs on document instances that actually go through .save(). These query-based update methods structurally never touch that code path at all.
Why This Specifically Gets Missed
Both approaches, .save() and findByIdAndUpdate, look like equally valid, equally idiomatic ways to update a Mongoose document, and most Mongoose tutorials use both interchangeably without flagging that they behave completely differently with respect to middleware. A codebase that uses .save() for creating users and findByIdAndUpdate for updating them, which is an extremely common and reasonable-looking pattern, ends up with password hashing that works perfectly at signup and silently doesn't exist at all for a password reset, profile update, or admin-initiated password change, unless someone happens to specifically test that exact path with a tool that shows raw database contents.
The Fix: Use Query Middleware, Not Just Document Middleware
Mongoose provides a separate middleware hook specifically for query-based update methods:
userSchema.pre('findOneAndUpdate', async function (next) {
const update = this.getUpdate() as any;
if (update.password) {
update.password = await bcrypt.hash(update.password, 12);
}
next();
});
This specifically targets findOneAndUpdate and findByIdAndUpdate (which is implemented as a wrapper around findOneAndUpdate internally). updateOne and updateMany need their own separate middleware registration, since Mongoose treats each query method as its own distinct middleware hook rather than one unified "any kind of update" event:
userSchema.pre('updateOne', async function (next) {
const update = this.getUpdate() as any;
if (update.password) {
update.password = await bcrypt.hash(update.password, 12);
}
next();
});
A More Defensive Alternative: Standardize on One Update Path
Registering middleware separately for every query method Mongoose offers is viable but easy to let drift out of sync as new update call sites get added later by someone who doesn't know this distinction exists. A more durable fix for anything genuinely sensitive, like password handling, is routing all updates through a single service function that always uses .save() internally, rather than letting findByIdAndUpdate calls for that model spread throughout the codebase:
// services/userService.ts
export async function updateUser(userId: string, data: Partial<UserInput>) {
const user = await User.findById(userId);
if (!user) throw new Error('User not found');
Object.assign(user, data);
await user.save(); // always goes through pre('save') middleware
return user;
}
This trades a small amount of performance, an extra read before the write, for a guarantee that every update for this model goes through the exact same validation and transformation logic, with no separate middleware registration required for each query method and no risk of a future findByIdAndUpdate call quietly skipping it.
The Rule
Anything inside a pre('save') hook only runs for updates that go through .save(), never for findOneAndUpdate, findByIdAndUpdate, updateOne, or updateMany. For any logic where skipping it has real consequences, password hashing being the clearest example, either register the equivalent query middleware for every update method your codebase actually uses, or standardize on a single service function that always goes through .save().
I route every sensitive field update through a dedicated service layer exactly like this in the auth code behind my Next.js and MongoDB templates, specifically because this gap is easy to introduce the moment a second developer, or a future version of yourself, reaches for the more convenient-looking findByIdAndUpdate call.
Get the templates: https://pixelanas.gumroad.com
Go check your own password reset, profile update, and admin edit flows for a findByIdAndUpdate or updateOne call on a model with password hashing in pre('save'). If you find one, check what's actually sitting in that field in your database right now. Drop what you find in the comments.
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751