Mongoose adds an application-level schema on top of MongoDB's flexible documents: typed fields, casting, validation rules with readable messages, and hooks that run before or after saves and queries. After this lesson you will be able to define schemas with the right SchemaTypes and options, write built-in and custom validators, understand when validation does and does not run, and use middleware for hashing, auditing and soft deletes.
A schema maps each path to a SchemaType: String, Number, Date, Boolean, ObjectId, Decimal128, Buffer, Map, UUID, arrays, nested objects, or Mixed for anything. Field options refine behaviour:
| Option | Effect |
|---|---|
| required | value must be present; accepts a custom message or a function |
| default | static value or function (Date.now, () => []) |
| enum, min/max, minLength/maxLength, match | built-in validators |
| lowercase, uppercase, trim | setters applied before validation |
| select: false | excluded from query results unless +field is requested |
| immutable: true | cannot change after creation |
| index, unique, sparse | index definitions; not validators |
{ timestamps: true } adds createdAt/updatedAt; strict mode (the default) drops fields that are not in the schema.
const productSchema = new Schema({
sku: {
type: String,
validate: {
validator: (v) => /^[A-Z]{2,5}-\d{3,}$/.test(v),
message: (props) => `${props.value} is not a valid SKU`
}
},
price: { type: Number, min: [0, "Price cannot be negative"] },
discount: {
type: Number,
validate: { validator: function (v) { return v <= this.price; }, message: "Discount exceeds price" }
}
});A validator may return a promise for asynchronous checks. Validation runs on save() and create(), and errors arrive as a ValidationError whose errors object is keyed by path:
try {
await Product.create({ sku: "bad", price: -5 });
} catch (err) {
Object.values(err.errors).map(e => e.message);
// [ "bad is not a valid SKU", "Price cannot be negative" ]
}Two rules that surprise newcomers: unique builds an index and throws a driver E11000 error rather than a ValidationError, and update operations (updateOne, findOneAndUpdate) skip validation unless you pass { runValidators: true }.
Middleware runs around an operation. Which this you get depends on the kind:
| Kind | Triggers | this is |
|---|---|---|
| Document | save, validate, deleteOne (with { document: true }) | the document |
| Query | find, findOne, updateOne, findOneAndUpdate, deleteMany, ... | the Query |
| Aggregate | aggregate | the Aggregate object |
| Model | insertMany, bulkWrite | the Model |
// Soft delete: hide deleted documents from every find-style query
userSchema.pre(/^find/, function () {
this.where({ deletedAt: null });
});
// Audit: capture a change record after each save
userSchema.post("save", async function (doc) {
await AuditLog.create({ userId: doc._id, at: new Date() });
});
// Error middleware: turn a duplicate email into a readable message
userSchema.post("save", function (err, doc, next) {
next(err.code === 11000 ? new Error("Email already registered") : err);
});Hooks written as async functions do not need next(); throwing (or rejecting) aborts the operation. Use regular function syntax, not arrow functions, so this is bound. Register hooks before calling mongoose.model(), otherwise they are silently ignored. save hooks do not fire for updateOne or findOneAndUpdate; add query hooks for those paths when the logic must apply everywhere.
userSchema.index({ email: 1 }, { unique: true });
userSchema.index({ name: "text" });
await User.syncIndexes(); // create missing, drop indexes not in the schemaIn development Mongoose builds indexes automatically when a model is first used (autoIndex). In production set autoIndex: false in mongoose.connect() options and run syncIndexes() from a deploy step, so a large index build never starts at process boot.
unique to validate. Catch E11000 from the driver instead.runValidators: true, letting invalid data through findOneAndUpdate.this binding.Mixed or nested value in place without doc.markModified("path"), so save() sees no change.Which operation does NOT run schema validators by default?
timestamps: true adds audit dates.ValidationError keyed by path.unique is an index, not a validator, and updates need runValidators: true.this refers to; register hooks before creating the model.syncIndexes() at deploy time with autoIndex off in production.Next lesson: Mongoose Population, Virtuals and Query Helpers — model relationships between collections and add computed fields and reusable query logic.