MongoDB Course — 18 · Mongoose Deep Dive (Schemas, Hooks, Populate, Discriminators, Plugins, Transactions)
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 / findOneAndUpdatehooks don't firesavemiddleware. Use theupdateOnehook explicitly.bulkWriteops skip hooks unless you opt in.- Throw inside
preto 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:
- Embedded references — denormalise the rare fields you always need (Post 07 extended-reference pattern).
- 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.
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
- Mongoose docs
- Mongoose Schemas Guide
- Mongoose Discriminators
- Mongoose Plugins
- Mongoose ODM Support (MongoDB Inc)
Note: this post is in progress. Planned additions: Mongoose 9 changelog highlights; integration with NestJS; comparison with
typegoose; perf benchmarks.










