Two Places Validation Can Live
Since MongoDB accepts any document you send it, something has to decide what counts as a valid record. You have two independent places to put those rules, and they protect against different failures.
Application-level validation means Mongoose schemas, or your own checks in Node.js. It runs before anything reaches the database, produces friendly error messages you can show a user, and can express rules that involve other systems. Its weakness is that it only protects data that arrives through your application. A quick fix typed into mongosh, a data import script, or a second service written by a different team can all write straight past it.
Database-level validation means a $jsonSchema validator attached to the collection. The server checks every write from every source, so nothing can slip round the back. Its weakness is the opposite: the error message is generic, and it is not the place to express business rules that change often.
Serious projects use both, with different jobs. Mongoose handles the rich, user-facing rules; the database validator is a safety net for a handful of things that must never be wrong, such as required identity fields and correct types.
- Use Mongoose for: helpful messages, conditional rules, rules that touch other data, and formatting such as trim and lowercase
- Use a database validator for: required fields, correct BSON types, and simple bounds that must hold no matter who writes
- Use a unique index for: uniqueness. No validator of either kind can enforce it reliably against simultaneous writes
- Validate untrusted input in your API layer as well, before it ever reaches the model
- Never rely on browser-side validation as anything more than a convenience for the user. Anyone can send a request directly to your API, so every rule that matters must also exist on the server.
Mongoose Built-in Validators
Mongoose ships with validators for the common cases. required insists the field is present; min and max bound numbers and dates; minlength and maxlength bound strings; enum restricts a string to a list; match tests it against a regular expression.
Each of them accepts a custom message, written as an array of [rule, message] — and you should always supply one, because the default messages are written for developers, not for the person filling in your form. Inside the message, {VALUE} is replaced by whatever the user actually submitted, and {PATH} by the field name.
Two limits are worth knowing. required on a string treats an empty string as missing, which is usually what you want but occasionally surprises. And unique, as the previous lesson warned, is not a validator at all — it creates an index, and a clash arrives as a MongoDB duplicate key error rather than a Mongoose validation error.
const productSchema = new Schema({
name: {
type: String,
required: [true, 'Product name is required'],
minlength: [2, 'Name must be at least 2 characters'],
maxlength: [100, 'Name cannot be longer than 100 characters'],
trim: true
},
price: {
type: Number,
required: [true, 'Price is required'],
min: [0, 'Price cannot be negative']
},
category: {
type: String,
enum: {
values: ['Electronics', 'Furniture', 'Books'],
message: '{VALUE} is not a valid category'
}
},
sku: {
type: String,
required: true,
unique: true, // an index, not a validator
match: [/^[A-Z]{3}-\d{4}$/, 'SKU must look like ABC-1234']
},
launchDate: { type: Date, min: '2020-01-01' }
}); - Validators run on the fields you actually touch. Adding a new
requiredfield to a schema does not make existing documents invalid, and they will keep saving happily until something writes to that field. Migrating old data is a separate job that the schema will not do for you.
Custom Validators for Real Rules
When a rule cannot be expressed with the built-ins, write a function. A validator receives the value, and returns true if it is acceptable. Use a regular function rather than an arrow function when you need this to be the document, because arrow functions do not have their own this — that single detail causes a lot of confusion in Mongoose code.
Access to the whole document is what makes custom validators powerful. "The discounted price must be below the list price" and "the end date must be after the start date" are rules about a relationship between two fields, and no per-field validator can express them.
Validators may also be asynchronous: return a promise and Mongoose waits for it. That is how you check something that requires a database lookup. Use it sparingly, though, because it means a query on every save, and for uniqueness in particular a unique index remains the correct and race-proof answer.
const bookingSchema = new Schema({
startDate: { type: Date, required: true },
endDate: {
type: Date,
required: true,
validate: {
validator: function (value) {
return value > this.startDate; // `this` is the document
},
message: 'End date must be after the start date'
}
},
listPrice: { type: Number, required: true },
salePrice: {
type: Number,
validate: {
validator: function (v) {
return v == null || v <= this.listPrice;
},
message: props => `Sale price ${props.value} is above the list price`
}
},
seats: {
type: Number,
validate: {
validator: Number.isInteger,
message: 'Seats must be a whole number'
}
}
}); - In a custom validator that runs during an update,
thisis the query rather than the document, so cross-field rules like the one above silently stop working. If you need them on updates, pass{ runValidators: true, context: 'query' }and write the validator to handle both cases — or simply load the document and usesave(), which is easier to reason about.
Database-Level Validation with $jsonSchema
A collection validator is a filter the server applies to every insert and update. The usual form is $jsonSchema, which describes the document's expected structure: which fields are required, what BSON type each holds, and simple constraints such as a minimum value or a pattern. Attach it when you create the collection, or add it later with the collMod command.
Two options control how strict it is. validationLevel chooses which writes are checked: strict checks everything, while moderate checks inserts and updates to documents that already satisfy the rules, leaving existing invalid documents alone. validationAction chooses what happens on a failure: error rejects the write, while warn allows it and records a warning in the server log. Adding a validator to a collection with existing data is much safer with moderate and warn first — look at the log, fix what it finds, then tighten to strict and error.
One gotcha is specific to the shell. Every plain number you type in mongosh is stored as a double, so a rule of bsonType: "int" will reject { age: 22 } even though it looks like an integer. Either write NumberInt(22), or use bsonType: "number", which accepts every numeric type. Applications using a driver may send true integers, which is why a validator can pass in code and fail in the shell.
db.createCollection("members", {
validator: {
$jsonSchema: {
bsonType: "object",
title: "member document validation",
required: ["name", "email", "joinedAt"],
properties: {
name: { bsonType: "string", minLength: 2 },
email: { bsonType: "string", pattern: "^.+@.+\\..+$" },
age: { bsonType: "number", minimum: 0, maximum: 120 },
role: { enum: ["student", "teacher", "admin"] },
joinedAt: { bsonType: "date" }
}
}
},
validationLevel: "strict",
validationAction: "error"
})
db.members.insertOne({ name: "Ananya" })
// MongoServerError: Document failed validation
// Add or change the rules on an existing collection
db.runCommand({
collMod: "members",
validator: { $jsonSchema: { bsonType: "object", required: ["name", "email"] } },
validationLevel: "moderate",
validationAction: "warn"
}) - Find out how much existing data would fail before you switch a validator to
error. The$norof your own schema does the job:db.members.find({ $nor: [ { $jsonSchema: { ... } } ] })returns exactly the documents that do not match.
Handling the Errors Properly
Validation is only useful if the failure reaches the user as something they can act on. Mongoose raises a ValidationError whose errors property is an object keyed by field name, each entry carrying the message you wrote. Turning that into a per-field response is a few lines, and it is the difference between "Something went wrong" and "Price cannot be negative" appearing next to the right input box.
Two other error names are worth recognising. A CastError means a value could not be converted to the declared type — the classic case is an invalid id in a URL, where /users/abc reaches findById and cannot become an ObjectId. And code 11000 is the duplicate key error from a unique index, which is not a Mongoose validation error at all but which your users will hit constantly on signup.
All three of these are the client's mistake, so they should produce a 400-class response, not a 500. A server that returns 500 for a duplicate email address is telling its users, and its monitoring, that the server broke — when in fact it worked exactly as designed.
try {
const product = await Product.create(req.body);
res.status(201).json(product);
} catch (err) {
if (err.name === 'ValidationError') {
const fields = {};
for (const [path, e] of Object.entries(err.errors)) fields[path] = e.message;
return res.status(400).json({ error: 'Validation failed', fields });
}
if (err.name === 'CastError') {
return res.status(400).json({ error: `Invalid value for ${err.path}` });
}
if (err.code === 11000) {
const field = Object.keys(err.keyValue)[0];
return res.status(409).json({ error: `That ${field} is already registered` });
}
next(err); // genuinely unexpected — let the error handler log it as a 500
} - You can check a document without writing it:
await doc.validate()throws the sameValidationErrorthatsave()would, which is handy for a multi-step form that should report problems before the final step.
