Mongoose has been the dominant Node.js ODM for MongoDB since ~2012; in 2026 it's at v9.x. Post 17 introduced it briefly; this post is the working deep-dive — every schema feature you'll actually use, validation, hooks (pre/post middleware), virtuals, getters/setters, methods + statics, populate + populate strategies, discriminators (single-collection inheritance), plugins, transactions inside Mongoose, performance traps (.lean()), and when to ditch it for the official driver + Zod.

Series map: 00 → 17 base course → 18 Mongoose Deep (this post) → 19 Beanie + FastAPI → 20 Outbox → 21 CQRS + ES → 22 RAG → 23 SQL→Mongo migration → 24 Kafka + Debezium CDC → 25 Testing → 26 OpenTelemetry → 27 Geo deep → 28 Time-series extreme → 29 K8s + mongosync + cost + compliance.


Learning Outcome

  • Define Mongoose schemas with every common option (types, defaults, validators, indexes).
  • Use pre/post hooks idiomatically.
  • Add virtuals, getters/setters, methods, statics, query helpers.
  • Use populate() with paths, multi-step, conditions; understand its costs.
  • Use discriminators for polymorphic collections.
  • Build + apply plugins.
  • Run Mongoose transactions with withTransaction.
  • Apply .lean() and other performance escape hatches.

Chapter 1 — Connect + bootstrap

1
2
npm install mongoose
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import mongoose from 'mongoose';

await mongoose.connect(process.env.MONGO_URI!, {
// pool + driver options pass through to the official driver
maxPoolSize: 50,
minPoolSize: 5,
appName: 'shop-api',
serverSelectionTimeoutMS: 5000,
});

// optional: strict query behaviour
mongoose.set('strictQuery', true); // ignore unknown filter fields silently? false = warn
mongoose.set('debug', process.env.NODE_ENV !== 'production'); // log every op

// graceful shutdown
process.on('SIGTERM', async () => { await mongoose.disconnect(); process.exit(0); });

mongoose is a singleton wrapper around MongoClient. Sharing connections + pool across the whole app is automatic.


Chapter 2 — Schemas

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { Schema, model, Types } from 'mongoose';

const UserSchema = new Schema({
name: { type: String, required: true, trim: true, minlength: 1 },
email: {
type: String,
required: true,
unique: true,
lowercase: true,
match: /^[^@\s]+@[^@\s]+\.[^@\s]+$/,
},
age: { type: Number, min: 0, max: 150 },
role: { type: String, enum: ['user', 'admin', 'staff'], default: 'user' },
tags: { type: [String], default: [] },
address: {
city: String,
country: { type: String, default: 'UK' },
},
meta: { type: Schema.Types.Mixed, default: {} }, // any shape
refId: { type: Schema.Types.ObjectId, ref: 'Order' }, // for populate
}, {
timestamps: true, // adds createdAt + updatedAt
collection: 'users', // override default 'users'
optimisticConcurrency: true, // adds __v + checks on save
toJSON: { virtuals: true },
toObject: { virtuals: true },
});

UserSchema.index({ email: 1 }, { unique: true });
UserSchema.index({ 'address.country': 1 });

export const User = model('User', UserSchema);

Schema type shorthand: name: String is equivalent to name: { type: String }.

Built-in types

String, Number, Date, Boolean, Buffer, ObjectId (Schema.Types.ObjectId), Array, Map, Decimal128, Mixed (any), UUID (8.x+).

Schema-level options worth knowing

Option Effect
timestamps: true auto createdAt + updatedAt
strict: 'throw' error on unknown fields (default: silently strip)
versionKey: false omit __v
optimisticConcurrency: true use __v for safer save() concurrency
discriminatorKey: 'kind' override default __t
id: false disable virtual id getter (string of _id)
toJSON: { virtuals: true } include virtuals when serialising

Chapter 3 — Validators

Built-in: required, min, max, minlength, maxlength, match, enum.

Custom synchronous:

1
2
3
4
5
6
7
8
age: {
type: Number,
validate: {
validator: (v: number) => v % 1 === 0,
message: (props) => `${props.value} is not an integer`,
}
}

Custom async:

1
2
3
4
5
6
7
8
9
10
11
email: {
type: String,
validate: {
validator: async function (v) {
const exists = await this.constructor.findOne({ email: v }).lean();
return !exists;
},
message: 'email already taken',
}
}

Run validators on update:

1
2
await User.updateOne({ _id }, { $set: { age: 999 } }, { runValidators: true });

By default validators run only on save(). Always pass runValidators: true when using updateOne / updateMany / findOneAndUpdate if you want validation enforced.


Chapter 4 — Hooks (pre / post middleware)

Hooks fire around save, validate, remove, init, updateOne, findOneAndUpdate, aggregate, and more.

1
2
3
4
5
6
7
8
9
10
11
12
13
import bcrypt from 'bcrypt';

UserSchema.pre('save', async function (next) {
if (this.isModified('password')) {
this.password = await bcrypt.hash(this.password, 12);
}
next();
});

UserSchema.post('save', function (doc) {
publishEvent({ type: 'user.saved', id: doc._id });
});

Document middleware (save, validate, remove, init) — this is the document.
Query middleware (find, findOneAndUpdate, etc.) — this is the query, not the doc:

1
2
3
4
UserSchema.pre('find', function () {
this.where({ deletedAt: { $exists: false } }); // soft-delete filter on every find
});

Aggregation middleware:

1
2
3
4
UserSchema.pre('aggregate', function () {
this.pipeline().unshift({ $match: { deletedAt: { $exists: false } } });
});

Pitfalls

  • updateOne / findOneAndUpdate hooks don't fire save middleware. Use the updateOne hook explicitly.
  • bulkWrite ops skip hooks unless you opt in.
  • Throw inside pre to abort the op.
  • post('save') can't reject the operation — for side-effect-only work.

Chapter 5 — Virtuals, getters, setters

1
2
3
4
5
6
7
8
UserSchema.virtual('fullName')
.get(function () { return `${this.firstName} ${this.lastName}`; })
.set(function (v: string) {
const [first, ...rest] = v.split(' ');
this.firstName = first;
this.lastName = rest.join(' ');
});

Virtuals aren't persisted. Include in JSON output with toJSON: { virtuals: true }.

Getters / setters transform field values on read / write:

1
2
3
4
5
6
7
8
const Schema = new Schema({
email: {
type: String,
get: (v: string) => v.toLowerCase(),
set: (v: string) => v.trim(),
},
});

Caveat: getters don't run inside .lean() calls. Setters run on assignment + save.


Chapter 6 — Methods, statics, query helpers

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// instance method
UserSchema.methods.greet = function () { return `Hi, ${this.name}`; };
const u = await User.findById(id);
u.greet();

// static (collection-level)
UserSchema.statics.findByEmail = function (email: string) {
return this.findOne({ email });
};
await User.findByEmail('ada@x');

// chainable query helper
UserSchema.query.active = function () { return this.where({ deletedAt: null }); };
await User.find().active().sort({ createdAt: -1 });

Keep models tidy by attaching domain logic here.


Chapter 7 — Populate (the Mongoose "join")

1
2
3
4
5
6
7
8
9
10
const PostSchema = new Schema({
title: String,
author: { type: Schema.Types.ObjectId, ref: 'User' },
comments: [{ type: Schema.Types.ObjectId, ref: 'Comment' }],
});

const post = await Post.findById(id)
.populate('author') // fetch author doc
.populate({ path: 'comments', select: 'text createdAt' }); // fetch comments

Multi-step:

1
2
3
4
5
await Post.findById(id).populate({
path: 'comments',
populate: { path: 'author', select: 'name' }
});

Conditional populate:

1
2
3
4
5
6
await Post.find().populate({
path: 'comments',
match: { hidden: false },
options: { sort: { createdAt: -1 }, limit: 10 },
});

Cost

populate runs additional queries — one per referenced collection, with $in over the gathered IDs. Not server-side $lookup — happens client-side via the driver.

When you have many populates over big lists, prefer:

  1. Embedded references — denormalise the rare fields you always need (Post 07 extended-reference pattern).
  2. Aggregation $lookup — single round-trip server-side.

Trick: lint for populates inside loops. Each iteration is a fresh round-trip. Always fan out the IDs first, then one User.find({ _id: { $in: ids } }), then map.

Round trips to load 100 posts with authors: populate in a loop takes 101, populate takes 2, and $lookup takes 1


Chapter 8 — Discriminators (polymorphic collection)

Single collection holds multiple subtypes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const EventSchema = new Schema(
{ user: String, at: Date },
{ discriminatorKey: 'kind', collection: 'events' }
);
const Event = model('Event', EventSchema);

const ClickEvent = Event.discriminator('Click', new Schema({
url: String,
button: String,
}));

const PurchaseEvent = Event.discriminator('Purchase', new Schema({
amount: Number,
items: [String],
}));

await ClickEvent.create({ user: 'ada', at: new Date(), url: '/home', button: 'cta' });
await PurchaseEvent.create({ user: 'ada', at: new Date(), amount: 50, items: ['p1'] });

await Event.find(); // all events of all types
await ClickEvent.find(); // only clicks

The discriminator key kind is stored on each doc (Click or Purchase). Queries on the subtype model auto-filter.

Use for the polymorphic pattern (Post 07). Cleaner than manual type field handling in the app.

Embedded discriminators work too — array of mixed-type sub-documents.


Chapter 9 — Plugins

Plugins add cross-cutting schema features. Pattern:

1
2
3
4
5
6
7
8
9
10
11
12
function softDelete(schema: Schema) {
schema.add({ deletedAt: { type: Date, default: null } });
schema.pre(/^find/, function (this: any) { this.where({ deletedAt: null }); });
schema.methods.softDelete = function () {
this.deletedAt = new Date();
return this.save();
};
}

UserSchema.plugin(softDelete);
PostSchema.plugin(softDelete);

Useful community plugins

Plugin Use
mongoose-paginate-v2 cursor + offset pagination helpers
mongoose-autopopulate auto populate on configured fields
mongoose-aggregate-paginate-v2 paginate aggregation results
mongoose-unique-validator nicer error on unique violation
mongoose-encryption field-level encryption (CSFLE alternative)
mongoose-history revision log per doc
mongoose-delete soft-delete out-of-the-box
mongoose-lean-virtuals preserve virtuals in .lean() output

Apply globally: mongoose.plugin(softDelete) before any model registration.


Chapter 10 — Transactions in Mongoose

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const session = await mongoose.startSession();

try {
await session.withTransaction(async () => {
const acc = await Account.findById('A').session(session);
if (acc.balance < amount) throw new Error('insufficient');
acc.balance -= amount;
await acc.save({ session });

await Account.updateOne({ _id: 'B' }, { $inc: { balance: amount } }, { session });
await Transfer.create([{ from: 'A', to: 'B', amount }], { session });
});
} finally {
await session.endSession();
}

Pass { session } to every Mongoose op inside. Same rules + retry semantics as the official-driver pattern (Post 09).

Note Model.create([...], { session }) — array form is required when passing options to create.


Chapter 11 — .lean() — the perf escape hatch

By default Mongoose find returns Mongoose documents — heavy objects with getters, virtuals, change tracking, methods.

For read-only queries (API responses, dashboards), use .lean():

1
2
const users = await User.find({ ... }).lean();    // plain JS objects, no overhead

Faster by 2-10× on big result sets. Loses: virtuals (use mongoose-lean-virtuals), getters, instanceof checks, methods, change tracking.

Rule: default .lean() on every list endpoint. Skip it only when you're going to mutate + save.


Chapter 12 — Indexes + Mongoose

Schema-level:

1
2
3
4
UserSchema.index({ email: 1 }, { unique: true });
UserSchema.index({ createdAt: -1 });
UserSchema.index({ name: 'text', bio: 'text' });

On startup, Mongoose calls createIndexes for each model. In production, disable auto-indexing + create indexes through migrations:

1
2
mongoose.set('autoIndex', process.env.NODE_ENV !== 'production');

Auto-indexing blocks reads/writes on first run with a big collection. Always control index builds explicitly in production.


Chapter 13 — Subdocuments + arrays

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const AddressSchema = new Schema({
line1: String,
city: String,
}, { _id: false }); // no _id on each subdoc

const UserSchema = new Schema({
name: String,
addresses: [AddressSchema],
});

const u = await User.findById(id);
u.addresses.push({ line1: '...', city: 'London' });
u.addresses.id(subdocId).remove(); // pull by subdoc _id
await u.save();

Subdocument arrays are validated + middleware-aware. Use them for bounded 1:few children (Post 07).


Chapter 14 — Schema-less / mixed types

When a field genuinely has dynamic shape:

1
2
3
4
5
6
7
8
9
const Schema = new Schema({
payload: Schema.Types.Mixed,
});

const doc = new Model({ payload: {} });
doc.payload.foo = 'bar';
doc.markModified('payload'); // required! Mongoose can't detect nested Mixed changes
await doc.save();

For SaaS multi-tenant where each tenant has custom fields, prefer the attribute pattern (Post 07) over Mixed.


Chapter 15 — Mongoose vs official driver + Zod

Aspect Mongoose Official + Zod
Schema + validation rich, in-DB-friendly runtime validation only
Middleware (hooks) yes none built-in (wrap manually)
populate client-side joins free use $lookup aggregation
Type safety in TypeScript inferred but partial full inference via Zod
Performance 30-50 % slower than driver matches driver
Aggregation thin wrapper identical to driver
Discriminators nice manual type field
Community huge thinner

Pick Mongoose for: traditional Express/Node services, teams used to ORM-style codebases, lots of populate use.

Pick official + Zod for: TypeScript-first projects, edge runtimes, services where aggregation is hot path, microservices that want minimal dependencies.

Both run perfectly fine on the same DB. You can mix per service.


Chapter 16 — Common pitfalls

Pitfall Cause Fix
Validators not running on update default: only save runs them runValidators: true
populate round-trips per loop iter N+1 query problem batch IDs, single $in query
Mixed field changes not saved Mongoose can't detect nested change doc.markModified('field')
Heavy responses on list endpoints not using .lean() add .lean()
Production startup slow / blocking autoIndex rebuild on big collection disable in prod; migrate indexes manually
Hooks fire twice on same op both pre('save') + pre('updateOne') registered pick one per op
findOneAndUpdate returns old doc default behaviour { new: true }
Discriminator queries on base also return subtype by design; filter explicitly if needed Model.find({ kind: 'Click' })
Schema circular ref errors two schemas reference each other before model registered declare schemas first, register models after

Practice Exercises

Exercise 1 — Soft-delete plugin

Build the soft-delete plugin from Chapter 9. Apply to User + Post. Confirm queries auto-filter, .softDelete() works.

Exercise 2 — Populate vs $lookup benchmark

Build the post-with-author relationship. Fetch 100 posts with populate('author') vs an aggregation $lookup. Measure latency.

Exercise 3 — Discriminators

Create Event base + ClickEvent + PurchaseEvent discriminators. Insert mixed events. Query both with the base model + each subtype.

Exercise 4 — Transactions

Implement the transfer(fromId, toId, amount) function from Chapter 10. Test the insufficient-funds rollback.

Exercise 5 — Hooks chain

Add a pre('save') hook hashing passwords, a post('save') hook publishing an event, and a pre('find') soft-delete filter. Verify all fire correctly.

Exercise 6 — .lean() mass conversion

Take any list endpoint in a Mongoose app. Add .lean(). Measure p95 latency before vs after.


Cheat-sheet

What How
Connect await mongoose.connect(uri, options)
Define schema new Schema({...}, { timestamps: true })
Index schema.index({ f: 1 }, { unique: true })
Validator validate: { validator, message }
Pre/post hook schema.pre('save', fn) / post
Query middleware schema.pre('find', fn) (this = query)
Virtual schema.virtual('name').get(fn)
Method (instance) schema.methods.x = fn
Static (model) schema.statics.x = fn
Query helper schema.query.x = fn
Populate .populate('field') or pipeline form
Discriminator Base.discriminator('Sub', subSchema)
Plugin schema.plugin(fn) or mongoose.plugin(fn) global
Transaction session.withTransaction(async () => { ... })
Pass session { session } to every op
Perf escape .lean() for read-only
Production index autoIndex: false; migrate manually
Soft delete plugin + query middleware filter
Update validation { runValidators: true }
Get new doc post-update { new: true }
Mongoose v current 9.x (2026)

What's next

Post 19 covers Beanie + Pydantic v2 + FastAPI — the Python equivalent of Mongoose's ergonomics, integrated with the dominant Python web stack.


References

Note: this post is in progress. Planned additions: Mongoose 9 changelog highlights; integration with NestJS; comparison with typegoose; perf benchmarks.