Mongoose
Mongoose ODM for MongoDB with schema validation, middleware hooks, population, virtuals, and TypeScript support in Node.js
You are an expert in Mongoose for MongoDB access and data modeling. You help developers define schemas, write queries, use middleware, and follow Mongoose best practices.
## Key Points
- **Schemas** — define document structure, types, validation, and defaults
- **Models** — constructors compiled from schemas, representing MongoDB collections
- **Middleware (hooks)** — pre/post hooks for document and query lifecycle events
- **Population** — automatic resolution of references between collections
- **Use `.lean()`** for read-only queries to skip Mongoose document hydration and improve performance significantly.
- **Define indexes in the schema** — Mongoose calls `createIndex` on startup. For production, also create indexes via database migration scripts.
- **Always pass `{ runValidators: true }`** to update operations — by default, Mongoose skips schema validation on updates.
- **Use `select()`** or `projection` to return only needed fields, especially when documents are large.
- **Set `timestamps: true`** in schema options for automatic `createdAt`/`updatedAt` management.
- **Close the connection gracefully** with `await mongoose.disconnect()` on process shutdown.
- **Validation skipped on update**: `findByIdAndUpdate` and `updateMany` do not run validators unless `{ runValidators: true }` is passed.
- **`create()` in transactions**: Inside a transaction, `Model.create()` requires an array argument and `{ session }` option. Passing a plain object throws.
## Quick Example
```bash
npm install mongoose
```skilldb get orm-skills/MongooseFull skill: 292 linesMongoose — Database Toolkit
You are an expert in Mongoose for MongoDB access and data modeling. You help developers define schemas, write queries, use middleware, and follow Mongoose best practices.
Overview
Mongoose is the most popular ODM (Object Data Modeling) library for MongoDB and Node.js. It provides schema-based modeling with type casting, validation, query building, middleware (hooks), and population (reference resolution). It has first-class TypeScript support.
Key components:
- Schemas — define document structure, types, validation, and defaults
- Models — constructors compiled from schemas, representing MongoDB collections
- Middleware (hooks) — pre/post hooks for document and query lifecycle events
- Population — automatic resolution of references between collections
Setup & Configuration
Installation
npm install mongoose
Connection (src/db.ts)
import mongoose from 'mongoose';
const MONGODB_URI = process.env.MONGODB_URI ?? 'mongodb://localhost:27017/myapp';
export async function connectDB(): Promise<void> {
await mongoose.connect(MONGODB_URI, {
maxPoolSize: 10,
serverSelectionTimeoutMS: 5000,
socketTimeoutMS: 45000,
});
console.log('Connected to MongoDB');
}
mongoose.connection.on('error', (err) => {
console.error('MongoDB connection error:', err);
});
mongoose.connection.on('disconnected', () => {
console.warn('MongoDB disconnected');
});
Schema & Model Definitions
// src/models/User.ts
import mongoose, { Schema, Document, Types } from 'mongoose';
export interface IUser {
email: string;
name?: string;
role: 'user' | 'admin';
createdAt: Date;
updatedAt: Date;
}
export interface IUserDocument extends IUser, Document {}
const userSchema = new Schema<IUser>(
{
email: {
type: String,
required: [true, 'Email is required'],
unique: true,
lowercase: true,
trim: true,
match: [/^\S+@\S+\.\S+$/, 'Invalid email format'],
},
name: { type: String, trim: true, maxlength: 100 },
role: { type: String, enum: ['user', 'admin'], default: 'user' },
},
{
timestamps: true,
toJSON: {
transform(_doc, ret) {
ret.id = ret._id.toString();
delete ret._id;
delete ret.__v;
},
},
},
);
userSchema.index({ email: 1 });
userSchema.index({ role: 1, createdAt: -1 });
export const User = mongoose.model<IUser>('User', userSchema);
// src/models/Post.ts
import mongoose, { Schema, Types } from 'mongoose';
export interface IPost {
title: string;
content?: string;
published: boolean;
author: Types.ObjectId;
tags: string[];
createdAt: Date;
}
const postSchema = new Schema<IPost>(
{
title: { type: String, required: true, maxlength: 255 },
content: { type: String },
published: { type: Boolean, default: false },
author: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
tags: [{ type: String, lowercase: true, trim: true }],
},
{ timestamps: true },
);
postSchema.index({ published: 1, createdAt: -1 });
postSchema.index({ tags: 1 });
export const Post = mongoose.model<IPost>('Post', postSchema);
Core Patterns
CRUD Operations
import { User } from './models/User';
import { Post } from './models/Post';
// Create
const user = await User.create({ email: 'alice@example.com', name: 'Alice' });
// Batch create
await Post.insertMany([
{ title: 'Post 1', author: user._id, tags: ['intro'] },
{ title: 'Post 2', author: user._id, published: true, tags: ['update'] },
]);
// Read
const found = await User.findOne({ email: 'alice@example.com' });
const byId = await User.findById('64a1b2c3d4e5f6a7b8c9d0e1');
const users = await User.find({ role: 'user' })
.sort({ createdAt: -1 })
.skip(0)
.limit(10)
.select('email name')
.lean(); // returns plain objects, faster
// Population (resolve references)
const postsWithAuthors = await Post.find({ published: true })
.populate('author', 'name email')
.sort({ createdAt: -1 })
.limit(20);
// Update
await User.findByIdAndUpdate(user._id, { name: 'Alice Updated' }, { new: true, runValidators: true });
// Upsert
await User.findOneAndUpdate(
{ email: 'alice@example.com' },
{ $set: { name: 'Alice' } },
{ upsert: true, new: true },
);
// Delete
await Post.findByIdAndDelete(postId);
await Post.deleteMany({ published: false, createdAt: { $lt: cutoffDate } });
Middleware (Hooks)
// Pre-save hook
userSchema.pre('save', async function (next) {
if (this.isModified('email')) {
this.email = this.email.toLowerCase();
}
next();
});
// Pre-find hook (applies to find, findOne, findById)
postSchema.pre(/^find/, function (next) {
// `this` is the query
this.populate('author', 'name email');
next();
});
// Post-save hook
userSchema.post('save', function (doc) {
console.log(`User ${doc.email} was saved`);
});
Virtuals
userSchema.virtual('posts', {
ref: 'Post',
localField: '_id',
foreignField: 'author',
});
userSchema.virtual('displayName').get(function () {
return this.name ?? this.email.split('@')[0];
});
Aggregation Pipeline
const authorStats = await Post.aggregate([
{ $match: { published: true } },
{ $group: {
_id: '$author',
postCount: { $sum: 1 },
latestPost: { $max: '$createdAt' },
tags: { $addToSet: '$tags' },
}},
{ $sort: { postCount: -1 } },
{ $limit: 10 },
{ $lookup: {
from: 'users',
localField: '_id',
foreignField: '_id',
as: 'authorInfo',
}},
{ $unwind: '$authorInfo' },
{ $project: {
authorName: '$authorInfo.name',
postCount: 1,
latestPost: 1,
}},
]);
Transactions (Replica Set Required)
const session = await mongoose.startSession();
const result = await session.withTransaction(async () => {
const user = await User.create([{ email: 'bob@example.com', name: 'Bob' }], { session });
const post = await Post.create([{ title: 'Hello', author: user[0]._id, published: true }], { session });
return { user: user[0], post: post[0] };
});
session.endSession();
Core Philosophy
Mongoose exists to bring structure to MongoDB's schemaless nature. MongoDB accepts any document shape, which is powerful but dangerous — without enforcement, collections accumulate inconsistent structures that break application code in unpredictable ways. Mongoose schemas define the expected shape of documents, validate data on write, cast types automatically, and provide defaults for missing fields. This schema-on-write approach gives you the flexibility of a document database with the safety guarantees that applications need.
The document model in Mongoose differs fundamentally from relational ORMs. There are no joins in MongoDB — each query returns complete documents from a single collection. Population (populate()) resolves references between collections, but it executes separate queries under the hood, not database-level joins. For complex multi-collection operations, the aggregation pipeline with $lookup is far more efficient. Understanding when to embed subdocuments (data that is always accessed together) versus when to reference separate collections (data that is accessed independently or shared) is the most important data modeling decision in MongoDB.
Middleware hooks (pre/post save, find, update, delete) are Mongoose's mechanism for cross-cutting concerns: hashing passwords before save, populating references before find, logging changes after update. They run transparently without the caller needing to know, which makes them powerful but also a source of hidden behavior. The most maintainable Mongoose codebases use middleware sparingly and for genuinely cross-cutting concerns, not as a replacement for explicit application logic.
Anti-Patterns
-
Using
populate()for complex multi-collection operations — eachpopulate()call executes a separate database query. Populating three fields on 100 documents fires 300+ queries. For complex joins, use the aggregation pipeline with$lookup, which executes server-side in a single operation. -
Skipping
runValidators: trueon update operations — by default,findByIdAndUpdate,updateOne, andupdateManyskip schema validation entirely. Invalid data gets written to the database without any error, and the bug surfaces only when reading the document later. -
Treating
uniqueas application-level validation — theuniqueoption creates a MongoDB unique index, not a validator. If index creation fails (because duplicates already exist), uniqueness is silently not enforced, and duplicate documents accumulate without errors. -
Using full Mongoose documents for read-only operations — Mongoose documents carry getters, setters, virtuals, change tracking, and validation logic. For read-only responses (especially API endpoints),
.lean()returns plain JavaScript objects that are significantly faster to create and serialize. -
Relying on schema changes to migrate existing documents — MongoDB is schemaless, so existing documents do not gain new fields or lose removed ones when you change the Mongoose schema. Old documents retain their original shape, and new schema defaults only apply to newly created documents. Handle backward compatibility explicitly or run data migration scripts.
Best Practices
- Use
.lean()for read-only queries to skip Mongoose document hydration and improve performance significantly. - Define indexes in the schema — Mongoose calls
createIndexon startup. For production, also create indexes via database migration scripts. - Always pass
{ runValidators: true }to update operations — by default, Mongoose skips schema validation on updates. - Use
select()orprojectionto return only needed fields, especially when documents are large. - Set
timestamps: truein schema options for automaticcreatedAt/updatedAtmanagement. - Close the connection gracefully with
await mongoose.disconnect()on process shutdown.
Common Pitfalls
- Validation skipped on update:
findByIdAndUpdateandupdateManydo not run validators unless{ runValidators: true }is passed. create()in transactions: Inside a transaction,Model.create()requires an array argument and{ session }option. Passing a plain object throws.- Population is not a join: Each
populate()call executes a separate query. Deep or multiple populations can cause many round-trips. Use aggregation$lookupfor complex joins. - Duplicate key errors: The
uniqueoption on schema fields creates a MongoDB unique index. If the index fails to create (e.g., duplicates already exist), uniqueness is silently not enforced. lean()loses methods: Documents from.lean()are plain objects — they lack Mongoose methods, virtuals, and getters. Use lean only when you don't need them.- Schema changes don't migrate: MongoDB is schemaless, so old documents don't automatically get new fields. Handle backward compatibility in application code or run data migration scripts.
Install this skill directly: skilldb add orm-skills
Related Skills
Drizzle
Drizzle ORM for lightweight, type-safe SQL in TypeScript with zero dependencies, SQL-like syntax, and schema-as-code
Knex
Knex.js SQL query builder for Node.js with schema building, migrations, seed files, and support for PostgreSQL, MySQL, and SQLite
Kysely
Kysely type-safe SQL query builder for TypeScript with zero overhead, full autocompletion, and no code generation
Mikro ORM
MikroORM for TypeScript with Unit of Work, Identity Map, decorator-based entities, and support for SQL and MongoDB
Prisma
Prisma ORM for type-safe database access with auto-generated client, migrations, and schema-first modeling in TypeScript/Node.js
Sequelize
Sequelize ORM for promise-based SQL database access with model definitions, associations, migrations, and transaction support in Node.js