Mongoose Validation: Enforce Schema Invariants and Avoid Update Traps
Trace one Plan model through saves, query updates and database errors. The worked cases show exactly where Mongoose rules hold and where an update can bypass them.
KnowledgeGate Team
Exam prep & CS education

You carefully declared required, enum, min and validation middleware. Then an updateOne() wrote a record that should have been impossible. A Mongoose schema is not one universal firewall: request parsing, document saves, query updates and MongoDB indexes enforce different rules at different moments. Follow the Plan write path from exact input values to validation failures and consistent HTTP errors.
Mongoose validation starts with the write path
Request validation enforces the JSON contract. A Mongoose schema casts values and declares path rules. Document validation runs before save(). Query update validation is narrower and opt-in. MongoDB indexes and server constraints are the final concurrency-safe layer.
Write path | What runs |
|---|---|
| Document validation |
| Document validation after modification |
| Update validation only with |
Direct MongoDB driver write | No Mongoose validation |
The official Mongoose validation guide documents these behaviours. For the basic vocabulary, start with Mongoose Schemas and Models: Validation and Population. Invariants hold on document saves but can be bypassed by narrower update validation.
Build one exact schema with types, validators and an index
const planSchema = new mongoose.Schema({
code: {
type: String, required: true, trim: true, uppercase: true,
match: /^[A-Z][A-Z0-9_]{2,19}$/
},
currency: { type: String, required: true, enum: ['INR', 'USD'] },
pricePaise: { type: Number, required: true, min: 0, max: 1000000 },
discountPaise: { type: Number, required: true, min: 0, default: 0 },
status: { type: String, enum: ['DRAFT', 'ACTIVE', 'ARCHIVED'], default: 'DRAFT' },
startsAt: { type: Date, required: true },
endsAt: { type: Date, required: true }
}, { strict: 'throw' });
planSchema.pre('validate', function () {
if (this.discountPaise > this.pricePaise) {
this.invalidate('discountPaise', 'discountPaise must not exceed pricePaise');
}
if (this.startsAt >= this.endsAt) {
this.invalidate('endsAt', 'endsAt must be after startsAt');
}
});
planSchema.index({ code: 1 }, { unique: true });
const Plan = mongoose.model('Plan', planSchema);The valid document is code='STARTER', currency='INR', pricePaise=50000, discountPaise=5000, status='ACTIVE', startsAt='2026-08-01T00:00:00.000Z', endsAt='2026-08-31T23:59:59.000Z'. Its net is 50000 - 5000 = 45000 paise. The values illustrate the net-price calculation.
type: Number is not strict request validation. Mongoose may cast pricePaise: '50000' to 50000, although the API may reject that string. 'fifty thousand' instead causes a cast failure.
Enforce cross-field invariants on the complete document
Built-in min checks one value. The middleware compares fields, so it needs the complete document.
The baseline
50000/5000and August window pass.With
discountPaise=55000, validation fails ondiscountPaisewithdiscountPaise must not exceed pricePaise. The excess is55000 - 50000 = 5000paise.With
startsAt='2026-09-10T00:00:00.000Z'andendsAt='2026-09-01T00:00:00.000Z', validation fails onendsAtwithendsAt must be after startsAt. The end precedes the start by 9 days.
An asynchronous uniqueness validator is unsafe: two requests can both see no duplicate before either commits. Keep the index and translate its duplicate-key result separately.

Keep request, validation, casting and database errors distinct
An application policy might map pricePaise: '50000' to HTTP 400, 55000 > 50000 to 422, duplicate code='STARTER' to 409, and an unexpected server failure to 500. These are not Mongoose defaults. Teams may choose other 4xx mappings, but one translator must stay consistent. For the HTTP side, review Web Technologies for Teaching Exams: HTML, HTTP, DNS Guide.
Inspect identities. Uncastable pricePaise gives a ValidationError containing errors.pricePaise as a CastError. An enum or invariant gives a ValidatorError. A unique collision is MongoDB code 11000, not a ValidationError. Unknown isAdmin can give a StrictModeError.
Return { error: 'VALIDATION_FAILED', fields: { discountPaise: 'must not exceed pricePaise' } }. Never return stacks, driver messages or database names.
Trace the update traps that make a schema look ineffective
Run each case from a fresh baseline STARTER document.
Case | Operation | Result |
|---|---|---|
1 |
| Stored |
2 | Same | Rejected by |
3 |
| Rejected by |
4 |
| Status checked; omitted required paths are not rechecked |
Starting at 50000, {$inc: { pricePaise: -60000 }} computes 50000 + (-60000) = -10000. The official guide says update validators do not cover $inc, even with runValidators: true. Recheck it before generalising to other operators.
{$set: { discountPaise: 55000 }} with validators does not execute document pre('validate'), so it can leave 55000 beside stored 50000. Here this is the query, and this.get(...) sees update values, not a complete document.

Choose a safe update pattern for each invariant
For a cross-field change, load the document, modify it, then save it:
const plan = await Plan.findOne({ code: 'STARTER' });
plan.set({ discountPaise: 55000 });
await plan.save();The invariant sees 55000 > 50000, rejects the save and leaves storage unchanged. A patch with pricePaise=70000, discountPaise=15000, endsAt='2026-09-30T23:59:59.000Z' passes: 15000 <= 70000, the end follows 1 August, and 70000 - 15000 = 55000 paise.
For an independent path, use findOneAndUpdate(filter, { $set: { status: 'ARCHIVED' } }, { runValidators: true, returnDocument: 'after' }). The official tutorial defines the return option. This is still not full document validation.
When the invariant must be atomic under concurrency, encode it in the filter:
await Plan.updateOne(
{ code: 'STARTER', pricePaise: { $gte: 15000 } },
{ $set: { discountPaise: 15000 } },
{ runValidators: true }
);At 50000, matchedCount=1; on a fresh 10000 document, matchedCount=0, so return a named conflict. Multi-document invariants need a transaction or redesigned model.
Test every write path, not only the schema definition
With fresh fixtures, test nine cases: valid save; missing code; currency='EUR'; pricePaise='fifty thousand'; discountPaise=55000; duplicate STARTER; $set pricePaise=-1 without validators; the same update with validators; $inc:-60000. Expect, respectively: success, required, enum, CastError, invariant, code 11000, stored -1, rejection with stored 50000, and stored -10000.
Assert err.name, err.errors.<path>.name, path, code 11000, matchedCount and stored values, not complete messages. After recreating indexes, await model initialisation before testing duplicates.
In review, identify the write path, predict casting, explain why unique is not a validator, state what this means, then repair the partial update.
Mongoose validation: the short version and next step
Validate requests. Use built-ins for path rules and documents for cross-field rules. Opt into query validators, respecting path and operator limits. Keep uniqueness in an index. Translate request, Mongoose and driver errors separately.
Implement STARTER, run each write path on fresh fixtures, and inspect storage. Replace one unsafe update with load, set, save, and another with the conditional atomic update.
For a focused backend path, use the Node.js, Express.js & MongoDB Course. For the wider application stack, continue with the MERN Stack Course - Full Stack Development. The Coding & Skill Development Courses page is the neutral catalogue hub.
Keep learning

JavaScript Event Delegation: Bubbling, target and currentTarget with Runnable Examples
Learn how browser events travel through the DOM, then build one delegated listener that handles nested controls and rows added later.

JavaScript Operators: Types, Precedence and Runnable Examples
Learn how JavaScript arithmetic, assignment, comparison, logical and modern operators behave by tracing one score program from inputs to final output.

JavaScript Data Types and Coercion: Beginner Tutorial with Runnable Examples
Learn how JavaScript stores values, copies primitives and objects, converts input, compares values and produces surprising output. Trace each rule in the console.

JavaScript Conditionals and Loops: Beginner Tutorial with Runnable Examples
Learn how JavaScript chooses a branch and repeats work. Build from small syntax examples to a complete score classifier with a traced average.