Skip to main content
Technology & EngineeringOrm292 lines

Mongoose

Mongoose ODM for MongoDB with schema validation, middleware hooks, population, virtuals, and TypeScript support in Node.js

Quick Summary24 lines
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 lines
Paste into your CLAUDE.md or agent config

Mongoose — 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 — each populate() 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: true on update operations — by default, findByIdAndUpdate, updateOne, and updateMany skip schema validation entirely. Invalid data gets written to the database without any error, and the bug surfaces only when reading the document later.

  • Treating unique as application-level validation — the unique option 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 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.

Common Pitfalls

  • 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.
  • Population is not a join: Each populate() call executes a separate query. Deep or multiple populations can cause many round-trips. Use aggregation $lookup for complex joins.
  • Duplicate key errors: The unique option 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

Get CLI access →