Fix: Mongoose ValidationError — Document Failed to Save
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 formatOr 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: truebut the document does not include it. - Type mismatch — a string is passed for a
Numberfield, or a non-date value for aDatefield. Mongoose tries to cast values but fails for clearly incompatible types. - Custom validator rejected the value — a
validatefunction in the schema returnedfalseor threw. - Enum constraint violated — a field with
enum: ['active', 'inactive']received an unlisted value. minlength/maxlength/min/maxviolated — 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 documentConditional 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 duringfindOneAndUpdate()orupdateOne(). 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 defaultWarning:
runValidators: truewiththis-referencing validators (like conditionalrequired) can behave unexpectedly in updates becausethisis 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 requiredFor related MongoDB issues, see Fix: MongoDB Duplicate Key Error and Fix: MongoDB Connect ECONNREFUSED.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: MongoServerError: bad auth / MongoNetworkError: connect ECONNREFUSED / MongooseServerSelectionError
How to fix MongoDB 'MongoServerError: bad auth Authentication failed', 'MongoNetworkError: connect ECONNREFUSED 127.0.0.1:27017', and 'MongooseServerSelectionError' connection errors. Covers MongoDB not running, connection string format, Atlas network access, Docker networking, authentication, DNS/SRV issues, TLS/SSL, and Mongoose options.
Fix: Express req.body Is undefined
How to fix req.body being undefined in Express — missing body-parser middleware, wrong Content-Type header, middleware order issues, and multipart form data handling.
Fix: MongoDB "not primary" Write Error (Replica Set)
How to fix MongoDB 'not primary' errors when writing to a replica set — read preference misconfiguration, connecting to a secondary, replica set elections, and write concern settings.
Fix: Node.js Crashing with UnhandledPromiseRejection (--unhandled-rejections)
How to fix Node.js UnhandledPromiseRejectionWarning and process crashes — why unhandled promise rejections crash Node.js 15+, how to add global handlers, find the source of the rejection, and fix async error handling.