Skip to content

Fix: Mongoose ValidationError — Document Failed to Save

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix Mongoose ValidationError when saving documents — required field errors, type cast failures, custom validator errors, and how to handle validation in Express APIs properly.

The Error

Calling .save() or Model.create() throws a ValidationError:

ValidationError: User validation failed:
  email: Path `email` is required.,
  age: Cast to Number failed for value "abc" at path `age`

Or a custom validator fails:

ValidationError: User validation failed:
  email: Invalid email format

Or in an Express API the error surfaces as an unhandled promise rejection or a 500 response when it should be a 400.

Why This Happens

Mongoose validates documents against the schema before writing to MongoDB. Validation runs on .save(), .create(), and Model.validate() — not on update operations by default. This default split between create-time and update-time validation is the root of most production incidents involving Mongoose: a schema change that adds a constraint will reject new inserts but leave existing documents (and updates that target them) silently non-compliant.

The other source of confusion is how Mongoose layers casting on top of validation. Casting happens first: Mongoose attempts to coerce the input value to the schema’s declared type ("42" becomes 42, "2024-01-01" becomes a Date). If casting fails, you get a CastError wrapped inside the ValidationError. If casting succeeds, the cast value flows into the validators. That means a custom validator never sees the raw user input — it sees whatever Mongoose’s caster decided. Validators that assume the input is a string can misbehave when the caster has already turned it into a Number or ObjectId.

Common causes:

  • Required fields missing — the schema marks a field as required: true but the document does not include it.
  • Type mismatch — a string is passed for a Number field, or a non-date value for a Date field. Mongoose tries to cast values but fails for clearly incompatible types.
  • Custom validator rejected the value — a validate function in the schema returned false or threw.
  • Enum constraint violated — a field with enum: ['active', 'inactive'] received an unlisted value.
  • minlength / maxlength / min / max violated — the value is outside the allowed range.
  • Subdocument validation triggered by parent save — a nested schema’s required field is missing, and the error is reported with a dotted path like address.city.
  • Strict mode dropped fields silently — fields not declared in the schema are stripped (default behaviour with strict: true), so a typo in a field name looks like a missing-required failure.
  • Schema deployed before the migration — the running API enforces a new constraint, but the data layer still has legacy documents that violate it. The next update on those documents fails.

Fix 1: Catch and Handle ValidationError in Express

The most important fix — catch Mongoose errors and return a proper HTTP response instead of a 500:

// routes/users.js
const { Error: MongooseError } = require('mongoose');

app.post('/users', async (req, res) => {
  try {
    const user = new User(req.body);
    await user.save();
    res.status(201).json(user);
  } catch (error) {
    if (error instanceof MongooseError.ValidationError) {
      // Extract human-readable messages from each failed path
      const messages = Object.values(error.errors).map(e => e.message);
      return res.status(400).json({ error: 'Validation failed', details: messages });
    }
    // Other errors (network, auth, etc.)
    res.status(500).json({ error: 'Internal server error' });
  }
});

Inspect the full error structure:

catch (error) {
  if (error.name === 'ValidationError') {
    console.log('Validation errors:');
    for (const [field, err] of Object.entries(error.errors)) {
      console.log(`  ${field}: ${err.message} (kind: ${err.kind}, value: ${err.value})`);
    }
  }
}

Global error handler middleware (Express):

// middleware/errorHandler.js
const { Error: MongooseError } = require('mongoose');

module.exports = function errorHandler(err, req, res, next) {
  if (err instanceof MongooseError.ValidationError) {
    const details = Object.values(err.errors).map(e => ({
      field: e.path,
      message: e.message,
      value: e.value,
    }));
    return res.status(400).json({ error: 'Validation failed', details });
  }

  if (err.code === 11000) {
    // Duplicate key error
    const field = Object.keys(err.keyValue)[0];
    return res.status(409).json({ error: `${field} already exists` });
  }

  console.error(err);
  res.status(500).json({ error: 'Internal server error' });
};

// app.js — register AFTER all routes
app.use(errorHandler);

Fix 2: Fix Required Field Errors

const userSchema = new Schema({
  email: { type: String, required: true },
  name:  { type: String, required: [true, 'Name is required'] }, // Custom message
  role:  { type: String, required: true, default: 'user' },      // Default satisfies required
});

Common mistake — required + default:

// This is fine — default value satisfies required
role: { type: String, required: true, default: 'user' }

// This fails if role is explicitly set to undefined
const user = new User({ email: '[email protected]', role: undefined });
// role is undefined — default is not applied when explicitly set to undefined
// Fix: don't send the field at all, or validate input before constructing the document

Conditional required:

const orderSchema = new Schema({
  paymentMethod: { type: String, required: true },
  cardLast4: {
    type: String,
    required: function() {
      return this.paymentMethod === 'card'; // Required only for card payments
    },
  },
});

Fix 3: Fix Type Cast Errors

Mongoose silently coerces compatible types (e.g., "42"42 for Number), but throws for incompatible ones:

CastError: Cast to Number failed for value "abc" at path "age"

Validate and sanitize input before passing to Mongoose:

app.post('/users', async (req, res) => {
  const { name, email, age } = req.body;

  // Validate types before touching Mongoose
  if (age !== undefined && isNaN(Number(age))) {
    return res.status(400).json({ error: 'age must be a number' });
  }

  try {
    const user = await User.create({ name, email, age: age ? Number(age) : undefined });
    res.status(201).json(user);
  } catch (err) {
    // ...
  }
});

Use Zod or Joi for input validation before Mongoose:

import { z } from 'zod';

const createUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  age: z.number().int().min(0).max(150).optional(),
});

app.post('/users', async (req, res) => {
  const parsed = createUserSchema.safeParse(req.body);
  if (!parsed.success) {
    return res.status(400).json({ error: parsed.error.flatten() });
  }

  const user = await User.create(parsed.data); // Types guaranteed valid
  res.status(201).json(user);
});

Fix 4: Fix Custom Validator Errors

const userSchema = new Schema({
  email: {
    type: String,
    required: true,
    validate: {
      validator: function(v) {
        return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
      },
      message: props => `${props.value} is not a valid email address`,
    },
  },
  age: {
    type: Number,
    min: [0, 'Age cannot be negative'],
    max: [150, 'Age seems too high: {VALUE}'],
  },
  role: {
    type: String,
    enum: {
      values: ['admin', 'user', 'moderator'],
      message: '{VALUE} is not a valid role',
    },
  },
});

Async validator (e.g., check uniqueness):

email: {
  type: String,
  validate: {
    validator: async function(email) {
      // 'this' is the document being validated
      const existing = await User.findOne({ email, _id: { $ne: this._id } });
      return !existing; // Return false if email already taken
    },
    message: 'Email already in use',
  },
},

Note: Async validators only run during .save() and .validate(), not during findOneAndUpdate() or updateOne(). For update operations, add { runValidators: true }.

Fix 5: Enable Validation on Update Operations

By default, Mongoose skips validation for update* and findOneAndUpdate* methods:

// Does NOT run validators by default
await User.updateOne({ _id: id }, { age: -5 });
await User.findOneAndUpdate({ _id: id }, { email: 'invalid' });

// Run validators explicitly
await User.updateOne(
  { _id: id },
  { age: -5 },
  { runValidators: true }
);

await User.findOneAndUpdate(
  { _id: id },
  { email: 'invalid' },
  { runValidators: true, new: true } // new: true returns the updated document
);

Enable globally for all queries:

mongoose.set('runValidators', true); // All update operations validate by default

Warning: runValidators: true with this-referencing validators (like conditional required) can behave unexpectedly in updates because this is the query, not the document. Test thoroughly after enabling globally.

Fix 6: Validate Without Saving

Use .validate() to check a document without writing to the database — useful for testing or pre-flight checks:

const user = new User({ email: 'not-an-email', age: -1 });

try {
  await user.validate();
  console.log('Document is valid');
} catch (err) {
  if (err.name === 'ValidationError') {
    console.log('Validation errors:', err.errors);
  }
}

// Validate a specific path only
try {
  await user.validate('email');
} catch (err) {
  console.log('Email error:', err.errors.email.message);
}

In Production: Incident Lens

A surge of ValidationError in production is a data-integrity incident. The blast radius is per-endpoint: anywhere your API writes to the affected collection, document creation or update fails. Reads keep working. Background jobs that consume validated documents may halt because they were enqueued before the schema tightened and cannot now persist their updates. The user-facing symptom is “save button does nothing” or “submission failed” on the specific feature backed by that collection.

The most common trigger is a schema deploy that adds required: true to a previously optional field, or tightens an enum. The new code rejects documents the old code happily wrote. Until you either roll back or migrate the legacy data, every write to a non-compliant document fails.

Detection signals to wire up:

  • 4xx error-rate spike on POST/PUT/PATCH endpoints (your global error handler should map ValidationError to 400)
  • APM tagged metric: mongoose.validation.failed{path=email} counted per failing path
  • A log filter matching ValidationError: with a path/value pair extracted
  • A datadog/grafana panel showing failing-write rate by collection over time
  • Synthetic test that round-trips a representative document for each critical collection every 5 minutes

Recovery playbook:

  1. Roll back the schema deploygit revert and redeploy. The previous schema accepted whatever data is in the database.
  2. If rollback is not an option, soft-validate — change the new constraint to a warning that logs but does not throw. Patch the validator to call console.warn and return true. Ship the migration in parallel.
  3. Quantify the bad data — run a counting query in a read-only console: db.users.countDocuments({ email: { $exists: false } }). Confirm how many documents violate the new constraint.
  4. Backfill the missing data — write a migration script (idempotent, batched) to populate defaults: db.users.updateMany({ email: { $exists: false } }, { $set: { email: '' } }). Then redeploy strict validation.
  5. Notify dependent services — anything reading from the collection may have assumed the field exists in production and crashed. Triage downstream consumers.

Prevention:

  • Run new schemas through a shadow validation period — log validator failures for 24-48 hours before flipping them to throw. This surfaces every legacy document that would break before the strict deploy.
  • Test migrations on a production-shape dataset (sanitized export, not synthetic fixtures). Synthetic data never has the edge cases real data does.
  • Gate schema deploys behind a CI step that runs the new schema against a snapshot of production document IDs and reports validation failures.
  • Use a feature flag for strict validation — flip it on per-cohort, monitor 400 rate, roll forward only when clean.
  • Document every schema change in a CHANGELOG and link the corresponding data migration. No schema change ships without a migration plan.

Still Not Working?

Check that validation runs at the right time. Model.insertMany() skips validation by default:

// Validation skipped by default
await User.insertMany(users);

// Enable validation
await User.insertMany(users, { runValidators: true });

Check for strict: false. If the schema has strict: false, Mongoose allows extra fields but still validates defined fields. Extra fields pass silently but defined fields must still meet schema constraints.

Check nested schema validation. Validators on nested schemas run when the parent document is saved:

const addressSchema = new Schema({
  city: { type: String, required: true },
  zip:  { type: String, match: [/^\d{5}$/, 'Invalid ZIP code'] },
});

const userSchema = new Schema({
  address: { type: addressSchema, required: true },
});

// This fails — city is required in the nested schema
const user = new User({ address: { zip: '10001' } });
await user.save(); // ValidationError: address.city is required

Check for discriminator schemas. If your collection uses Mongoose discriminators, each child schema has its own set of validators. A document built with the wrong discriminator key is validated against the wrong rule set and fails in confusing ways. Verify __t (the discriminator key) matches the intended subtype.

Check for setDefaultsOnInsert on upserts. findOneAndUpdate({...}, {...}, { upsert: true }) does not apply schema defaults to the inserted document unless you pass setDefaultsOnInsert: true. The insert can then fail because required fields with defaults are absent.

Check for transaction-wrapped writes. Inside a Mongoose transaction, a single failing validator aborts the whole transaction. The error surfaces on the operation you might not suspect — for example, you see a validation failure on save() but the bad data was actually set up by an earlier updateOne() in the same transaction.

Check for Mongoose middleware that mutates the document. A pre('validate') or pre('save') hook that modifies fields can introduce invalid values you did not pass in. Log the document state inside the hook to confirm.

For related MongoDB issues, see Fix: MongoDB Duplicate Key Error, Fix: MongoDB Connect ECONNREFUSED, Fix: MongoDB Schema Validation Error, and Fix: Mongoose Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles