Mongoose lean(): A Performance Decision for Read-Only Query Paths
Mongoose hydrates every query result into a Document by default. Append `.lean()` to return plain JavaScript objects for serialization-only endpoints, and learn where that shortcut breaks.
30 Dec 2025, 09:38 UTC

The hidden cost of default Mongoose queries
When a Node.js endpoint queries MongoDB through Mongoose, the database is rarely the bottleneck. By default, Model.find() and similar methods hydrate every raw result into a full Mongoose Document instance. That hydration adds change tracking, validation state, getters, setters, virtuals, and prototype methods to every row. If your route handler only serializes the data to JSON and sends it to a client, you are paying CPU and memory costs for features the request never uses.
The useful takeaway: append .lean() to read-only queries to receive plain JavaScript objects instead of hydrated documents, cutting per-request overhead.
What .lean() actually does
Hydration is the process of converting raw database values into rich application objects. When Mongoose executes a query, the MongoDB driver returns raw BSON documents. By default, Mongoose wraps each one in a Document instance so you can call .save(), trigger middleware, or use virtuals.
Appending .lean() tells Mongoose to skip that wrapping step. The driver still returns the raw values, but Mongoose passes them through as plain JavaScript objects—POJOs, or plain old JavaScript objects—that contain only the fields you selected. Because these objects lack the Document prototype, they do not carry Mongoose's internal state. There is no change-tracking delta and no validation cache. For read-heavy APIs that immediately call res.json(), this removes a meaningful amount of allocation and CPU work.
A worked read-only example
Consider a user listing endpoint that returns active users. Without .lean(), every user becomes a full document. With .lean(), the same query returns bare objects:
// In your route handler or service function
const users = await User.find({ active: true })
.select('name email')
.lean();
// users is now an array of plain objects:
// [{ _id: ObjectId('...'), name: 'Alice', email: '[contact removed]' }, ...]
The returned objects contain _id, name, and email—and nothing else. There is no save() method, no toObject(), and no virtual properties unless you explicitly request them.
Enabling virtuals in lean mode
Virtuals are computed properties defined on the schema but not stored in MongoDB. They are omitted by default in lean results. To include them, pass an options object:
const users = await User.find({ active: true })
.select('name email')
.lean({ virtuals: true });
Schema getters are also bypassed. If a field has a getter that formats a date or masks a value, the lean result contains the raw stored value, not the transformed one.
Operational limits and common mistakes
Lean results are not documents
The most common failure is treating a lean object like a document. The following will throw a TypeError because the method does not exist on a plain object:
const user = await User.findOne({ email: '[contact removed]' }).lean();
user.name = 'Alicia';
await user.save(); // TypeError: user.save is not a function
The same applies to .remove() and other instance methods. If you need to mutate and persist a record, either re-fetch it as a document or use findOneAndUpdate() directly.
Document middleware does not run
Query middleware and document middleware attached to the schema do not execute on lean results. If you rely on a post hook to attach computed data or audit information, that logic is skipped.
Populate returns plain objects
When you chain .populate() with .lean(), the populated paths are also plain objects. Verify that your serialization layer handles ObjectId and Date instances correctly, because document methods and custom transforms are absent.
No schema validation on write-back
Because lean results are detached from the schema, you cannot call validate() on them. Any client-side checks must happen in your application code before you pass data to an update operation.
Verifying the behavior in your codebase
Before adopting .lean() broadly, confirm the semantics in your specific Mongoose version. The following checks are practical and do not require external benchmarks:
- Type check: Run
result instanceof mongoose.Documenton a lean query result. It should returnfalse. - Method check: Write a unit test that calls
.save()on a lean result and assert that it throws aTypeError. - Version check: Inspect
package.jsonfor the installed Mongoose version and confirm thatlean({ virtuals: true })is supported by the library's TypeScript definitions or documentation. - Micro-benchmark: Compare
await Model.find().lean()againstawait Model.find()on a large dataset, measuring elapsed time withperformance.now().
When not to use .lean()
Avoid .lean() on any code path that later calls document instance methods, relies on virtuals without the options flag, or depends on document middleware. If the same query result feeds both a JSON response and an update operation, fetch it as a document and use .toObject() for serialization instead.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.