Schema Validation in Mongoose: Best Practices
Mongoose validation is a backup to middleware validation. Here is how to use it well.
Schema Validation in Mongoose: Best Practices
Mongoose validation runs at the database layer. It is a backup to your middleware validation (Zod). Here are best practices.
1. Use Built-in Validators
required, min, max, minlength, maxlength, enum, match. These cover most cases.
email: { type: String, required: true, match: /^\S+@\S+\.\S+$/ }
age: { type: Number, min: 18, max: 120 }
gender: { type: String, enum: ['male', 'female', 'other'] }
2. Use Custom Validators for Complex Rules
phone: {
type: String,
validate: {
validator: v => /^\+?\d{10,15}$/.test(v),
message: 'Invalid phone number'
}
}
3. Use runValidators on Updates
Validation does not run on findByIdAndUpdate or updateOne by default. Pass { runValidators: true }:
await User.findByIdAndUpdate(id, update, { runValidators: true, new: true });
4. Use unique Indexes for Uniqueness
unique: true is not a validator; it is an index. It does not produce a ValidationError. It produces a 11000 duplicate key error. Catch it in your handler:
try {
await user.save();
} catch (err) {
if (err.code === 11000) return res.status(409).json({ error: 'Email already in use' });
throw err;
}
5. Use pre('validate') for Normalization
Normalize fields before validation runs:
userSchema.pre('validate', function(next) {
if (this.email) this.email = this.email.toLowerCase().trim();
next();
});
6. Do Not Put Business Logic in Validators
Validators should check data shape, not business rules. "User must be 18" is data shape. "User cannot signup if they have unpaid invoices" is business logic. Put the latter in a service.
7. Combine With Zod
Use Zod at the middleware layer as the primary check. It returns clean 400 errors with field-level messages. Use Mongoose validation as a backup for anything middleware missed. Defense in depth.
8. Handle ValidationError Cleanly
try {
await user.save();
} catch (err) {
if (err.name === 'ValidationError') {
const fields = Object.keys(err.errors);
return res.status(400).json({ errors: fields });
}
throw err;
}
The Takeaway
Mongoose validation: use built-in validators (required, min, max, minlength, maxlength, enum, match), custom validators for complex rules, runValidators on updates, unique indexes for uniqueness (catch the 11000 error), pre('validate') for normalization, no business logic in validators, combine with Zod, and handle ValidationError cleanly.
Use built-in validators (required, min, max, minlength, maxlength, enum, match). Add custom validators with the validate property for complex rules. Use runValidators: true on updates. Combine with Zod at the middleware layer for defense in depth.
Mongoose runs validation on .save() and .create() but not on findByIdAndUpdate or updateOne by default. Pass { runValidators: true, new: true } on every update to enforce validation.
No. It is an index. It does not produce a ValidationError. It produces a 11000 duplicate key error. Catch it in your handler: if (err.code === 11000) return res.status(409).json({ error: 'Email already in use' }).
No. Validators should check data shape (user must be 18). Business rules (user cannot signup if they have unpaid invoices) belong in a service. Mixing them makes validators hard to test and reason about.
Catch it in your handler. err.name === 'ValidationError' has an errors object keyed by field. Return 400 with the field list. Let other errors fall through to the error handler.
Ready to master Node.js completely?
Want to upskill yourself, crack your next interview, and get your dream job? Join our comprehensive course to dive deeper with high-quality video tutorials, solve interview questions, and a premium community.
Master Node.js
Want to upskill yourself, crack your next interview, and get your dream job? Join our comprehensive course.

