Mongoose is the ODM (Object Document Mapper) that sits between your Node.js application and MongoDB. Where the raw MongoDB driver gives you total flexibility — storing anything in any shape — Mongoose adds a schema layer that defines the structure, types, validation rules, default values, and computed properties of your data. This is the contract between your application code and your database. A well-designed Mongoose schema is self-documenting, catches data integrity errors before they reach the database, and provides computed properties that keep your business logic centralised in one place. This lesson covers the full schema API from primitive types through custom validators to virtual properties.
Mongoose SchemaTypes
| SchemaType | JS Equivalent | Notes |
|---|---|---|
String |
string | Options: minlength, maxlength, enum, match (regex), trim, lowercase, uppercase |
Number |
number | Options: min, max, enum |
Boolean |
boolean | — |
Date |
Date | Options: min, max. Stored as ISODate in MongoDB |
Buffer |
Buffer | Binary data — file contents, image bytes |
ObjectId |
mongoose.Types.ObjectId | Reference to another document. Use ref for populate() |
Array |
Array | Shorthand for SchemaType[]. Supports nested schemas |
Map |
Map | Key-value pairs with dynamic keys |
Mixed |
any | No type constraint — use sparingly |
Decimal128 |
— | High-precision decimal — use for currency |
UUID |
string | Mongoose 7+ — stores as BSON Binary UUID |
Schema Path Options
| Option | Type | Effect |
|---|---|---|
required |
boolean / [bool, msg] / fn | Field must be present — validation error if missing |
default |
value / fn | Value used when field is absent on create |
unique |
boolean | Creates a unique index — NOT a Mongoose validator |
index |
boolean | Creates a regular index on this field |
select |
boolean | false = exclude from all queries by default |
validate |
fn / {validator, message} | Custom validation function |
get |
fn | Transform value when reading from document |
set |
fn | Transform value when writing to document |
alias |
string | Alternative property name for this field |
immutable |
boolean | Field cannot be changed after creation |
unique: true on a Mongoose schema path creates a MongoDB unique index but is NOT a Mongoose validator. This means Mongoose’s validation step (which runs before saving) does not check uniqueness — it happens at the database level when MongoDB tries to insert. The result is a MongoDB error with code 11000, not a Mongoose ValidationError. Your global error handler must specifically detect and handle err.code === 11000 to return a proper 409 response.select: false on sensitive fields like password, refreshTokens, and resetToken. This ensures they are excluded from every query result by default — you can never accidentally return them in an API response. When you explicitly need the field (login, password reset), select it back with: User.findOne({ email }).select('+password'). The + prefix adds the field back to a query that would otherwise exclude it.save(), create(), and when runValidators: true is explicitly passed to findOneAndUpdate(), updateOne() etc. Calling updateOne() or findByIdAndUpdate() without { runValidators: true } bypasses all schema validators. Always include this option in update operations: Task.findByIdAndUpdate(id, data, { new: true, runValidators: true }).Complete Schema Examples
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const { Schema } = mongoose;
// ── Full User schema with all schema features ─────────────────────────────
const userSchema = new Schema({
// Basic types with built-in validators
name: {
type: String,
required: [true, 'Name is required'],
trim: true,
minlength: [2, 'Name must be at least 2 characters'],
maxlength: [100, 'Name cannot exceed 100 characters'],
},
email: {
type: String,
required: [true, 'Email is required'],
unique: true, // index only — not a validator
lowercase: true, // auto-transform to lowercase
trim: true,
match: [/^\S+@\S+\.\S+$/, 'Please provide a valid email address'],
},
password: {
type: String,
required:[true, 'Password is required'],
minlength:[8, 'Password must be at least 8 characters'],
select: false, // NEVER returned in queries by default
},
role: {
type: String,
enum: {
values: ['user', 'admin', 'moderator'],
message: '{VALUE} is not a valid role',
},
default: 'user',
},
avatar: {
type: String,
default: '/uploads/avatars/default.png',
},
// Boolean with default
isVerified: { type: Boolean, default: false },
isActive: { type: Boolean, default: true },
// Date with min/max
birthDate: {
type: Date,
max: [new Date(), 'Birth date cannot be in the future'],
validate: {
validator: function(v) {
if (!v) return true; // optional field
const age = (Date.now() - v.getTime()) / (365.25 * 24 * 3600 * 1000);
return age >= 13;
},
message: 'You must be at least 13 years old',
},
},
// Number with range
loginAttempts: {
type: Number,
default: 0,
min: 0,
max: [10, 'Maximum login attempts exceeded'],
select: false,
},
lockUntil: {
type: Date,
select: false,
},
// Immutable field — cannot be changed after creation
plan: {
type: String,
enum: ['free', 'pro', 'enterprise'],
default: 'free',
immutable: true, // once set, cannot be updated via findOneAndUpdate
},
// Object/subdocument — embedded preferences
preferences: {
theme: { type: String, enum: ['light', 'dark'], default: 'light' },
timezone: { type: String, default: 'UTC' },
notifications: {
email: { type: Boolean, default: true },
push: { type: Boolean, default: false },
},
},
// Array of subdocuments (refresh tokens)
refreshTokens: {
type: [{
token: { type: String, required: true },
device: String,
expiresAt: { type: Date, required: true },
}],
select: false,
default: [],
},
// Custom getter: always return email in lowercase for display
displayEmail: {
type: String,
get: v => v ? v.toLowerCase() : v,
},
// Field with custom setter: strip whitespace and truncate bio
bio: {
type: String,
maxlength: 500,
set: v => typeof v === 'string' ? v.trim().substring(0, 500) : v,
},
// Alias — access field as either 'phone' or 'phoneNumber'
phone: {
type: String,
alias: 'phoneNumber',
match: [/^\+?[1-9]\d{6,14}$/, 'Invalid phone number format'],
},
// Custom validator with async support (e.g. check external service)
username: {
type: String,
validate: {
validator: async function(v) {
const reserved = ['admin', 'root', 'system', 'support'];
return !reserved.includes(v.toLowerCase());
},
message: 'This username is reserved',
},
},
}, {
timestamps: true, // adds createdAt and updatedAt automatically
toJSON: { virtuals: true, getters: true }, // include virtuals/getters in JSON output
toObject: { virtuals: true, getters: true },
});
// ── Virtual properties — computed, not stored in DB ───────────────────────
// fullName virtual: getter
userSchema.virtual('fullName').get(function() {
return `${this.firstName} ${this.lastName}`;
});
// fullName virtual: setter
userSchema.virtual('fullName').set(function(name) {
const parts = name.trim().split(' ');
this.firstName = parts[0];
this.lastName = parts.slice(1).join(' ');
});
// isLocked virtual — derived from loginAttempts and lockUntil
userSchema.virtual('isLocked').get(function() {
return !!(this.lockUntil && this.lockUntil > Date.now());
});
// avatarUrl virtual — construct full URL from stored filename
userSchema.virtual('avatarUrl').get(function() {
return `${process.env.BASE_URL}${this.avatar}`;
});
// taskCount virtual — reference to related collection
userSchema.virtual('tasks', {
ref: 'Task',
localField: '_id',
foreignField: 'user',
});
// Usage:
// User.findById(id).populate('tasks') — resolves the virtual population
How It Works
Step 1 — Schema Defines the Contract, Model Enforces It
A Mongoose schema is a blueprint — it describes the fields, types, and constraints but does not interact with the database. Calling mongoose.model('User', userSchema) creates a Model class bound to that schema. The Model is the active record — it provides the methods (find, create, save) that interact with MongoDB using the schema as validation and transformation rules.
Step 2 — required and validate Run Before Database Writes
When you call user.save() or User.create(data), Mongoose runs all validators synchronously and asynchronously before issuing any MongoDB command. If any validator fails, Mongoose throws a ValidationError and no database write occurs. This catches data integrity errors in application code, with descriptive messages, before bad data can reach MongoDB.
Step 3 — Virtuals Are Computed Properties Not Stored in MongoDB
Virtuals exist only on Mongoose document instances. user.isLocked is computed from user.lockUntil on the fly — it is never stored in MongoDB. When you call user.toJSON() or res.json(user), virtuals are included only if you set { toJSON: { virtuals: true } } in the schema options. Without this, virtuals are invisible in API responses even though they are accessible on the document object.
Step 4 — Getters and Setters Transform Data Transparently
A getter transforms a field’s value every time it is read. A setter transforms a value every time it is written. They are transparent to the rest of your code — you just read and write the field normally. Getters are useful for formatting stored data (capitalising a name, constructing a URL from a path). Setters are useful for normalising input before storage (trimming whitespace, hashing a PIN).
Step 5 — select: false Protects Sensitive Fields Everywhere
Setting select: false on a field modifies the default projection for all queries on that model. MongoDB still stores the field, but Mongoose never returns it unless you explicitly request it with .select('+fieldName'). This is a defence-in-depth measure — even if a developer forgets to exclude the password field in a specific query, it will not appear in the result. It is much safer than relying on every query to explicitly exclude sensitive fields.
Real-World Example: Complete Task Schema
// models/task.model.js — complete schema with all features
const mongoose = require('mongoose');
const attachmentSchema = new mongoose.Schema({
filename: { type: String, required: true },
url: { type: String, required: true },
size: { type: Number, min: 0 },
mimeType: String,
uploadedAt: { type: Date, default: Date.now },
}, { _id: true });
const taskSchema = new mongoose.Schema({
title: {
type: String,
required: [true, 'Task title is required'],
trim: true,
minlength: [1, 'Title cannot be empty'],
maxlength: [200, 'Title cannot exceed 200 characters'],
set: v => typeof v === 'string' ? v.trim() : v,
},
description: {
type: String,
trim: true,
maxlength:[2000, 'Description cannot exceed 2000 characters'],
},
status: {
type: String,
enum: { values: ['pending', 'in-progress', 'completed'], message: 'Invalid status: {VALUE}' },
default: 'pending',
index: true,
},
priority: {
type: String,
enum: ['low', 'medium', 'high'],
default: 'medium',
index: true,
},
dueDate: {
type: Date,
validate: {
validator: function(v) {
// Only validate on create (new documents), not on update
if (!this.isNew) return true;
return !v || v > new Date();
},
message: 'Due date must be in the future',
},
},
completedAt: {
type: Date,
select: false,
},
tags: {
type: [{ type: String, trim: true, maxlength: 50 }],
default: [],
validate: {
validator: v => v.length <= 20,
message: 'Maximum 20 tags allowed',
},
},
attachments: {
type: [attachmentSchema],
default: [],
validate: {
validator: v => v.length <= 10,
message: 'Maximum 10 attachments per task',
},
},
user: {
type: mongoose.Types.ObjectId,
ref: 'User',
required: [true, 'Task must belong to a user'],
immutable: true, // cannot reassign task to different user
index: true,
},
deletedAt: { type: Date, select: false },
}, {
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true },
});
// Virtuals
taskSchema.virtual('isOverdue').get(function() {
return !!(this.dueDate && this.dueDate < new Date() && this.status !== 'completed');
});
taskSchema.virtual('isDeleted').get(function() {
return !!this.deletedAt;
});
taskSchema.virtual('daysUntilDue').get(function() {
if (!this.dueDate) return null;
return Math.ceil((this.dueDate - new Date()) / 86400000);
});
// Indexes
taskSchema.index({ user: 1, status: 1, createdAt: -1 });
taskSchema.index({ user: 1, priority: 1, createdAt: -1 });
taskSchema.index({ user: 1, dueDate: 1 });
taskSchema.index({ title: 'text', description: 'text' }, { weights: { title: 10, description: 3 } });
module.exports = mongoose.model('Task', taskSchema);
Common Mistakes
Mistake 1 — Trusting unique: true as a validator
❌ Wrong — ValidationError is not thrown for duplicate emails:
userSchema.path('email').validate(async function(v) {
// This custom validator runs on save() — but unique: true still only creates an index
// Two concurrent requests can both pass validation and then hit the DB-level unique constraint
});
// Always handle err.code === 11000 in your error middleware
✅ Correct — handle duplicate key error in global error handler:
if (err.code === 11000) {
const field = Object.keys(err.keyPattern)[0];
return res.status(409).json({ message: `${field} already in use` });
}
Mistake 2 — Forgetting runValidators on update operations
❌ Wrong — invalid data saved via update without validation:
await Task.findByIdAndUpdate(id, { priority: 'critical' });
// 'critical' is not in the enum — but no ValidationError! Saved silently.
✅ Correct — always include runValidators on update operations:
await Task.findByIdAndUpdate(id, { priority: 'critical' }, { runValidators: true });
// ValidationError: priority: 'critical' is not a valid enum value
Mistake 3 — Not setting toJSON: { virtuals: true } — virtuals missing from API responses
❌ Wrong — isOverdue virtual never appears in JSON responses:
const taskSchema = new mongoose.Schema({ ... });
taskSchema.virtual('isOverdue').get(function() { ... });
// res.json(task) — isOverdue is absent even though task.isOverdue works in code
✅ Correct — configure toJSON and toObject to include virtuals:
const taskSchema = new mongoose.Schema({ ... }, {
toJSON: { virtuals: true },
toObject: { virtuals: true },
});
Quick Reference
| Feature | Schema Syntax |
|---|---|
| Required field | { type: String, required: [true, 'msg'] } |
| Enum validation | { type: String, enum: ['a','b'], default: 'a' } |
| Hidden field | { type: String, select: false } |
| Immutable field | { type: ObjectId, immutable: true } |
| Auto-timestamp | new Schema({...}, { timestamps: true }) |
| Custom validator | { validate: { validator: fn, message: 'msg' } } |
| Getter | { get: v => transform(v) } |
| Setter | { set: v => transform(v) } |
| Virtual property | schema.virtual('name').get(fn) |
| Include virtuals in JSON | new Schema({...}, { toJSON: { virtuals: true } }) |
| Virtual populate | schema.virtual('tasks', { ref, localField, foreignField }) |