Skip to content

Fix: Mongoose ValidationError — Document Failed to Save

FixDevs ·

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

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);
}

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

For related MongoDB issues, see Fix: MongoDB Duplicate Key Error and Fix: MongoDB Connect ECONNREFUSED.

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