Using Mongoose Virtuals to Keep Derived Data Out of MongoDB
Learn how to define virtual getters and setters in Mongoose so computed values like full names appear in API responses without extra storage or index overhead.
05 Oct 2026, 18:25 UTC

The problem: storing derived data wastes space and couples logic
When you build a REST API with Mongoose, you often need to return values that aren’t raw fields—think a user’s full name built from firstName and lastName, or a cart total calculated from line items. If you persist those values in MongoDB you introduce storage overhead and risk inconsistency whenever the source fields change. You could compute the value in every controller, but that scatters the logic and makes it easy to forget an update.
Thesis: Mongoose virtuals give you a clean, centralized way to expose derived values
Virtuals are schema‑level getters (and optional setters) that Mongoose treats like a regular property when you work with a document, but they are never written to the database. By defining the calculation once in the schema you keep the logic in one place, avoid extra storage, and still have the value available when you convert a document to a plain object for JSON responses.
Defining a virtual getter and setter
Start with a basic user schema. The virtual fullName concatenates firstName and lastName. A setter lets you do the reverse—assigning a full name splits it back into the two fields.
const mongoose = require('mongoose');
const { Schema } = mongoose;
const userSchema = new Schema({
firstName: { type: String, required: true },
lastName: { type: String, required: true },
// other fields …
});
// Virtual getter
userSchema.virtual('fullName').get(function () {
return `${this.firstName} ${this.lastName}`.trim();
});
// Virtual setter (optional)
userSchema.virtual('fullName').set(function (v) {
const parts = v.trim().split(/\s+/);
this.firstName = parts[0] || '';
this.lastName = parts.length > 1 ? parts.slice(1).join(' ') : '';
});
// Ensure virtuals appear when converting to JSON or plain object
userSchema.set('toObject', { virtuals: true });
userSchema.set('toJSON', { virtuals: true });
module.exports = mongoose.model('User', userSchema);
The getter uses this to access the document’s real fields. The setter parses a string and updates the underlying fields. Because we enabled virtuals: true on toObject and toJSON, calling doc.toObject() or JSON.stringify(doc) will include fullName in the output.
Worked example: using the virtual in a service
Below is a minimal script you could run in a Node.js REPL or a test file. It assumes you have a running MongoDB instance accessible at the default URI.
// install: npm install mongoose
const mongoose = require('mongoose');
const User = require('./userModel'); // the schema from above
async function demo() {
await mongoose.connect('mongodb://127.0.0.1:27017/virtual-demo', {
useNewUrlParser: true,
useUnifiedTopology: true,
});
// Create a document
const user = new User({ firstName: 'Ada', lastName: 'Lovelace' });
// Virtual works like a regular property
console.log('fullName via getter:', user.fullName); // Ada Lovelace
// Using the setter updates the source fields
user.fullName = 'Alan Turing';
console.log('after setter:', user.firstName, user.lastName); // Alan Turing
// Persist to DB (virtual is NOT stored)
await user.save();
// Retrieve and serialize for an API response
const fresh = await User.findById(user._id);
const plain = fresh.toObject(); // includes virtual because of schema option
console.log('serialized doc:', plain);
// { _id: ..., firstName: 'Alan', lastName: 'Turing', fullName: 'Alan Turing', __v: 0 }
await mongoose.disconnect();
}
Demo().catch(console.error);
Notice that after save() the database only contains firstName and lastName. The fullName field appears only when we explicitly convert the document to a plain object or JSON.
Trade‑off: virtuals cannot be queried or indexed
Because virtuals are purely a Mongoose layer construct, MongoDB has no knowledge of them. You cannot use collection.where('fullName', 'Ada Lovelace') or create an index on the virtual path. If you need to filter or sort by the derived value you have two options:
- Compute the value in your application logic before querying (e.g., build a regex for
firstNameandlastNamecombinations). - Store the derived value as a regular field and keep it in sync via middleware or the virtual setter.
Choosing to store the value adds write overhead and duplication, but gives you queryability. Virtuals are best when the value is purely for presentation or aggregation that happens after the document is fetched.
Actionable checklist
- Define virtuals in the schema with
schema.virtual('name').get(fn)and optionally a setter. - Enable
toObjectandtoJSONwith{ virtuals: true }so the value appears in API responses. - Remember that virtuals are not stored, indexed, or queryable directly in MongoDB.
- Verify behavior by:
- Creating a document and reading the virtual property.
- Using the setter to confirm it updates the source fields.
- Calling
doc.toObject()orJSON.stringify(doc)and checking the output includes the virtual. - Attempting a query on the virtual path and confirming it returns no matches (or an error, depending on the driver).
- If you later need queryability, consider adding a persisted field and using the virtual setter to keep it in sync.
By keeping derived logic inside the schema, you avoid scattering concatenation or calculation code across controllers and services while keeping your MongoDB storage lean. Use virtuals for presentation‑only values, and fall back to stored fields when you need to search or sort on the derived data.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.