Mongoose Validation and Middleware

MongoDB itself is schema-flexible, but most real applications still want guardrails: an email field that looks like an email, a price that can’t go negative, a password that gets hashed before it’s ever written to disk. Mongoose, the most popular MongoDB ODM (Object Document Mapper) for Node.js, gives you both of those tools at the application layer: validation, which rejects bad data before it’s saved, and middleware (also called hooks), which lets you run code before or after schema operations like save, validate, update, and remove.

Overview: How Validation and Middleware Work

When you define a Mongoose schema, each field can carry validation rules alongside its type. These aren’t MongoDB features — MongoDB’s server has no idea a field is “required” or has a “min” value. Validation happens entirely inside your Node.js process, in the Mongoose layer, before a write is ever sent to the database. This matters: if you write to MongoDB through the native driver, or another application writes to the same collection directly, Mongoose’s validation rules are bypassed entirely. Validation is an application-level contract, not a database-level guarantee (for a database-enforced version of this, see MongoDB’s own $jsonSchema validator, which is a different mechanism).

Under the hood, when you call document.save(), Mongoose runs through a pipeline: it first casts each field to its declared SchemaType (a string "42" assigned to a Number field becomes the number 42; an invalid cast, like assigning "abc" to a Number, becomes a CastError), then it runs any validate hooks, then all field-level validators (built-in ones like required/min/enum, plus any custom ones you defined), and only if everything passes does it move on to save hooks and the actual write. If any validator fails, Mongoose throws a ValidationError containing an errors object keyed by field path — no write happens at all.

Middleware (hooks) are functions you attach to a schema that run at specific points in that pipeline. There are four categories: document middleware (runs on an individual document for operations like save, validate, deleteOne), query middleware (runs on operations like find, updateOne, findOneAndUpdate that don’t necessarily load a full document first), aggregate middleware (runs when .aggregate() is called), and model middleware (currently just insertMany). Each can have a pre hook (runs before the operation) and a post hook (runs after). This is how libraries and applications implement things like password hashing, updated-at timestamps, cascading deletes, and audit logging without cluttering route handlers.

Syntax

// Field-level validation, defined inline in the schema
const schema = new mongoose.Schema({
  fieldName: {
    type: String,
    required: true,
    validate: { validator: fn, message: msg }
  }
});

// Middleware, registered on the schema before compiling the model
schema.pre('save', function (next) { /* ... */ next(); });
schema.post('save', function (doc) { /* ... */ });
schema.pre(/^find/, function (next) { /* ... */ next(); });
Validator Applies to Meaning
required any type field must be present and non-null; can be a boolean or [true, 'message']
min / max Number, Date numeric or date bounds, inclusive
minlength / maxlength String character-count bounds
match String value must match the given regular expression
enum String, Number value must be one of a fixed list of allowed values
validate any type custom function (sync, or async returning a promise/boolean); receives the value and can return false to fail

Every built-in validator accepts either a plain value (min: 0) or a two-element array [value, 'custom error message'] so you control the error text shown to the user.

Examples

Example 1: Built-in validators. This schema combines required, minlength/maxlength, match, min/max, and enum on a user document, then deliberately saves an invalid one to see how the errors surface.

import mongoose from 'mongoose';

const userSchema = new mongoose.Schema({
  name: {
    type: String,
    required: [true, 'Name is required'],
    minlength: 2,
    maxlength: 50
  },
  email: {
    type: String,
    required: true,
    match: [/^\S+@\S+\.\S+$/, 'Invalid email format'],
    unique: true
  },
  age: {
    type: Number,
    min: [0, 'Age cannot be negative'],
    max: 120
  },
  role: {
    type: String,
    enum: ['customer', 'admin', 'moderator'],
    default: 'customer'
  }
});

const User = mongoose.model('User', userSchema);

async function createUser() {
  await mongoose.connect('mongodb://<user>:<password>@<cluster-url>/mydb');

  try {
    const user = new User({ name: 'A', email: 'not-an-email', age: -5, role: 'owner' });
    await user.save();
  } catch (err) {
    console.log(err.name);
    console.log(Object.keys(err.errors));
  }
}

createUser();

Output:

ValidationError
[ 'name', 'email', 'age', 'role' ]

Four fields fail at once: the name is shorter than minlength: 2, the email doesn’t match the regex, the age is negative, and 'owner' isn’t in the enum list. Mongoose validates every field before throwing, so err.errors contains all four failures in one shot rather than stopping at the first one — useful for showing a user all the problems with a form at once.

Example 2: Custom validators. Built-in validators don’t cover everything — here a custom validate function checks that an order has at least one item, and an async validator checks the total.

import mongoose from 'mongoose';

const orderSchema = new mongoose.Schema({
  items: {
    type: [String],
    validate: {
      validator: function (arr) {
        return arr.length > 0;
      },
      message: 'An order must contain at least one item'
    }
  },
  total: {
    type: Number,
    validate: {
      validator: async function (value) {
        return value >= 0;
      },
      message: props => `${props.value} is not a valid total`
    }
  }
});

const Order = mongoose.model('Order', orderSchema);

async function createEmptyOrder() {
  const order = new Order({ items: [], total: -10 });
  const error = order.validateSync();
  console.log(error.errors.items.message);
  console.log(error.errors.total.message);
}

createEmptyOrder();

Output:

An order must contain at least one item
-10 is not a valid total

Note that the async validator still works with validateSync() here for illustration, but in real code an async validator requires await document.validate() (the promise-returning form) rather than validateSync(), since a synchronous call can’t wait on a promise. The message option can also be a function receiving props, letting you interpolate the rejected value into the error text.

Example 3: Middleware for password hashing and timestamps. A pre('save') hook is the standard place to hash a password only when it changes, and to stamp a creation date only on first insert.

import mongoose from 'mongoose';
import bcrypt from 'bcrypt';

const accountSchema = new mongoose.Schema({
  email: { type: String, required: true },
  password: { type: String, required: true },
  createdAt: Date
});

accountSchema.pre('save', async function (next) {
  if (this.isModified('password')) {
    this.password = await bcrypt.hash(this.password, 10);
  }
  if (this.isNew) {
    this.createdAt = new Date();
  }
  next();
});

accountSchema.post('save', function (doc) {
  console.log(`Account saved for ${doc.email}`);
});

const Account = mongoose.model('Account', accountSchema);

async function registerAccount() {
  const account = new Account({ email: 'jane@example.com', password: 'plaintext123' });
  await account.save();
}

registerAccount();

Output:

Account saved for jane@example.com

this inside the hook is the document being saved, so this.isModified('password') and this.isNew are how you avoid re-hashing an already-hashed password on every unrelated update. The hook calls next() to signal completion; since it’s also declared async, Mongoose additionally waits for the returned promise, so either style (callback or async/await) works, but mixing them incorrectly is a common source of hangs (see Common Mistakes).

How It Works Step by Step

Consider await user.save() end to end: (1) Mongoose casts each modified path to its schema type, throwing a CastError immediately for anything that can’t be coerced; (2) it runs any pre('validate') hooks; (3) it runs every field’s validators — built-in and custom — collecting all failures rather than stopping at the first; if any exist, it throws a ValidationError and the pipeline stops here, nothing is sent to MongoDB; (4) assuming validation passes, it runs pre('save') hooks in the order they were registered; (5) it sends the actual insertOne/updateOne to the MongoDB driver; (6) it runs post('save') hooks with the saved document. Query middleware works differently because updateOne/updateMany/findOneAndUpdate don’t load a document into memory first — this inside a query hook refers to the query object, not a document, which is why query-level validators must be explicitly opted into with { runValidators: true } (shown in Common Mistakes below); Mongoose otherwise has no document to validate against.

Common Mistakes

Mistake 1: Assuming update operations run validators automatically. They don’t — updateOne/updateMany/findOneAndUpdate skip schema validation entirely unless you opt in.

// Wrong: validators don't run on update operations by default
await Product.updateOne({ name: 'Widget' }, { price: -5 });
// This succeeds even though price violates min: 0

// Correct: opt in with runValidators
await Product.updateOne(
  { name: 'Widget' },
  { price: -5 },
  { runValidators: true }
);

Mistake 2: Using an arrow function for a pre hook. Arrow functions don’t bind their own this, so inside an arrow-function hook this is not the document — it’s whatever this was in the enclosing scope (often undefined in strict mode), and the hook silently does nothing useful or throws.

// Wrong: arrow functions don't get their own `this`
accountSchema.pre('save', (next) => {
  this.createdAt = new Date();
  next();
});

// Correct: a regular function keeps `this` bound to the document
accountSchema.pre('save', function (next) {
  this.createdAt = new Date();
  next();
});

Mistake 3: Treating unique: true as a validator. It isn’t — unique just tells Mongoose to build a unique index; the actual duplicate-key rejection happens on MongoDB’s server, and it surfaces as a driver-level MongoServerError with code === 11000, not a Mongoose ValidationError. Code that only checks err.errors will crash on a duplicate key error since that property doesn’t exist on it.

// Wrong: assuming a duplicate email throws a ValidationError
try {
  await user.save();
} catch (err) {
  console.log(err.errors.email.message); // err.errors is undefined for duplicate keys!
}

// Correct: check for the duplicate-key error code separately
try {
  await user.save();
} catch (err) {
  if (err.code === 11000) {
    console.log('Email already exists');
  } else if (err.name === 'ValidationError') {
    console.log(err.errors);
  }
}

Best Practices

  • Give every built-in validator a custom message ([value, 'message']) — the default messages are generic and not fit for end users.
  • Pass { runValidators: true, context: 'query' } to update calls whenever the update can affect a validated field, and remember custom validators referencing other fields need context: 'query' to access this correctly during an update.
  • Keep validation logic in the schema, not scattered across route handlers — it should be impossible to save an invalid document no matter which code path calls save().
  • Register schema-wide indexes like unique deliberately, and always handle MongoDB error code 11000 separately from ValidationError.
  • Use regular function declarations (not arrow functions) for any hook or validator that needs this to refer to the document or query.
  • Prefer async/await middleware over the next()-callback style for new code — it’s easier to reason about and naturally propagates thrown errors.
  • Keep pre('save') hooks focused on data preparation (hashing, defaults, normalization); push cross-collection side effects (cascading deletes, notifications) to post hooks so they run only after a successful write.

Practice Exercises

  • Add a publishedAt field to a blog-post schema and write a pre('save') hook that sets it only the first time a post’s status field changes to 'published'. Hint: check this.isModified('status') together with the new value.
  • Write a custom validator on a tags array field (type: [String]) that rejects the document if the array has more than 10 entries or contains duplicate strings.
  • Add a pre(/^find/) query middleware to a schema that automatically excludes documents where deletedAt is set (a soft-delete pattern), then confirm that a plain Model.find({}) no longer returns them.

Summary

  • Mongoose validation is an application-layer contract, checked before a write reaches MongoDB — it does not protect data written outside Mongoose.
  • Built-in validators (required, min/max, minlength/maxlength, match, enum) cover common cases; custom validate functions (sync or async) cover the rest.
  • A failed save() throws a ValidationError with all failing paths collected in err.errors, and no document is written.
  • Middleware (pre/post) hooks run around document, query, aggregate, and model operations, and are the standard place for hashing, timestamps, and cascading logic.
  • Update operations (updateOne, findOneAndUpdate, etc.) skip both validation and most document middleware unless you explicitly pass { runValidators: true }.
  • unique: true is an index constraint enforced by MongoDB, not a Mongoose validator — its failures are MongoServerError code 11000, handled differently from ValidationError.