Mongoose is the most popular Node.js ODM (Object Document Mapper) for MongoDB. It adds schemas, validation, middleware, virtuals, and type casting on top of the native driver — making your data layer predictable and your code organized.
# Install mongoose npm install mongoose # Also install dotenv for environment variables npm install dotenv
// db.js — connection module const mongoose = require('mongoose') const connectDB = async () => { try { const conn = await mongoose.connect(process.env.MONGODB_URI, { // These options are now defaults in Mongoose 6+ // But explicit for clarity: }) console.log(`MongoDB Connected: ${conn.connection.host}`) } catch (err) { console.error('MongoDB connection error:', err.message) process.exit(1) } } module.exports = connectDB // In your app.js / server.js: require('dotenv').config() const connectDB = require('./db') connectDB() // Connection events mongoose.connection.on('connected', () => console.log('Connected')) mongoose.connection.on('error', (err) => console.error(err)) mongoose.connection.on('disconnected', () => console.log('Disconnected')) // Graceful disconnect on app shutdown process.on('SIGINT', async () => { await mongoose.connection.close() process.exit(0) })
const { Schema, model, Types } = require('mongoose') // All Mongoose Schema Types: const exampleSchema = new Schema({ // Basic types name: String, age: Number, active: Boolean, birthday: Date, // Reference to another document userId: Types.ObjectId, // Arrays tags: [String], // array of strings scores: [Number], // array of numbers // Mixed — accepts any data metadata: Schema.Types.Mixed, // Buffer — binary data avatar: Buffer, // Map — dynamic keys settings: { type: Map, of: String }, // Embedded sub-document address: { street: String, city: String, zip: String } })
const userSchema = new Schema({ username: { type: String, required: [true, 'Username is required'], unique: true, trim: true, // remove leading/trailing whitespace lowercase: true, // store as lowercase minlength: [3, 'Min 3 chars'], maxlength: [30, 'Max 30 chars'], match: [/^[a-zA-Z0-9_]+$/, 'Alphanumeric only'] }, email: { type: String, required: true, unique: true, lowercase: true, trim: true }, password: { type: String, required: true, minlength: 8, select: false // never include in query results by default }, age: { type: Number, min: [0, 'Age cannot be negative'], max: [120, 'Invalid age'] }, role: { type: String, enum: { values: ['admin', 'author', 'reader'], message: '{VALUE} is not a valid role' }, default: 'reader' }, loginCount: { type: Number, default: 0 }, lastLogin: { type: Date, default: null }, website: { type: String, validate: { validator: (v) => /^https?:\/\/.*/.test(v), message: 'Website must be a valid URL starting with http/https' } }, authorId: { type: Types.ObjectId, ref: 'User' // for .populate() } }, { timestamps: true, // auto-adds createdAt and updatedAt toJSON: { virtuals: true }, // include virtuals when converting to JSON toObject: { virtuals: true } })
// models/User.js const mongoose = require('mongoose') const userSchema = new mongoose.Schema({ username: { type: String, required: true, unique: true }, email: { type: String, required: true, unique: true, lowercase: true }, password: { type: String, required: true, select: false }, role: { type: String, enum: ['admin', 'author', 'reader'], default: 'reader' } }, { timestamps: true }) // Model name: 'User' → collection: 'users' (auto-pluralized + lowercased) const User = mongoose.model('User', userSchema) module.exports = User
const User = require('./models/User') // ---- CREATE ---- // Method 1: new + save() const user = new User({ username: 'alice_dev', email: 'alice@example.com', password: 'hashedpassword' }) await user.save() // Method 2: create() — shortcut for new + save const user2 = await User.create({ username: 'bob_dev', email: 'bob@example.com', password: 'hashedpassword' }) // Method 3: insertMany await User.insertMany([ { username: 'carol', email: 'carol@example.com', password: '...' }, { username: 'dave', email: 'dave@example.com', password: '...' } ]) // ---- READ ---- // find() — returns array const users = await User.find({ role: 'author' }) // findOne() — returns one document or null const user = await User.findOne({ email: 'alice@example.com' }) // findById() — shortcut for findOne({ _id: id }) const user = await User.findById(req.params.id) // With select — include (+) or exclude (-) fields const user = await User.findById(id).select('username email role') const user = await User.findById(id).select('-password -__v') // ---- UPDATE ---- // findByIdAndUpdate — returns updated document const updated = await User.findByIdAndUpdate( id, { $set: { role: 'author' } }, { new: true, runValidators: true } // new: true = return updated doc, runValidators = validate on update ) // updateOne() / updateMany() await User.updateOne({ email: 'old@example.com' }, { $set: { email: 'new@example.com' } }) await User.updateMany({ role: 'reader' }, { $set: { newsletterEnabled: true } }) // ---- DELETE ---- // findByIdAndDelete — returns deleted document const deleted = await User.findByIdAndDelete(id) // deleteOne() / deleteMany() await User.deleteOne({ email: 'spam@example.com' }) await User.deleteMany({ active: false })
Middleware (hooks) are functions that run before (pre) or after (post) a specific event. Use them for password hashing, logging, cascading deletes, etc.
const bcrypt = require('bcrypt') // pre('save') — hash password before saving userSchema.pre('save', async function(next) { // 'this' refers to the document being saved if (!this.isModified('password')) return next() this.password = await bcrypt.hash(this.password, 12) next() }) // pre('save') — set updatedAt manually (if not using timestamps) userSchema.pre('save', function(next) { this.updatedAt = new Date() next() }) // pre('find') — automatically exclude inactive users userSchema.pre(/^find/, function(next) { // 'this' refers to the query this.where({ active: { $ne: false } }) next() }) // post('save') — log after saving userSchema.post('save', function(doc) { console.log(`User saved: ${doc.username}`) }) // pre('remove') — cascade delete user's posts userSchema.pre('remove', async function(next) { await Post.deleteMany({ author: this._id }) next() }) // Instance method — compare passwords userSchema.methods.comparePassword = async function(candidatePassword) { return bcrypt.compare(candidatePassword, this.password) } // Static method — find by email userSchema.statics.findByEmail = function(email) { return this.findOne({ email: email.toLowerCase() }) }
Virtuals are computed properties that are NOT stored in MongoDB. They exist only in JavaScript — computed on the fly.
userSchema.virtual('fullName').get(function() { return `${this.firstName} ${this.lastName}` }) userSchema.virtual('fullName').set(function(name) { const [first, ...rest] = name.split(' ') this.firstName = first this.lastName = rest.join(' ') }) // Usage: const user = await User.findById(id) console.log(user.fullName) // "Alice Johnson" // Virtual for post comment count postSchema.virtual('commentCount').get(function() { return this.comments.length }) // Virtual populate — like a lookup without storing the reference postSchema.virtual('author', { ref: 'User', localField: 'authorId', foreignField: '_id', justOne: true }) // Enable virtuals in JSON output (in schema options): // { toJSON: { virtuals: true }, toObject: { virtuals: true } }
// Post schema with author reference const postSchema = new mongoose.Schema({ title: String, body: String, authorId: { type: Types.ObjectId, ref: 'User' }, // ref enables populate tags: [String] }, { timestamps: true }) // Basic populate — replace authorId with full user document const post = await Post.findById(id).populate('authorId') // post.authorId is now a full User object, not just an ObjectId // Populate with field selection const post = await Post.findById(id) .populate('authorId', 'username email avatar -_id') // Populate multiple fields const post = await Post.findById(id) .populate('authorId', 'username') .populate('tags') // Populate with object options const post = await Post.findById(id).populate({ path: 'authorId', select: 'username email', match: { active: true }, // only populate if user is active options: { lean: true } }) // Nested populate — populate inside populated docs const post = await Post.findById(id).populate({ path: 'comments.userId', select: 'username avatar' })
// Mongoose query chaining const users = await User .find({ active: true }) .where('age').gte(18).lt(65) // where chaining .where('role').in(['admin', 'author']) .sort('-createdAt') // - prefix = descending .limit(10) .skip(0) .select('username email role createdAt') .populate('profile') // .lean() — return plain JS objects instead of Mongoose documents // Much faster — no hydration, no virtuals, no methods const users = await User.find({ active: true }).lean() // users is a plain array of JS objects — perfect for read-only APIs // countDocuments in Mongoose const total = await User.countDocuments({ active: true }) // Aggregation in Mongoose const stats = await User.aggregate([ { $group: { _id: '$role', count: { $sum: 1 } } } ])
const postSchema = new mongoose.Schema({ title: { type: String, required: [true, 'Title is required'], validate: { validator: function(v) { return v.length >= 5 && v.length <= 200 }, message: 'Title must be between 5 and 200 characters' } }, slug: { type: String, validate: { validator: async function(slug) { // Async validator — check uniqueness in DB const post = await this.constructor.findOne({ slug }) if (post && post._id.toString() !== this._id.toString()) { return false // slug already taken by another post } return true }, message: 'Slug already exists' } }, tags: { type: [String], validate: { validator: (tags) => tags.length <= 10, message: 'Maximum 10 tags allowed' } } }) // Handling validation errors try { await post.save() } catch (err) { if (err.name === 'ValidationError') { const messages = Object.values(err.errors).map(e => e.message) // ["Title is required", "Slug already exists"] } }
| Feature | Mongoose ODM | Native MongoDB Driver |
|---|---|---|
| Schema validation | Built-in, rich validators | Manual or $jsonSchema |
| Type casting | Automatic (string→ObjectId, etc) | Manual — exact types required |
| Middleware | pre/post hooks | Not available |
| Virtuals | Computed fields on model | Not available |
| Populate | Built-in .populate() | Manual $lookup aggregation |
| Query builder | Chainable .where().gte().lt() | Raw query objects only |
| Plugin ecosystem | Large (mongoose-paginate, etc) | Not applicable |
| Performance | Slightly slower (hydration overhead) | Faster (less abstraction) |
| Best for | Full-stack apps, rapid development | High-performance microservices |
| Learning curve | Steeper (more concepts) | Simpler (closer to mongosh) |