Mongoose ODM
Mongoose is the most widely used Object Document Mapper (ODM) for MongoDB in Node.js. It wraps the native driver with schemas, validation, middleware, and a query builder API — trading a little flexibility and a small performance overhead for structure and developer ergonomics.
Connecting
import mongoose from 'mongoose'
await mongoose.connect('mongodb://localhost:27017/shop', {
serverSelectionTimeoutMS: 5000
})
mongoose.connection.on('error', (err) => console.error('Mongo error:', err))Schemas and Models
A Schema defines the shape and rules for documents; a Model is the compiled constructor you actually query and save through.
import mongoose, { Schema } from 'mongoose'
const userSchema = new Schema(
{
email: { type: String, required: true, unique: true, lowercase: true, trim: true },
name: { type: String, required: true },
age: { type: Number, min: 0, max: 150 },
role: { type: String, enum: ['user', 'admin'], default: 'user' },
tags: [String]
},
{ timestamps: true } // adds createdAt / updatedAt automatically
)
const User = mongoose.model('User', userSchema)SchemaTypes and Built-in Validation
SchemaType | Common Options |
|---|---|
String | required, unique, minLength, maxLength, match, enum, trim, lowercase |
Number | required, min, max |
Date | required, min, max, default: Date.now |
Boolean | required, default |
ObjectId (Schema.Types.ObjectId) | ref (for populate) |
Array ([Type]) | default [] |
Mixed (Schema.Types.Mixed) | no validation — arbitrary shape |
const productSchema = new Schema({
sku: { type: String, required: true, unique: true },
price: {
type: Number,
required: true,
validate: {
validator: (v) => v >= 0,
message: (props) => `${props.value} is not a valid price`
}
}
})Queries
const user = await User.findOne({ email: 'alice@example.com' })
const admins = await User.find({ role: 'admin' }).sort({ createdAt: -1 }).limit(20)
const created = await User.create({ email: 'bob@example.com', name: 'Bob' })
await User.updateOne({ _id: user._id }, { $set: { name: 'Alice Smith' } })
await User.deleteOne({ _id: user._id })
// Instance style — load, mutate, save (triggers validation + middleware)
const doc = await User.findById(user._id)
doc.name = 'Alice S.'
await doc.save()Middleware (pre / post Hooks)
Middleware runs functions before or after schema operations — the classic use case is hashing a password before save, or logging after a delete.
import bcrypt from 'bcrypt'
userSchema.pre('save', async function hashPassword(next) {
if (!this.isModified('password')) return next()
this.password = await bcrypt.hash(this.password, 10)
next()
})
userSchema.post('save', function logCreated(doc) {
console.log('Saved user:', doc._id.toString())
})
userSchema.pre(/^find/, function excludeDeleted() {
this.where({ deletedAt: { $exists: false } })
})Virtuals
Virtuals are computed fields that don't get persisted to MongoDB — derived values based on other fields.
userSchema.virtual('fullName').get(function getFullName() {
return `${this.firstName} ${this.lastName}`
})
// Include virtuals when converting to JSON (e.g. for API responses)
userSchema.set('toJSON', { virtuals: true })populate() — Resolving References
Mongoose's ref + populate() is the ODM equivalent of a $lookup — it replaces stored ObjectIds with the referenced documents.
const orderSchema = new Schema({
customer: { type: Schema.Types.ObjectId, ref: 'User', required: true },
items: [{ product: { type: Schema.Types.ObjectId, ref: 'Product' }, qty: Number }]
})
const Order = mongoose.model('Order', orderSchema)
const order = await Order.findById(orderId)
.populate('customer', 'name email') // only pull name + email
.populate('items.product')lean() — Skipping Hydration
By default, query results are hydrated into full Mongoose documents (with getters, virtuals, save(), change tracking). If you only need plain data — e.g. serializing an API response — .lean() skips that overhead and returns plain JS objects.
const users = await User.find({ role: 'admin' }).lean()
// users are plain objects — faster, less memory, but no .save(), no virtuals,
// no getters/setters, no middleware on subsequent mutation.lean() for read-heavy, display-only queries (API list endpoints, reports). Skip it when you need to call .save(), use virtuals, or rely on middleware.Mongoose vs Native Driver
Mongoose | Native Driver | |
|---|---|---|
Schema enforcement | Yes, in application code | No (or via $jsonSchema validator) |
Validation | Built-in, declarative | Manual, or DB-side validator |
Middleware/hooks | Yes (pre/post) | No — write it yourself |
populate() joins | Yes, convenient | Manual $lookup or app-side fetch |
Performance overhead | Small (hydration, casting) | Minimal — closer to the wire protocol |
Learning curve | Lower for teams used to ORMs | Requires understanding raw MongoDB API |
Best for | Typical CRUD apps, fast iteration | High-throughput services, full control |
"42" becomes the number 42) — this is convenient but can hide bugs if you rely on it instead of validating input at the API boundary.Summary
Schemas + Models give you structure, validation, and casting on top of a schemaless database.
Middleware (pre/post) centralizes cross-cutting logic like hashing and soft-delete filtering.
Virtuals compute derived fields without storing them.
populate() resolves references declaratively; lean() skips hydration for read-only paths.
Reach for the native driver instead when you need maximum throughput or full control over the wire protocol.