How to Define Custom Validation That Runs on Save in Mongoose
Use the validate option in your schema field definition to attach a validator function that Mongoose executes automatically during the save process.
When creating a schema in Mongoose, you can enforce data integrity by defining custom validation that runs on save. This client-side validation layer catches errors before documents ever reach the MongoDB server, providing immediate feedback and reducing network overhead. According to the mongodb/mongo source code, while the server can enforce its own validation rules via JSON Schema validators defined in src/mongo/db/document_validation/validator.cpp, Mongoose validators run first in the application layer.
Understanding the Mongoose Validation Pipeline
Mongoose executes validation logic in a specific sequence during the save operation. Understanding this flow helps you place your custom validation logic in the correct hook or option.
Pre-save Hooks vs Path Validators
The pre('save') middleware runs before any built-in validation occurs. While you can perform side-effects here, actual field validation should reside in the validate option so that errors surface as ValidationError objects with proper path attribution.
After pre-save hooks complete, Mongoose iterates through every path in the document and executes all validators attached to that path. This is where your custom validation that runs on save actually executes.
Document-level Validation
For constraints that involve multiple fields (e.g., ensuring endDate is after startDate), use document-level validation. You can attach validators to specific paths that reference this to access other fields, or use the schema-level validate option introduced in Mongoose v6.
Defining Field-Level Custom Validators
The most common approach for custom validation that runs on save is the validate option within a field definition. This accepts an object with a validator function and an optional message property.
Synchronous Validators
For simple checks that don't require I/O, provide a synchronous function that returns true for valid values and false for invalid ones.
const userSchema = new mongoose.Schema({
username: {
type: String,
required: true,
minlength: 3,
// Custom validation runs on every save
validate: {
validator: function (v) {
// Disallow whitespace
return /^\S+$/.test(v);
},
message: props => `${props.value} contains spaces – usernames must be a single word.`
}
}
});
Asynchronous Validators
When validation requires database queries or external API calls, return a Promise or use the callback pattern (pre-v4 style). Mongoose will await the resolution before completing the save operation.
const emailSchema = new mongoose.Schema({
email: {
type: String,
required: true,
validate: {
// Return a promise – Mongoose will await it
validator: async function (v) {
// `this` is the document being saved
const count = await this.constructor.countDocuments({ email: v });
// `this.isNew` ensures we don't count the document itself on updates
return this.isNew ? count === 0 : count <= 1;
},
message: 'Email address already in use.'
}
}
});
Schema-Level and Document-Level Validation
For cross-field validation or complex business logic, attach validators at the schema level or use pre-validate hooks.
const orderSchema = new mongoose.Schema({
quantity: { type: Number, required: true, min: 1 },
pricePerItem: { type: Number, required: true, min: 0 },
totalPrice: { type: Number }
});
// Compute totalPrice before validation
orderSchema.pre('validate', function (next) {
this.totalPrice = this.quantity * this.pricePerItem;
next();
});
// Ensure totalPrice matches the calculation (extra safety)
orderSchema.path('totalPrice').validate(function (v) {
return v === this.quantity * this.pricePerItem;
}, 'Total price does not match quantity * pricePerItem.');
Mongoose v6+ also supports a schema-level validate option for document-wide checks:
const productSchema = new mongoose.Schema(
{
name: { type: String, required: true },
sku: { type: String, required: true }
},
{
// Runs after all path validators; useful for cross-field checks
validate: {
validator: function (doc) {
// Example: SKU must start with the first three letters of the name
return doc.sku.startsWith(doc.name.slice(0, 3).toUpperCase());
},
message: props => `SKU "${props.sku}" does not match product name.`
}
}
);
MongoDB Server-Side Validation Context
While Mongoose handles client-side validation, the MongoDB server itself can enforce document structure through JSON Schema validators defined at the collection level. According to the mongodb/mongo source code, these mechanisms operate independently:
src/mongo/db/catalog/coll_mod.cpp– Implements thecollModcommand that attaches JSON Schema validators to collections.src/mongo/db/document_validation/validator.cpp– Core logic that evaluates documents against collection-level validators during insert and update operations.src/mongo/db/commands/validate.cpp– Handles thevalidatecommand for checking collection consistency.
Mongoose custom validation that runs on save executes before the document is transmitted to the server, providing immediate feedback. The server-side validator acts as a final safety net for writes that bypass Mongoose (e.g., direct shell updates or writes from other applications).
Summary
- Field-level validation uses the
validateoption in schema definitions to run custom logic during save operations. - Synchronous validators return boolean values, while asynchronous validators return Promises for I/O-dependent checks.
- Document-level validation handles cross-field constraints via
schema.path().validate(),pre('validate')hooks, or the schema-levelvalidateoption (v6+). - Mongoose validation runs client-side before documents reach the MongoDB server, which maintains its own validation layer in files like
src/mongo/db/document_validation/validator.cpp.
Frequently Asked Questions
How do I ensure custom validators run when using findOneAndUpdate?
By default, Mongoose does not run validators on update operations. To enforce custom validation that runs on save behavior during updates, pass the { runValidators: true } option to findOneAndUpdate, updateOne, or updateMany. Without this flag, Mongoose skips validation for performance reasons.
Can I access other fields within a custom validator function?
Yes. In field-level validators, this refers to the document being saved, allowing you to access other fields via this.otherField. For schema-level validators (v6+), the function receives the entire document as an argument. Alternatively, use pre('validate') middleware to compute derived values before validation runs.
What is the difference between pre('save') and the validate option?
pre('save') middleware executes before validation occurs and is intended for side-effects like hashing passwords or updating timestamps. The validate option defines validators that run during the validation phase and properly populate ValidationError objects with path-specific error messages. Use validate for constraint checking; use pre('save') for data transformation.
How do I skip validation for a specific save operation?
To bypass custom validation that runs on save for a single operation, call doc.save({ validateBeforeSave: false }). You can also disable validation globally for a schema by setting schema.set('validateBeforeSave', false), though this is not recommended for production environments as it removes the client-side safety layer.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →