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

Updated 23 Sep 20265 min read

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

new Plan(payload).save()

Document validation

plan.set(patch); await plan.save()

Document validation after modification

updateOne() or findOneAndUpdate()

Update validation only with runValidators: true

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

js
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/5000 and August window pass.

  • With discountPaise=55000, validation fails on discountPaise with discountPaise must not exceed pricePaise. The excess is 55000 - 50000 = 5000 paise.

  • With startsAt='2026-09-10T00:00:00.000Z' and endsAt='2026-09-01T00:00:00.000Z', validation fails on endsAt with endsAt 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.

Five-stage write path for a STARTER plan: request, casting and path validators pass, then the document invariant stops 55000 over 50000.

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

$set: { pricePaise: -1 }, no option

Stored -1; update validators did not run

2

Same $set, runValidators: true

Rejected by min: 0

3

$unset: { currency: 1 }, runValidators: true

Rejected by required

4

$set: { status: 'ARCHIVED' }, runValidators: true

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.

Five update cases on the STARTER plan: save and runValidators reject -1, while a plain $set, $inc and $set discount slip through.

Choose a safe update pattern for each invariant

For a cross-field change, load the document, modify it, then save it:

js
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:

js
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.