What is Mongoose?
Mongoose is an Object Data Modeling (ODM) library for MongoDB and Node.js. It provides a schema-based solution to model your application data, managing relationships between data, validating data, and translating between objects in code and their representation in MongoDB. In essence, Mongoose gives you a structured way to interact with MongoDB, which is inherently schema-less, by allowing you to define schemas at the application level.
Why Mongoose Matters
MongoDB is flexible—you can insert documents with completely different shapes into the same collection. While this flexibility is powerful, real-world applications need structure, validation, and consistency. Mongoose bridges that gap by offering:
- Schema definitions – Define the shape of your documents, including types, defaults, and validation rules
- Built-in validation – Catch invalid data before it reaches the database
- Middleware (hooks) – Execute logic before or after certain operations, like saving or deleting
- Population – Automatically replace references with actual documents from other collections
- Query building – A powerful, chainable API for constructing database queries
- Index management – Define indexes at the schema level for performance optimization
- Plugins – Extend schemas with reusable functionality across your project
Getting Started: Installation and Connection
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →First, install Mongoose in your Node.js project. Make sure you have MongoDB running locally or have a remote connection string ready (such as MongoDB Atlas).
npm install mongoose
Now, let's establish a connection. The best practice is to handle the connection asynchronously and set up event listeners to monitor the connection state.
// app.js or server.js
const mongoose = require('mongoose');
const connectDB = async () => {
try {
await mongoose.connect('mongodb://127.0.0.1:27017/my_database', {
// Mongoose 6+ has these options as defaults; explicit inclusion is optional
});
console.log('MongoDB connected successfully');
} catch (error) {
console.error('MongoDB connection error:', error.message);
// Exit process with failure in production
process.exit(1);
}
};
// Monitor connection events
mongoose.connection.on('connected', () => {
console.log('Mongoose connected to the database');
});
mongoose.connection.on('error', (err) => {
console.log('Mongoose connection error:', err);
});
mongoose.connection.on('disconnected', () => {
console.log('Mongoose disconnected');
});
// Graceful shutdown
process.on('SIGINT', async () => {
await mongoose.connection.close();
console.log('Mongoose connection closed due to app termination');
process.exit(0);
});
connectDB();
For production applications connecting to MongoDB Atlas, use the connection string from your Atlas dashboard, ensuring you handle credentials securely via environment variables.
const MONGO_URI = process.env.MONGO_URI || 'mongodb+srv://user:password@cluster.mongodb.net/myDB';
await mongoose.connect(MONGO_URI);
Defining Schemas and Models
A Schema is the blueprint for your documents. It defines fields, their types, default values, validators, and more. A Model is the compiled constructor created from the schema—it's the actual interface you use to interact with the collection.
Creating Your First Schema
const mongoose = require('mongoose');
const { Schema } = mongoose;
const userSchema = new Schema({
name: {
type: String,
required: true,
trim: true
},
email: {
type: String,
required: true,
unique: true,
lowercase: true,
validate: {
validator: function(v) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
},
message: props => `${props.value} is not a valid email address!`
}
},
age: {
type: Number,
min: 0,
max: 120,
default: null
},
createdAt: {
type: Date,
default: Date.now,
immutable: true // cannot be changed after creation
},
bio: {
type: String,
maxlength: 500
}
});
// Compile schema into a Model
const User = mongoose.model('User', userSchema);
module.exports = User;
Schema Types at a Glance
Mongoose supports the following SchemaTypes:
- String – Text data with options like lowercase, uppercase, trim, minlength, maxlength
- Number – Numeric values with min and max validators
- Date – JavaScript Date objects
- Buffer – Binary data (images, files)
- Boolean – True/false values
- Mixed (Schema.Types.Mixed) – Any type; schema-less; use with caution
- ObjectId (Schema.Types.ObjectId) – References to other documents
- Array – Arrays of any of the above types, including nested schemas
- Decimal128 – High-precision decimal numbers for financial calculations
- Map – Key-value pairs where keys are strings
CRUD Operations: The Core of Mongoose
Once your model is defined, you can perform all standard database operations. Mongoose provides a fluent API with both callback-style and Promise-based methods.
Creating Documents
There are several ways to create and save documents:
// Method 1: Create a document instance, then save
const newUser = new User({
name: 'Alice Johnson',
email: 'alice@example.com',
age: 28,
bio: 'Software engineer and open source contributor'
});
try {
const savedUser = await newUser.save();
console.log('User saved:', savedUser);
} catch (error) {
console.error('Save error:', error.message);
}
// Method 2: Use Model.create() - creates and saves in one step
const user = await User.create({
name: 'Bob Smith',
email: 'bob@example.com',
age: 32
});
console.log('User created:', user);
// Method 3: Insert multiple documents at once
const users = await User.insertMany([
{ name: 'Charlie', email: 'charlie@example.com', age: 25 },
{ name: 'Diana', email: 'diana@example.com', age: 29 },
{ name: 'Eve', email: 'eve@example.com', age: 35 }
]);
console.log(`${users.length} users inserted`);
Reading Documents (Queries)
// Find all users
const allUsers = await User.find({});
// Find users with criteria and chain query modifiers
const youngUsers = await User.find({ age: { $lt: 30 } })
.select('name email age')
.sort({ age: 1 }) // ascending by age
.limit(10)
.lean(); // returns plain JS objects, not Mongoose docs
// Find a single document by some condition
const userByEmail = await User.findOne({ email: 'alice@example.com' });
// Find by ID (convenience method)
const userById = await User.findById('64a1b2c3d4e5f6g7h8i9j0k1');
// Complex query with logical operators
const filteredUsers = await User.find({
$or: [
{ age: { $gte: 25, $lte: 40 } },
{ name: { $in: ['Alice Johnson', 'Bob Smith'] } }
]
});
// Using cursor for large datasets
const cursor = User.find({ age: { $gt: 20 } }).cursor();
for await (const doc of cursor) {
console.log(doc.name);
}
Updating Documents
// Update a document and return the updated version
const updatedUser = await User.findByIdAndUpdate(
'64a1b2c3d4e5f6g7h8i9j0k1',
{ age: 29, bio: 'Updated bio text here' },
{
new: true, // return the modified document
runValidators: true // run schema validators on update
}
);
// Update multiple documents matching a condition
const updateResult = await User.updateMany(
{ age: { $lt: 25 } },
{ $set: { bio: 'Young professional' } }
);
console.log(`Modified ${updateResult.modifiedCount} documents`);
// Update one document without returning it
await User.updateOne(
{ email: 'bob@example.com' },
{ $inc: { age: 1 } } // increment age by 1
);
// Find a document, modify it, and save (useful for complex logic)
const user = await User.findOne({ name: 'Alice Johnson' });
if (user) {
user.bio = 'Updated via save() method';
user.age += 1;
await user.save();
}
Deleting Documents
// Delete one document
await User.deleteOne({ email: 'eve@example.com' });
// Delete multiple documents
const deleteResult = await User.deleteMany({ age: { $lt: 18 } });
console.log(`Deleted ${deleteResult.deletedCount} documents`);
// Find and delete, returning the deleted document
const deletedUser = await User.findByIdAndDelete('64a1b2c3d4e5f6g7h8i9j0k1');
if (deletedUser) {
console.log('Deleted user:', deletedUser.name);
}
// Another find-and-delete variant
const removedUser = await User.findOneAndDelete({ email: 'charlie@example.com' });
Schema Validation Deep Dive
Mongoose provides robust validation mechanisms. Built-in validators cover common needs, but you can always define custom validation logic.
Built-in Validators
const productSchema = new Schema({
name: {
type: String,
required: [true, 'Product name is required'],
minlength: [3, 'Name must be at least 3 characters'],
maxlength: [100, 'Name cannot exceed 100 characters']
},
price: {
type: Number,
required: true,
min: [0.01, 'Price must be greater than 0'],
max: [99999, 'Price seems too high']
},
rating: {
type: Number,
enum: {
values: [1, 2, 3, 4, 5],
message: '{VALUE} is not a valid rating; must be 1-5'
}
},
tags: {
type: [String],
validate: {
validator: function(arr) {
return arr.length <= 10;
},
message: 'A product can have at most 10 tags'
}
}
});
Custom Validators
const orderSchema = new Schema({
items: {
type: [{
productId: String,
quantity: Number,
priceAtTime: Number
}],
required: true,
validate: {
validator: function(items) {
return items.length > 0;
},
message: 'An order must have at least one item'
}
},
totalAmount: {
type: Number,
required: true,
validate: {
validator: function(value) {
// Custom async validator
return new Promise((resolve) => {
// Simulate async check
setTimeout(() => {
const calculated = this.items.reduce(
(sum, item) => sum + item.quantity * item.priceAtTime, 0
);
resolve(Math.abs(value - calculated) < 0.01);
}, 100);
});
},
message: 'Total amount must match sum of item prices'
}
},
couponCode: {
type: String,
validate: {
validator: async function(code) {
if (!code) return true; // optional field
// Check against external service or database
const couponExists = await Coupon.findOne({ code: code });
return !!couponExists;
},
message: 'Invalid or expired coupon code'
}
}
});
Validation Timing
By default, validators run on save() but not on updateOne(), findByIdAndUpdate(), etc. To enforce validation on updates, pass { runValidators: true } in the options object. Also note that unique: true is not a validator—it creates a unique index, and the database enforces uniqueness at save time.
Middleware (Hooks)
Middleware functions, also called hooks, let you execute code at specific stages of the document lifecycle. They come in two flavors: pre (before the operation) and post (after the operation).
Pre Middleware
Pre hooks run before the target operation. They can modify the document or abort the operation.
// Hash a password before saving
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 10;
userSchema.pre('save', async function(next) {
// 'this' refers to the document being saved
if (this.isModified('password') || this.isNew) {
try {
const salt = await bcrypt.genSalt(SALT_ROUNDS);
this.password = await bcrypt.hash(this.password, salt);
} catch (error) {
return next(error);
}
}
next();
});
// Pre-find middleware to add a query filter automatically
userSchema.pre('find', function() {
// 'this' refers to the Query object
this.where({ isActive: true }); // only find active users
});
// Pre middleware for delete operations
userSchema.pre('deleteOne', { document: true, query: false }, function(next) {
// When called on a document instance
console.log(`User ${this.name} is about to be deleted`);
next();
});
Post Middleware
// Post-save hook: runs after successful save
userSchema.post('save', function(doc, next) {
console.log(`User ${doc.name} has been saved successfully`);
// Perform side effects: send email, trigger analytics, etc.
next();
});
// Post-find hook for logging
userSchema.post('find', function(docs) {
console.log(`Query returned ${docs.length} documents`);
});
// Handle errors in post middleware
userSchema.post('save', function(error, doc, next) {
if (error.name === 'MongoServerError' && error.code === 11000) {
// Duplicate key error handling
console.error('Duplicate email detected');
next(new Error('A user with this email already exists'));
} else {
next(error);
}
});
Query Middleware vs Document Middleware
Middleware can target queries (find, findOne, updateOne, deleteOne when called on the model) or documents (save, validate, remove when called on an instance). Use the options { document: true } or { query: true } to disambiguate when the same method name applies to both contexts.
Virtuals
Virtuals are computed properties that are not persisted to the database. They are useful for formatting, combining fields, or exposing derived values.
const userSchema = new Schema({
firstName: String,
lastName: String,
dateOfBirth: Date
});
// Virtual for full name
userSchema.virtual('fullName').get(function() {
return `${this.firstName} ${this.lastName}`;
});
// Virtual with a setter
userSchema.virtual('fullName').set(function(fullName) {
const parts = fullName.split(' ');
this.firstName = parts[0];
this.lastName = parts.slice(1).join(' ');
});
// Virtual for age calculation
userSchema.virtual('age').get(function() {
const today = new Date();
const birth = this.dateOfBirth;
if (!birth) return null;
let age = today.getFullYear() - birth.getFullYear();
const monthDiff = today.getMonth() - birth.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
age--;
}
return age;
});
// Ensure virtuals are included in JSON output and console.log
userSchema.set('toJSON', { virtuals: true });
userSchema.set('toObject', { virtuals: true });
Virtuals shine when combined with population or when you want to expose computed fields in API responses without storing redundant data.
Query Building and Indexing
Advanced Query Patterns
// Chaining multiple query modifiers
const results = await User.find({ age: { $gte: 18 } })
.where('bio').ne(null)
.select('name email age')
.sort('-createdAt') // descending by createdAt
.skip(20) // skip first 20 results (pagination)
.limit(10)
.lean()
.exec();
// Using Query.prototype.then() - queries are thenable
const count = await User.countDocuments({ age: { $gt: 30 } });
// Distinct values
const uniqueAges = await User.distinct('age');
// Aggregation pipeline via Model.aggregate()
const aggregationResult = await User.aggregate([
{ $match: { age: { $gte: 20 } } },
{ $group: { _id: '$age', count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 5 }
]);
console.log('Age distribution:', aggregationResult);
Indexes
Define indexes at the schema level to ensure they are created automatically. Indexes dramatically speed up queries on large collections.
const userSchema = new Schema({
email: { type: String, unique: true }, // unique index
name: String,
age: Number,
city: String,
createdAt: Date
});
// Compound index for query performance
userSchema.index({ city: 1, age: -1 });
// Text index for full-text search
userSchema.index({ bio: 'text', name: 'text' });
// Unique compound index
userSchema.index({ email: 1, accountType: 1 }, { unique: true });
// TTL index (automatically delete documents after a period)
userSchema.index({ createdAt: 1 }, { expireAfterSeconds: 3600 * 24 * 30 }); // 30 days
// Background index creation (for production)
userSchema.index({ age: 1 }, { background: true });
Mongoose automatically syncs indexes in development, but in production, it's safer to manage indexes via migrations or the autoIndex: false option and create them manually.
Population: Working with References
Population is Mongoose's killer feature—it automatically replaces document references with the actual documents from other collections, similar to SQL joins but for NoSQL.
Basic Population
// Define schemas with references
const authorSchema = new Schema({
name: String,
bio: String,
website: String
});
const Author = mongoose.model('Author', authorSchema);
const bookSchema = new Schema({
title: { type: String, required: true },
author: { type: Schema.Types.ObjectId, ref: 'Author' }, // reference
publishedYear: Number,
genres: [String]
});
const Book = mongoose.model('Book', bookSchema);
// Create an author and a book referencing them
const author = await Author.create({
name: 'Jane Austen',
bio: 'English novelist',
website: 'https://janeausten.example.com'
});
const book = await Book.create({
title: 'Pride and Prejudice',
author: author._id,
publishedYear: 1813,
genres: ['Romance', 'Classic']
});
// Populate when querying
const populatedBook = await Book.findOne({ title: 'Pride and Prejudice' })
.populate('author')
.exec();
console.log(populatedBook.author.name); // 'Jane Austen'
// Populate with field selection
const bookWithPartialAuthor = await Book.findOne({})
.populate('author', 'name website') // only include name and website
.exec();
// Populate multiple paths
const librarySchema = new Schema({
name: String,
books: [{ type: Schema.Types.ObjectId, ref: 'Book' }],
owner: { type: Schema.Types.ObjectId, ref: 'User' }
});
const Library = mongoose.model('Library', librarySchema);
const populatedLibrary = await Library.findOne({})
.populate('owner')
.populate({
path: 'books',
select: 'title publishedYear',
options: { sort: { publishedYear: -1 } }
})
.exec();
Deep Population (Nested References)
// Nested population: populate fields within populated documents
const chapterSchema = new Schema({
title: String,
book: { type: Schema.Types.ObjectId, ref: 'Book' },
notes: [{ type: Schema.Types.ObjectId, ref: 'Note' }]
});
const Chapter = mongoose.model('Chapter', chapterSchema);
const noteSchema = new Schema({
content: String,
author: { type: Schema.Types.ObjectId, ref: 'Author' }
});
const Note = mongoose.model('Note', noteSchema);
// Deep populate
const chapter = await Chapter.findOne({ title: 'Chapter One' })
.populate({
path: 'book',
populate: {
path: 'author',
select: 'name'
}
})
.populate({
path: 'notes',
populate: {
path: 'author',
select: 'name bio'
}
})
.exec();
Populating Across Multiple Levels Dynamically
For maximum flexibility, you can define a recursive populate function or use mongoose-deep-populate plugin for deeply nested structures.
// Dynamic population example
async function populateRecursively(doc, paths, maxDepth = 3) {
let populated = doc;
for (let i = 0; i < maxDepth; i++) {
const options = paths.map(path => ({
path: path.split('.')[i] || path,
model: /* infer model from schema */ undefined
}));
if (options.every(opt => !opt.path)) break;
populated = await populated.populate(options).execPopulate
? populated.populate(options) // Mongoose 5
: await doc.constructor.populate(populated, options); // Mongoose 6+
}
return populated;
}
Advanced Schema Features
Discriminators
Discriminators enable schema inheritance, allowing you to have multiple models with overlapping schemas stored in the same collection. This is useful for event sourcing, user roles, or product types.
// Base schema for all events
const eventSchema = new Schema({
timestamp: { type: Date, default: Date.now },
user: { type: Schema.Types.ObjectId, ref: 'User' }
}, { discriminatorKey: 'eventType' }); // field that distinguishes types
const Event = mongoose.model('Event', eventSchema);
// Derived schemas for specific event types
const clickEventSchema = new Schema({
elementId: String,
pageUrl: String
});
const ClickEvent = Event.discriminator('ClickEvent', clickEventSchema);
const purchaseEventSchema = new Schema({
productId: String,
amount: Number,
currency: String
});
const PurchaseEvent = Event.discriminator('PurchaseEvent', purchaseEventSchema);
// All discriminators store documents in the same 'events' collection
await ClickEvent.create({
user: someUserId,
elementId: 'button-xyz',
pageUrl: '/home'
});
await PurchaseEvent.create({
user: someUserId,
productId: 'prod-123',
amount: 49.99,
currency: 'USD'
});
// Query the base model to get all event types
const allEvents = await Event.find({});
// Each document will have an 'eventType' field: 'ClickEvent' or 'PurchaseEvent'
// Query a specific discriminator
const clicks = await ClickEvent.find({ pageUrl: '/home' });
Plugins
Plugins are reusable schema extensions. You can package common functionality like timestamps, soft deletes, or pagination into plugins and apply them across your schemas.
// Create a plugin for soft delete functionality
function softDeletePlugin(schema, options) {
schema.add({
isDeleted: { type: Boolean, default: false },
deletedAt: Date
});
schema.pre('find', function() {
if (!this.getOptions().includeDeleted) {
this.where({ isDeleted: false });
}
});
schema.pre('findOne', function() {
if (!this.getOptions().includeDeleted) {
this.where({ isDeleted: false });
}
});
schema.statics.softDelete = async function(id) {
return this.findByIdAndUpdate(id, {
isDeleted: true,
deletedAt: new Date()
});
};
schema.methods.restore = async function() {
this.isDeleted = false;
this.deletedAt = undefined;
return this.save();
};
}
// Apply plugin to any schema
const postSchema = new Schema({
title: String,
content: String
});
postSchema.plugin(softDeletePlugin);
const Post = mongoose.model('Post', postSchema);
// Usage
const post = await Post.create({ title: 'Hello', content: 'World' });
await Post.softDelete(post._id); // soft delete
const visiblePosts = await Post.find({}); // excludes deleted
const allPosts = await Post.find({}).setOptions({ includeDeleted: true }); // includes deleted
Timestamps
Enable automatic createdAt and updatedAt fields by passing timestamps: true in the schema options. Mongoose automatically manages these fields for you.
const schema = new Schema({
title: String
}, {
timestamps: true, // adds createdAt and updatedAt
// timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' } // custom field names
});
Schema Options Reference
const schema = new Schema({
// fields
}, {
timestamps: true,
toJSON: { virtuals: true, getters: true },
toObject: { virtuals: true },
discriminatorKey: '__type',
collection: 'custom_collection_name',
autoIndex: process.env.NODE_ENV !== 'production',
optimisticConcurrency: true, // enables versionKey-based optimistic locking
strict: true, // only fields in schema are saved (default: true)
minimize: true, // remove empty objects (default: true)
id: true // virtual getter for _id as hex string
});
Error Handling and Debugging
Mongoose errors come in several flavors: validation errors, cast errors, and MongoDB errors. Proper handling prevents crashes and provides meaningful feedback to users.
// Centralized error handling middleware (Express example)
app.post('/users', async (req, res, next) => {
try {
const user = await User.create(req.body);
res.status(201).json(user);
} catch (error) {
if (error instanceof mongoose.Error.ValidationError) {
// Extract field-specific validation messages
const messages = Object.values(error.errors).map(e => e.message);
return res.status(400).json({
error: 'Validation failed',
details: messages
});
}
if (error instanceof mongoose.Error.CastError) {
return res.status(400).json({
error: 'Invalid data type',
field: error.path,
value: error.value
});
}
if (error.code === 11000) {
// Duplicate key error from MongoDB
const field = Object.keys(error.keyValue)[0];
return res.status(409).json({
error: `Duplicate value for field: ${field}`
});
}
// Pass unknown errors to global handler
next(error);
}
});
Debug Mode
Enable Mongoose debug logging to see all queries in the console. This is invaluable during development.
// Enable debug globally
mongoose.set('debug', true);
// Or enable only for specific environments
if (process.env.NODE_ENV === 'development') {
mongoose.set('debug', true);
}
// Custom debug function
mongoose.set('debug', (collectionName, methodName, query, doc) => {
console.log(`Mongoose: ${collectionName}.${methodName}`,
JSON.stringify(query, null, 2), doc || '');
});
Best Practices and Performance Optimization
1. Use lean() for Read-Only Queries
When you only need plain JavaScript objects (e.g., for API responses), use .lean(). It bypasses Mongoose's document hydration, returning faster and using less memory.
const users = await User.find({}).lean();
// users are plain objects, no setters/getters, no virtuals by default
2. Limit Fields with select()
Always project only the fields you need, especially for large documents.
const users = await User.find({}).select('name email').lean();
3. Use Indexes Strategically
Analyze your query patterns and create indexes that support them. Use explain() to understand query plans.
const explanation = await User.find({ email: 'alice@example.com' }).explain('executionStats');
console.log(explanation.executionStats);
4. Batch Operations
When dealing with many documents, use bulk operations for better performance.
const bulkOps = [
{ insertOne: { document: { name: 'User1', email: 'user1@test.com' } } },
{ insertOne: { document: { name: 'User2', email: 'user2@test.com' } } },
{ updateOne: { filter: { name: 'Alice' }, update: { $set: { age: 30 } } } }
];
await User.bulkWrite(bulkOps);
5. Connection Pool Management
Mongoose manages a connection pool (default size 100). Adjust based on your workload:
mongoose.connect(MONGO_URI, {
maxPoolSize: 50, // limit concurrent connections
minPoolSize: 10, // maintain minimum idle connections
serverSelectionTimeoutMS: 5000, // timeout for server selection
socketTimeoutMS: 45000, // close sockets after 45s of inactivity
family: 4 // force IPv4
});
6. Use Transactions for Consistency
For operations that must be atomic across multiple collections, use MongoDB transactions (requires replica set).
const session = await mongoose.startSession();
session.startTransaction();
try {
const user = await User.create([{ name: 'Transactional User' }], { session });
const order = await Order.create([{
userId: user[0]._id,
amount: 99.99
}], { session });
await session.commitTransaction();
console.log('Transaction committed successfully');
} catch (error) {
await session.abortTransaction();
console.error('Transaction aborted:', error);
} finally {
await session.endSession();
}
7. Avoid Global Plugins Unless Necessary
Applying plugins globally via mongoose.plugin() affects all schemas. Use schema-specific plugins for modularity.
8. Handle Connection Errors Gracefully
Implement retry logic and circuit breakers for production resilience.
const connectWithRetry = async (retries = 5, delay = 5000) => {
for (let i = 0; i < retries; i++) {
try {
await mongoose.connect(MONGO_URI);
console.log('Connected successfully');
return;
} catch (error) {
console.log(`Connection attempt ${i + 1} failed: ${error.message}`);
if (i < retries - 1) {
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw new Error('Failed to connect after multiple retries');