FullStack Course LogoFullStack Course
Module: MongoDB
MongoDB·145·14 MIN READ

145: Mongoose Deep Dive — Schemas, Models, Validation, Middleware, `lean()`, Populate, Transactions, Discriminators, and Concurrency

TOPICS COVERED: Mongoose Deep Dive — Schemas, Models, Validation, Middleware, `lean()`, Populate, Transactions, Discriminators, and Concurrency

Learning objectives

By the end of this lesson, you should be able to:

  • explain what Mongoose adds on top of the native MongoDB driver;
  • use modern Mongoose 9 patterns;
  • create schemas, models, documents, and subdocuments;
  • explain casting and validation;
  • use defaults, timestamps, getters, setters, virtuals, and methods deliberately;
  • reason about middleware order and side effects;
  • distinguish save() from query updates;
  • use lean() when its trade-offs are appropriate;
  • use populate() without turning a document model into relational over-normalization;
  • declare indexes correctly;
  • use sessions and transactions;
  • use discriminators for related polymorphic documents;
  • understand optimistic concurrency;
  • avoid common Mongoose performance and correctness traps.

Current baseline

As of August 2026, Mongoose 9 is the current major line.

Install it with:

bash
npm install mongoose

Be cautious with older tutorials that still contain:

js
useNewUrlParser: true
useUnifiedTopology: true
useFindAndModify: false
useCreateIndex: true

Those are legacy connection or configuration options from older Mongoose generations. They should not be copied into a current application without checking the version-specific documentation.

What Mongoose is

If you are using MongoDB through Mongoose, you are adding an application-level modeling layer to the native driver. Mongoose is an ODM:

text
Object Document Mapper

The layer provides concepts such as:

text
Schema
Model
Document
casting
validation
middleware
virtuals
populate
discriminators
plugins

Mongoose internally uses the MongoDB Node driver. That means it provides useful application behavior, but it does not replace knowledge of MongoDB itself.

If you do not understand:

  • indexes;
  • aggregation;
  • transactions;
  • shard keys;
  • read/write concerns;
  • document modeling;

Mongoose cannot make those database decisions for you. A schema can describe application expectations, but it cannot turn an unsuitable index or document model into a suitable one.

Connect

At the simplest level, connect during application startup:

js
import mongoose from 'mongoose';

await mongoose.connect(
  process.env.MONGODB_URI,
);

In a larger application, make connection and lifecycle management explicit. Importing a module that connects as a side effect makes startup ordering, tests, workers, and shutdown harder to reason about.

For a clean shutdown, disconnect the default connection:

js
await mongoose.disconnect();

If the application manages more than one connection, close the specific connection instead.

Schema

A schema describes the shape and behavior Mongoose expects at the application boundary. For example:

js
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,
  },
);

Think of a schema as an application-layer contract. It controls Mongoose casting, validation, defaults, and related behavior. MongoDB collections can also have a database validator, which is especially valuable when more than one writer can modify the collection.

SchemaTypes

Common SchemaTypes include:

text
String
Number
Date
Buffer
Boolean
ObjectId
Array
Decimal128
Map
Mixed
UUID in supported versions
BigInt in supported versions

Check the current Mongoose documentation for the exact SchemaType support in the version you are running. Choose types that match MongoDB semantics and the values your queries, indexes, and APIs actually need.

Casting

Mongoose may convert an input value to the schema type before sending the operation to MongoDB. For example:

js
Task.findOne({
  _id: '66d0...',
});

can be cast to an ObjectId if the value is valid.

This convenience is useful, but it can also hide a missing API boundary check. Validate an externally supplied ID at the HTTP boundary. A malformed ID should produce a controlled 400 or 422 response, rather than relying on a CastError to reach global error middleware and become whatever response happens to be generated there.

Validation

Built-in validators run when a document is saved and on selected operations according to the API and options in use. A short example is:

js
const task = new Task({
  title: 'x',
});

await task.save();
// validation error

The application can inspect the validation error, but the server or API should translate it into a stable public error format. Internal Mongoose error shapes are not a good contract for clients.

Custom validator

For rules that belong to one field, a custom validator can be appropriate:

js
title: {
  type: String,
  validate: {
    validator(value) {
      return !value.includes('\0');
    },
    message: 'Title contains invalid characters.',
  },
}

Keep field validators local and predictable. Do not put database or network I/O into every field validator. Cross-document business validation belongs in a service, repository, or transaction-oriented architecture where its consistency and failure behavior can be made explicit.

Update validators

One of the most common production mistakes is assuming that all update paths behave like save().

Mongoose update operations have different validation semantics from save(). Do not assume:

js
Model.updateOne(...)

runs every document validator and middleware exactly like:

js
document.save()

Use current options such as runValidators where appropriate, and understand that update validators generally apply to updated paths rather than replaying the complete document lifecycle. Query middleware and document middleware are also separate mechanisms.

This difference is a common source of production bugs: one write path enforces an invariant while another silently bypasses it.

Defaults

A schema default is used when a value is undefined according to Mongoose semantics:

js
priority: {
  type: String,
  default: 'normal',
}

Do not expect the default to replace an explicit:

js
null

unless the schema or application logic explicitly defines that behavior. undefined and null carry different meaning here.

Timestamps

Enabling timestamps:

js
{
  timestamps: true
}

creates and maintains:

text
createdAt
updatedAt

These are useful persistence timestamps. Do not manually update createdAt. When the business domain needs dates such as publishedAt, completedAt, or paidAt, define those as separate fields rather than overloading persistence metadata.

Getters/setters

A setter can normalize a value as it enters the document:

js
email: {
  type: String,
  set(value) {
    return value.trim().toLowerCase();
  },
}

Normalization can be useful, but email identity rules are more nuanced than simply lowercasing every address for every policy. More generally, do not hide a high-impact business transformation in an obscure setter. Developers debugging an update should be able to find and understand important changes to their data.

Virtuals

A virtual derives a value without storing it in MongoDB:

js
taskSchema.virtual('isOpen').get(
  function () {
    return !this.completed;
  },
);

Virtuals are useful for presentation and small domain conveniences. They are not persisted fields, so you cannot query or index a virtual as though it existed in MongoDB.

Instance methods

Methods can make document-specific behavior readable:

js
taskSchema.methods.canBeClosed = function () {
  return !this.completed;
};

That is useful when the behavior naturally belongs to a hydrated document. Large amounts of business logic tightly coupled to Mongoose documents can make service testing and a future persistence migration harder. Keep critical domain rules in explicit service or domain modules when the architecture benefits from that separation.

Static methods

Statics can centralize a query used by a model:

js
taskSchema.statics.findOpenForTenant =
  function (tenantId) {
    return this.find({
      tenantId,
      completed: false,
    });
  };

This is a reasonable place for a small, model-specific query. In a larger system, a repository abstraction may still make dependencies, authorization filters, and testing clearer.

Model

Compile the schema into a model:

js
const Task = mongoose.model(
  'Task',
  taskSchema,
);

The model maps to a MongoDB collection and is the main interface for creating documents and issuing queries.

Model compilation is global-ish per Mongoose connection. In hot-reload or serverless development, repeatedly defining the same model can cause recompilation errors. Use a framework-aware model-loading pattern rather than compiling the model blindly on every evaluation.

Documents

A model creates a hydrated Mongoose document:

js
const task = new Task({
  tenantId,
  title: 'Learn Mongoose',
});

await task.save();

The hydrated document includes:

  • getters and setters;
  • change tracking;
  • methods;
  • save;
  • virtuals.

That behavior has memory and CPU cost compared with returning plain BSON-derived objects. The useful question is not whether hydration is good or bad; it is whether the particular code path needs document behavior.

lean()

For a read-only query, lean() avoids hydration:

js
const tasks = await Task
  .find({
    tenantId,
  })
  .lean();

lean() skips Mongoose document hydration and returns plain objects.

The main benefits are:

  • lower memory use;
  • faster reads.

The trade-offs are just as important:

  • no document methods;
  • no save;
  • getter and virtual behavior differs unless specific plugins or options are used;
  • no change tracking.

Use lean() for API list and read paths where hydrated-document behavior is unnecessary. Do not apply it blindly to code that expects methods, virtuals, setters, or a later save() call.

save()

The normal read-modify-save flow looks like this:

js
const task = await Task.findOne({
  _id: taskId,
  tenantId,
});

if (!task) ...

task.completed = true;

await task.save();

Mongoose tracks the changed fields and runs save validation and middleware. The flow is convenient, but the read and write are separate operations. Another request can modify the document between them.

When that matters, use optimistic concurrency or an atomic query update rather than assuming the read-modify-write sequence is automatically safe.

Query updates

For a direct atomic change, use a query update:

js
await Task.updateOne(
  {
    _id: taskId,
    tenantId,
  },
  {
    $set: {
      completed: true,
    },
  },
  {
    runValidators: true,
  },
);

This is more direct and atomic at the query operation level. However, document save middleware does not automatically run for a query update. Mongoose has separate query middleware, and validation behavior also differs.

Choose the operation based on the invariants the write must preserve, not merely on which syntax is shorter.

Middleware

Middleware provides hooks around operations such as:

text
validate
save
find
findOneAndUpdate
deleteOne
aggregate

For example:

js
taskSchema.pre(
  'save',
  function () {
    this.title = this.title.trim();
  },
);

Middleware is powerful because it applies behavior at lifecycle boundaries. It is also easy to overlook when reading the call site.

Avoid middleware that:

  • sends external emails;
  • makes expensive remote calls;
  • changes unrelated collections silently;
  • creates retry-unfriendly side effects.

A developer calling:

js
await task.save();

should not unknowingly charge a payment. External side effects need explicit orchestration, especially when an operation can be retried.

Middleware order

Validation and save-hook ordering matters, and plugins can add their own hooks. When behavior is surprising, inspect all middleware declared by the schema as well as middleware added by plugins.

Do not treat the Mongoose lifecycle as magic. Trace which operation was called, which hook type applies, and the order in which registered hooks execute.

Error middleware

Mongoose supports error-handling middleware patterns, but error translation still belongs at a repository or service boundary. A duplicate key returned by MongoDB is not the same thing as a Mongoose validation error.

Catch the MongoDB duplicate-key error code and map it to a conflict response or domain error. Do not expose the raw database error shape as the public API contract.

Unique is not validator

This declaration:

js
email: {
  type: String,
  unique: true,
}

expresses index intention; it is not a per-document validator and does not provide a race-free precheck. The database unique index is what enforces uniqueness under concurrent writes.

Handle the duplicate-key error from that index. A preflight query can improve a user-facing message, but it cannot replace the database constraint.

Indexes

Declare compound indexes on the schema when the access pattern requires them:

js
taskSchema.index({
  tenantId: 1,
  completed: 1,
  createdAt: -1,
});

For production, treat indexes as deployment-managed infrastructure:

  • define indexes in code or a migration plan;
  • avoid uncontrolled automatic index builds at application startup on a huge production collection.

Mongoose autoIndex behavior should be configured deliberately for production. Use an explicit deployment and index-management process so an application restart does not unexpectedly begin expensive index creation.

Subdocuments

Subdocuments are useful when the child data belongs inside the parent document and should have Mongoose behavior of its own:

js
const itemSchema = new mongoose.Schema(
  {
    sku: String,
    quantity: Number,
  },
  {
    _id: false,
  },
);

const orderSchema = new mongoose.Schema({
  items: [itemSchema],
});

Subdocuments can have Mongoose behavior and middleware. Understand the difference between a nested path and a subdocument before choosing one. In this example, _id: false avoids creating an identifier for each item when the application has no need to address items independently.

Arrays

Mongoose tracks array changes, but its tracking behavior does not change MongoDB's storage and indexing constraints.

Unbounded arrays remain a MongoDB modeling anti-pattern. An ODM convenience layer does not change the BSON document-size limit or the cost of indexing a growing array. If a collection can accumulate an unlimited number of child values, consider a separate collection or another bounded model.

Mixed

Mixed allows an arbitrary shape:

js
mongoose.Schema.Types.Mixed

It can be useful for carefully scoped polymorphic metadata. The trade-off is that Mongoose provides no strong casting or validation for the contents, change tracking has nuances, and the schema becomes opaque.

Do not make every field Mixed simply to avoid making a schema decision. Flexibility at the storage boundary becomes work for every query, validator, and consumer.

Map

For a dynamic key-value structure whose values share a type, use a Map:

js
settings: {
  type: Map,
  of: String,
}

Dynamic field names can be difficult to query and index. Use a map only when its access pattern fits those limitations.

Populate

A reference can connect a document to another model:

js
authorId: {
  type: ObjectId,
  ref: 'User',
}

Then a query can request the related document:

js
const post = await Post
  .findById(id)
  .populate('authorId');

populate() performs additional query or join-like ODM work. It does not embed the user into the post document. Do not normalize every relationship into references just because populate is convenient; MongoDB data-modeling principles still apply.

Populate performance

Populate can become expensive through:

  • many populations;
  • deeply nested populate;
  • large result sets;
  • unnecessarily large fields;
  • repeated query work.

Depending on the access pattern, consider:

  • projection or select;
  • lean;
  • an explicit aggregation $lookup when it is suitable;
  • embedding or denormalization;
  • a batch query.

Measure the resulting queries and response behavior rather than choosing by intuition alone.

Autopopulate caution

Plugins that automatically populate every query hide I/O at the call site. A simple:

js
find()

can unexpectedly become several expensive operations.

Prefer explicit populate on critical paths. That keeps the cost visible when reviewing a query and makes it easier to choose projections, limits, or a different data model.

Discriminators

Discriminators model related polymorphic documents in one collection. Define a common base schema and a discriminator key:

js
const eventSchema = new Schema({
  tenantId: ObjectId,
  occurredAt: Date,
}, {
  discriminatorKey: 'type',
});

const Event = model(
  'Event',
  eventSchema,
);

Then define a subtype:

js
const PaymentEvent = Event.discriminator(
  'payment',
  new Schema({
    amountPaise: Number,
  }),
);

This is a good fit for related variants that share collection-level access patterns. Do not place completely unrelated domains in one discriminator collection merely because they can technically share a base schema.

Transactions

Mongoose transactions use MongoDB sessions. The current connection transaction helper makes the session lifecycle and retry behavior explicit:

js
await mongoose.connection.transaction(
  async (session) => {
    await Order.updateOne(
      {
        _id: orderId,
      },
      {
        $set: {
          status: 'paid',
        },
      },
      {
        session,
      },
    );

    await Payment.create(
      [
        {
          orderId,
          amountPaise,
        },
      ],
      {
        session,
      },
    );
  },
);

Use the current Mongoose transaction helper. Do not casually parallelize unrelated operations inside the same transaction. More importantly, do not call external side effects from a retryable transaction callback. The callback may run again, while an email, payment request, or message publish may not be safely repeatable.

Session propagation

Every query or document write that must participate in the transaction needs the correct session. Mongoose can associate sessions with documents in some flows, but repository and service code should make propagation explicit.

Missing the session on even one write places that write outside the transaction. When reviewing a transaction, inspect every database operation, not just the first one.

Optimistic concurrency

Mongoose supports optimistic concurrency through a schema option:

js
const schema = new Schema(
  {...},
  {
    optimisticConcurrency: true,
  },
);

Mongoose uses the version key to detect a stale save(). The default version key is commonly:

text
__v

You can configure the version key. This protects read-modify-save workflows by detecting that another writer changed the document after it was read.

Keep this separate from a domain-level version field or an API ETag. They may represent related concurrency ideas, but they are not automatically the same contract.

VersionKey

Do not disable the version key simply because:

js
versionKey: false

makes the output look cleaner. First understand how versioning and concurrency use it.

If the API should not expose __v, transform the serialized output rather than necessarily removing the concurrency metadata from the document.

findOneAndUpdate versus save

findOneAndUpdate is atomic at the query-operation level and is useful for direct updates. It is not interchangeable with save().

The differences include:

  • save middleware differs;
  • document validation differs;
  • return semantics differ.

Use the operation whose lifecycle and concurrency semantics match the invariant you need to preserve.

Serialization

Customize toJSON or toObject carefully:

js
taskSchema.set(
  'toJSON',
  {
    transform(doc, ret) {
      ret.id = ret._id.toString();
      delete ret._id;
      delete ret.__v;
      return ret;
    },
  },
);

Serialization is an API boundary, not a reason to alter the database representation. Do not serialize secrets or internal fields, and make sure a transform does not accidentally mutate or imply changes to the stored document.

Query casting security

Mongoose can cast query values, but casting does not make an arbitrary client-supplied filter safe.

Never pass the raw request query into a model:

js
Task.find(req.query);

Instead, validate the request and construct an allowlisted filter. The Mongoose ODM does not remove NoSQL injection risks or prevent a client from submitting an unexpectedly expensive query.

Strict query behavior

Mongoose strict-query options and defaults evolve between major versions. Do not rely on an undocumented or default strictness setting as your security boundary.

Validate input at the HTTP boundary and construct a filter containing only known fields and supported operators.

Plugins

Plugins can add capabilities such as:

  • soft delete;
  • pagination;
  • auditing;
  • autopopulate.

They can also:

  • add middleware;
  • mutate queries;
  • add dependencies.

Review a plugin's source, maintenance status, and operational impact before installing it. Do not add a plugin for five lines of code without understanding what else it changes in the model lifecycle and query behavior.

Repository with Mongoose

A repository can keep Mongoose details out of the service layer:

js
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();
    },
  };
}

The repository owns persistence details such as tenant scoping and whether a read is lean. The service still should not import Express; HTTP concerns belong at the route or controller boundary.

Testing

Test the behavior that tends to differ between Mongoose and the underlying database:

  • casting;
  • validation;
  • unique-index integration;
  • middleware;
  • lean paths;
  • populate;
  • transactions;
  • optimistic concurrency.

Use a real MongoDB instance for repository integration tests. Mocking model methods alone does not prove index enforcement, query semantics, session behavior, or database-level concurrency behavior.

Failure clinic

unique: true treated as validator

This creates a race bug: the database unique index, not a Mongoose precheck, is the enforcement mechanism.

lean() then calling .save()

A lean result is a plain object and has no document API.

every query autopopulates

Hidden population creates a performance surprise and obscures the I/O cost of a query.

query update assumed save middleware

The expected invariant is missed because query middleware and document save middleware are different paths.

raw req.query into Model.find

This permits injection or query abuse through an untrusted filter.

autoIndex builds huge indexes at every production startup

This is an operational problem that can make startup contend with production traffic.

business side effects in pre-save middleware

Retries and hidden coupling make the operation difficult to reason about safely.

Mongoose schema used as only database validation when multiple writers exist

Other writers can bypass application-layer Mongoose validation. Add database-level protection where the invariant requires it.

Exercises

  1. Build a Task schema with validation and timestamps.
  2. Compare hydrated-document memory and behavior with a lean result.
  3. Write save() and updateOne() variants and compare their hooks.
  4. Add a compound unique index and handle the duplicate-key error.
  5. Add subdocument items.
  6. Use populate, then remodel the same access pattern with embedding and compare the results.
  7. Create a discriminator collection.
  8. Enable optimistic concurrency and reproduce a stale save.
  9. Execute a transaction with a session.
  10. Disable automatic indexes in the production plan and create an index-deployment script.
  11. Audit a plugin before installing it.

Mastery checklist

You should be able to explain:

  • the difference between an ODM and the native driver;
  • schemas, models, and documents;
  • casting and validation;
  • save() versus query updates;
  • middleware;
  • indexes and unique;
  • lean();
  • populate();
  • subdocuments;
  • discriminators;
  • transactions;
  • optimistic concurrency;
  • plugin and autoIndex risks.

Official references

Reader page: /mongodb/lesson/145/mongoose-deep-dive-schemas-models-validation-middleware-lean-populate-transactions-discriminators-and-concurrency