145: Mongoose Deep Dive — Schemas, Models, Validation, Middleware, `lean()`, Populate, Transactions, Discriminators, and Concurrency
Learning objectives
You will learn to:
- understand what Mongoose adds over the native driver;
- use modern Mongoose 9 patterns;
- create schemas, models, documents, and subdocuments;
- understand casting and validation;
- use defaults, timestamps, getters, setters, virtuals, and methods carefully;
- understand middleware order and side effects;
- understand
save()versus query updates; - use
lean()appropriately; - use
populate()without recreating relational over-normalization; - declare indexes correctly;
- use sessions/transactions;
- use discriminators;
- understand optimistic concurrency;
- avoid common Mongoose performance and correctness traps.
Current baseline
As of August 2026, Mongoose 9 is the current major line.
Install:
npm install mongoose
Avoid old tutorials containing:
useNewUrlParser: true
useUnifiedTopology: true
useFindAndModify: false
useCreateIndex: true
These legacy connection/configuration options are from older Mongoose generations.
What Mongoose is
Mongoose is an ODM:
Object Document Mapper
It adds application modeling on top of MongoDB:
Schema Model Document casting validation middleware virtuals populate discriminators plugins
Mongoose internally uses the MongoDB Node driver.
It does not replace MongoDB knowledge.
If you do not understand:
- indexes;
- aggregation;
- transactions;
- shard keys;
- read/write concerns;
- document modeling;
Mongoose cannot make those decisions for you.
Connect
import mongoose from 'mongoose';
await mongoose.connect(
process.env.MONGODB_URI,
);
For a larger application, create connection/lifecycle explicitly rather than importing a module that auto-connects on import.
Shutdown:
await mongoose.disconnect();
or close specific connection.
Schema
const taskSchema = new mongoose.Schema(
{
tenantId: {
type: mongoose.Schema.Types.ObjectId,
required: true,
index: true,
},
title: {
type: String,
required: true,
trim: true,
minlength: 3,
maxlength: 80,
},
completed: {
type: Boolean,
default: false,
},
priority: {
type: String,
enum: [
'low',
'normal',
'high',
],
default: 'normal',
},
version: {
type: Number,
default: 1,
},
},
{
timestamps: true,
strict: true,
},
);
Schema is application-layer contract.
MongoDB collection can also have database validator.
SchemaTypes
Common:
String Number Date Buffer Boolean ObjectId Array Decimal128 Map Mixed UUID in supported versions BigInt in supported versions
Check current Mongoose docs for exact SchemaType support.
Choose types to match MongoDB semantics.
Casting
Mongoose may cast input:
Task.findOne({
_id: '66d0...',
});
into ObjectId if valid.
Casting convenience can hide bad API validation.
A malformed external ID should be validated at HTTP boundary and return controlled 400/422, not rely on a CastError reaching global error middleware.
Validation
Built-in validators run on document save and selected operations according to API/options.
Example:
const task = new Task({
title: 'x',
});
await task.save();
// validation error
Application can inspect validation errors.
But server/API should map to stable public error format.
Custom validator
title: {
type: String,
validate: {
validator(value) {
return !value.includes('\0');
},
message: 'Title contains invalid characters.',
},
}
Do not put database/network I/O into every field validator.
Cross-document business validation belongs service/repository/transaction architecture.
Update validators
Mongoose update operations have different validation semantics from save().
Do not assume:
Model.updateOne(...)
runs every document validator/middleware exactly like:
document.save()
Use current options such as runValidators where appropriate, and understand which validators run on updated paths.
This difference is a common production bug.
Defaults
priority: {
type: String,
default: 'normal',
}
Default applies when value is undefined according to Mongoose semantics.
Do not expect default to replace explicit:
null
unless schema logic says so.
Timestamps
{
timestamps: true
}
creates/maintains:
createdAt updatedAt
Useful.
Do not update createdAt manually.
For business dates separate from persistence timestamps, define separate field.
Getters/setters
Setter:
email: {
type: String,
set(value) {
return value.trim().toLowerCase();
},
}
Normalization can be useful.
But email normalization is more nuanced than simple lowercase for all identity policies.
Do not hide high-impact business transformations in obscure setters.
Virtuals
taskSchema.virtual('isOpen').get(
function () {
return !this.completed;
},
);
Virtual not stored in MongoDB.
Useful presentation/domain convenience.
Do not query/index a virtual as if persisted.
Instance methods
taskSchema.methods.canBeClosed = function () {
return !this.completed;
};
Can be useful for document behavior.
Large business logic tightly coupled to Mongoose document can make service testing/migration harder.
Keep critical domain rules in explicit service/domain modules when architecture benefits.
Static methods
taskSchema.statics.findOpenForTenant =
function (tenantId) {
return this.find({
tenantId,
completed: false,
});
};
Can centralize query.
Repository abstraction may still be clearer in large systems.
Model
const Task = mongoose.model(
'Task',
taskSchema,
);
Model maps to collection.
Model compilation is global-ish per Mongoose connection.
In hot-reload/serverless development, model recompilation can produce errors if code repeatedly defines same model. Use framework-aware patterns.
Documents
const task = new Task({
tenantId,
title: 'Learn Mongoose',
});
await task.save();
Hydrated document includes:
- getters/setters;
- change tracking;
- methods;
- save;
- virtuals.
That has memory/CPU overhead compared with plain BSON objects.
lean()
Read-only query:
const tasks = await Task
.find({
tenantId,
})
.lean();
lean() skips Mongoose document hydration and returns plain objects.
Benefits:
- lower memory;
- faster reads.
Trade-offs:
- no document methods;
- no save;
- getters/virtual behavior differs unless specific plugins/options;
- change tracking absent.
Use lean() for API list/read paths where hydrated document behavior is unnecessary.
Do not blindly use for code expecting methods/virtuals.
save()
const task = await Task.findOne({
_id: taskId,
tenantId,
});
if (!task) ...
task.completed = true;
await task.save();
Mongoose tracks changes and runs save validation/middleware.
Potential read-modify-write concurrency issue.
Use optimistic concurrency or atomic query updates where needed.
Query updates
await Task.updateOne(
{
_id: taskId,
tenantId,
},
{
$set: {
completed: true,
},
},
{
runValidators: true,
},
);
More direct/atomic.
But document save middleware does not automatically run for query update.
Mongoose has separate query middleware.
Choose based on invariants.
Middleware
Types include hooks around operations such as:
validate save find findOneAndUpdate deleteOne aggregate
Example:
taskSchema.pre(
'save',
function () {
this.title = this.title.trim();
},
);
Middleware is powerful but can hide work.
Avoid middleware that:
- sends external emails;
- makes expensive remote calls;
- changes unrelated collections silently;
- creates retry-unfriendly side effects.
A developer calling:
await task.save();
should not unknowingly charge payment.
Middleware order
Validation/save hook ordering matters.
Plugins can add hooks.
When debugging surprising behavior, inspect all schema middleware/plugins.
Do not treat Mongoose lifecycle as magic.
Error middleware
Mongoose has error-handling middleware patterns.
Still map DB errors at repository/service boundary.
Duplicate key from MongoDB is not a Mongoose validation error.
Catch Mongo duplicate key code and map to conflict.
Unique is not validator
This:
email: {
type: String,
unique: true,
}
declares index intention; it is not a per-document validator guaranteeing race-free precheck.
Database unique index enforces uniqueness.
Handle duplicate-key error.
Indexes
Schema:
taskSchema.index({
tenantId: 1,
completed: 1,
createdAt: -1,
});
Production recommendation:
- define indexes in code/migration plan;
- avoid uncontrolled automatic index builds at application startup on huge production collection.
Mongoose autoIndex behavior should be deliberately configured for production.
Use an explicit deployment/index management process.
Subdocuments
const itemSchema = new mongoose.Schema(
{
sku: String,
quantity: Number,
},
{
_id: false,
},
);
const orderSchema = new mongoose.Schema({
items: [itemSchema],
});
Subdocuments have Mongoose behavior/middleware.
Understand difference between nested path and subdocument.
Do not create subdocument _id when no need.
Arrays
Mongoose tracks arrays.
Unbounded arrays remain a Mongo modeling anti-pattern.
ODM convenience does not change BSON document size or index cost.
Mixed
mongoose.Schema.Types.Mixed
allows arbitrary shape.
Useful in carefully scoped polymorphic metadata.
Risk:
- no strong casting/validation;
- change tracking nuances;
- schema becomes opaque.
Do not make every field Mixed to avoid schema design.
Map
For dynamic key-value structure with consistent value type:
settings: {
type: Map,
of: String,
}
Dynamic field names can be hard to query/index.
Use only if access pattern fits.
Populate
Schema:
authorId: {
type: ObjectId,
ref: 'User',
}
Query:
const post = await Post
.findById(id)
.populate('authorId');
Populate performs additional query/join-like ODM work.
It is not embedding.
Do not normalize everything into references because populate is convenient.
Mongo data modeling lessons still apply.
Populate performance
Potential problems:
- many populations;
- deep nested populate;
- large result sets;
- huge fields;
- repeated query work.
Use:
- projection/select;
- lean;
- explicit aggregation
$lookupwhen suitable; - embedding/denormalization;
- batch query.
Measure.
Autopopulate caution
Plugins that automatically populate every query hide I/O.
This can turn:
find()
into many expensive operations unexpectedly.
Prefer explicit populate for critical paths.
Discriminators
Use polymorphic documents sharing one collection.
Base:
const eventSchema = new Schema({
tenantId: ObjectId,
occurredAt: Date,
}, {
discriminatorKey: 'type',
});
const Event = model(
'Event',
eventSchema,
);
Subtype:
const PaymentEvent = Event.discriminator(
'payment',
new Schema({
amountPaise: Number,
}),
);
Good for related variants.
Do not put completely unrelated domains in one discriminator collection.
Transactions
Mongoose uses MongoDB sessions.
await mongoose.connection.transaction(
async (session) => {
await Order.updateOne(
{
_id: orderId,
},
{
$set: {
status: 'paid',
},
},
{
session,
},
);
await Payment.create(
[
{
orderId,
amountPaise,
},
],
{
session,
},
);
},
);
Use current Mongoose transaction helper.
Do not parallelize unrelated operations inside same transaction casually.
Do not call external side effects inside retryable transaction callback.
Session propagation
Queries/documents need correct session.
Mongoose can associate sessions with documents in some flows.
Be explicit in repository/service architecture.
Missing session on one write means it is outside transaction.
Optimistic concurrency
Mongoose supports schema option:
const schema = new Schema(
{...},
{
optimisticConcurrency: true,
},
);
Mongoose uses version key to detect stale save().
Default version key is commonly:
__v
You can configure it.
This protects read-modify-save workflows.
Understand difference from your own domain version field/API ETag.
VersionKey
Do not simply disable:
versionKey: false
because it looks ugly before understanding how versioning/concurrency may use it.
If API should not expose __v, transform output rather than necessarily removing concurrency metadata.
findOneAndUpdate versus save
findOneAndUpdate is atomic at query operation level and useful for direct updates.
But:
- save middleware differs;
- document validation differs;
- return semantics differ.
Use the right tool.
Serialization
Customize toJSON/toObject carefully:
taskSchema.set(
'toJSON',
{
transform(doc, ret) {
ret.id = ret._id.toString();
delete ret._id;
delete ret.__v;
return ret;
},
},
);
Do not serialize secrets/internal fields.
Do not mutate database representation accidentally.
Query casting security
Mongoose can cast query values, but does not mean raw client filter is safe.
Never:
Task.find(req.query);
Build allowlisted filter.
Mongoose ODM does not remove NoSQL injection/query-cost risks.
Strict query behavior
Mongoose has strict query options/settings that evolve by major version.
Do not rely on defaults for security.
Validate at HTTP boundary and construct known filter.
Plugins
Plugins can add:
- soft delete;
- pagination;
- auditing;
- autopopulate.
Every plugin can:
- add middleware;
- mutate queries;
- add dependencies.
Review plugin source/maintenance.
Do not install a plugin for five lines of code without understanding impact.
Repository with Mongoose
export function createTaskRepository({
Task,
}) {
return {
async findById({
tenantId,
taskId,
}) {
return Task
.findOne({
_id: taskId,
tenantId,
})
.lean();
},
async create(input) {
const document = await Task.create(
input,
);
return document.toObject();
},
};
}
Service still should not import Express.
Testing
Test:
- casting;
- validation;
- unique index integration;
- middleware;
- lean paths;
- populate;
- transaction;
- optimistic concurrency.
Use real Mongo instance for repository integration.
Mocking Model methods alone does not prove indexes/query semantics.
Failure clinic
unique: true treated as validator
Race bug.
lean() then calling .save()
Plain object has no document API.
every query autopopulates
Performance surprise.
query update assumed save middleware
Invariant missed.
raw req.query into Model.find
Injection/query abuse.
autoIndex builds huge indexes at every production startup
Operational problem.
business side effects in pre-save middleware
Hidden retries/coupling.
Mongoose schema used as only database validation when multiple writers exist
Other writers can bypass.
Exercises
- Build Task schema with validation/timestamps.
- Compare hydrated document memory/behavior versus lean result.
- Write save and updateOne variants and compare hooks.
- Add compound unique index and handle duplicate key.
- Add subdocument items.
- Use populate, then remodel same access pattern with embedding and compare.
- Create discriminator collection.
- Enable optimistic concurrency and reproduce stale save.
- Execute transaction with session.
- Disable auto index in production plan and create index deployment script.
- Audit a plugin before installation.
Mastery checklist
Explain:
- ODM/native driver difference;
- schemas/models/documents;
- casting/validation;
- save versus query update;
- middleware;
- indexes/unique;
- lean;
- populate;
- subdocuments;
- discriminators;
- transactions;
- optimistic concurrency;
- plugin/autoIndex risks.
Official references
- https://mongoosejs.com/docs/
- https://mongoosejs.com/docs/guide.html
- https://mongoosejs.com/docs/validation.html
- https://mongoosejs.com/docs/middleware.html
- https://mongoosejs.com/docs/tutorials/lean.html
- https://mongoosejs.com/docs/populate.html
- https://mongoosejs.com/docs/transactions.html
- https://roadmap.sh/mongodb
