Virtuals
Learn how to define virtual fields in Mongoose — computed properties that are not stored in the database.
Virtuals
A virtual is a field that exists on a Mongoose document but is not stored in MongoDB. It is computed on the fly — usually derived from other fields on the same document.
Common examples — a fullName computed from firstName and lastName, an age computed from a dateOfBirth, or a passStatus computed from a score.
Why Use Virtuals?
You could calculate these values every time you need them in your application code. But virtuals let you define the calculation once, on the schema, and access it like a regular field — anywhere the document is used.
// Without virtual — repeated everywhere
console.log(`${student.firstName} ${student.lastName}`);
console.log(`${student.firstName} ${student.lastName}`); // again, in another file
// With virtual — defined once, used everywhere
console.log(student.fullName);Defining a Basic Virtual
Use schema.virtual(name).get(function):
const studentSchema = new mongoose.Schema({
firstName: String,
lastName: String
});
studentSchema.virtual('fullName').get(function () {
return `${this.firstName} ${this.lastName}`;
});
const Student = mongoose.model('Student', studentSchema);const student = await Student.create({
firstName: "Ali",
lastName: "Hassan"
});
console.log(student.fullName); // "Ali Hassan"this inside the getter refers to the document — so you can access any of its fields.
Virtuals Are Not Stored
This is the most important thing to understand about virtuals — they exist only on the JavaScript object, never in MongoDB.
const student = await Student.findOne({ firstName: "Ali" });
console.log(student.fullName); // "Ali Hassan" — works in JS
// But in the database, the document looks like:
// { _id: ..., firstName: "Ali", lastName: "Hassan" }
// There is NO "fullName" field storedYou also cannot query by a virtual field directly:
// This does NOT work — fullName does not exist in MongoDB
await Student.find({ fullName: "Ali Hassan" }) // returns nothingIf you need to query by a computed value, you must store it as a real field — possibly using a pre('save') hook to keep it updated, as we covered in the Middleware file.
School System Example — passStatus
Let's add a virtual to our Student schema that computes whether a student passed based on their average score:
const studentSchema = new mongoose.Schema({
name: String,
grade: String,
averageScore: Number
});
studentSchema.virtual('passStatus').get(function () {
return this.averageScore >= 50 ? "Pass" : "Fail";
});
const Student = mongoose.model('Student', studentSchema);const student = await Student.create({
name: "Ali Hassan",
grade: "10th",
averageScore: 42
});
console.log(student.passStatus); // "Fail"Virtual with Nested Field Access
Virtuals can use any field on the document, including nested ones:
const studentSchema = new mongoose.Schema({
name: String,
address: {
city: String,
country: String
}
});
studentSchema.virtual('location').get(function () {
return `${this.address.city}, ${this.address.country}`;
});const student = await Student.create({
name: "Ali Hassan",
address: { city: "Lahore", country: "Pakistan" }
});
console.log(student.location); // "Lahore, Pakistan"Virtual with Calculations
const studentSchema = new mongoose.Schema({
name: String,
grades: [
{ subject: String, score: Number }
]
});
studentSchema.virtual('averageScore').get(function () {
if (this.grades.length === 0) return 0;
const total = this.grades.reduce((sum, g) => sum + g.score, 0);
return Math.round((total / this.grades.length) * 10) / 10;
});
studentSchema.virtual('highestSubject').get(function () {
if (this.grades.length === 0) return null;
const top = this.grades.reduce((max, g) => g.score > max.score ? g : max);
return top.subject;
});const student = await Student.create({
name: "Ali Hassan",
grades: [
{ subject: "Math", score: 88 },
{ subject: "Physics", score: 92 },
{ subject: "English", score: 79 }
]
});
console.log(student.averageScore); // 86.3
console.log(student.highestSubject); // "Physics"This virtual averageScore is calculated every time you access it — always using the current grades array. Compare this to the pre('save') approach from the Middleware file, which stores averageScore as a real field calculated once at save time. Use a virtual when the calculation is cheap and you always want the freshest value. Use a stored field with middleware when you need to query by the value or the calculation is expensive.
Setter Virtuals
Virtuals can also have a setter — a function that runs when you assign a value to the virtual. This is useful for splitting a single input into multiple fields.
const studentSchema = new mongoose.Schema({
firstName: String,
lastName: String
});
studentSchema.virtual('fullName')
.get(function () {
return `${this.firstName} ${this.lastName}`;
})
.set(function (value) {
const parts = value.split(' ');
this.firstName = parts[0];
this.lastName = parts.slice(1).join(' ');
});const student = new Student();
student.fullName = "Ali Hassan";
console.log(student.firstName); // "Ali"
console.log(student.lastName); // "Hassan"Setting fullName automatically splits and assigns firstName and lastName — even though fullName itself is never stored.
Including Virtuals in JSON Output
By default, virtuals do not appear when you convert a document to JSON — like when sending it in an API response. You need to explicitly enable this with schema options.
const studentSchema = new mongoose.Schema(
{
firstName: String,
lastName: String,
averageScore: Number
},
{
toJSON: { virtuals: true }, // include virtuals in JSON output
toObject: { virtuals: true } // include virtuals when converting to plain object
}
);
studentSchema.virtual('fullName').get(function () {
return `${this.firstName} ${this.lastName}`;
});
studentSchema.virtual('passStatus').get(function () {
return this.averageScore >= 50 ? "Pass" : "Fail";
});const student = await Student.create({
firstName: "Ali",
lastName: "Hassan",
averageScore: 86
});
// Without toJSON: { virtuals: true } — fullName and passStatus would be missing
console.log(JSON.stringify(student));
// {
// "_id": "...",
// "firstName": "Ali",
// "lastName": "Hassan",
// "averageScore": 86,
// "fullName": "Ali Hassan",
// "passStatus": "Pass"
// }In an Express API response
app.get('/students/:id', async (req, res) => {
const student = await Student.findById(req.params.id);
res.json(student); // virtuals included automatically thanks to toJSON option
});If you forget toJSON: { virtuals: true }, your virtuals work fine inside your Node.js code (student.fullName works), but they silently disappear when the document is sent as JSON in an API response. This is a very common source of confusion — "the virtual works in my code but not in the API response".
Virtual Populate vs Virtual Fields
Do not confuse these two different uses of virtual():
// 1. Virtual FIELD — a computed value
studentSchema.virtual('fullName').get(function () {
return `${this.firstName} ${this.lastName}`;
});
// 2. Virtual POPULATE — a computed relationship (covered in Population file)
courseSchema.virtual('enrollments', {
ref: "Enrollment",
localField: "_id",
foreignField: "courseId"
});Both use schema.virtual(), but they serve different purposes — one computes a value from existing fields, the other defines a relationship to another collection.
Complete School System Example
const mongoose = require('mongoose');
const studentSchema = new mongoose.Schema(
{
firstName: { type: String, required: true },
lastName: { type: String, required: true },
grade: { type: String, enum: ["9th", "10th", "11th", "12th"] },
address: {
city: String,
country: String
},
grades: [
{ subject: String, score: Number }
]
},
{
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true }
}
);
// Full name from first and last name
studentSchema.virtual('fullName')
.get(function () {
return `${this.firstName} ${this.lastName}`;
})
.set(function (value) {
const parts = value.split(' ');
this.firstName = parts[0];
this.lastName = parts.slice(1).join(' ') || '';
});
// Average score computed from grades array
studentSchema.virtual('averageScore').get(function () {
if (this.grades.length === 0) return 0;
const total = this.grades.reduce((sum, g) => sum + g.score, 0);
return Math.round((total / this.grades.length) * 10) / 10;
});
// Pass/Fail status based on average score
studentSchema.virtual('passStatus').get(function () {
if (this.grades.length === 0) return "No Grades";
return this.averageScore >= 50 ? "Pass" : "Fail";
});
// Location string from address
studentSchema.virtual('location').get(function () {
if (!this.address) return "Unknown";
return `${this.address.city}, ${this.address.country}`;
});
module.exports = mongoose.model('Student', studentSchema);const student = await Student.create({
firstName: "Ali",
lastName: "Hassan",
grade: "10th",
address: { city: "Lahore", country: "Pakistan" },
grades: [
{ subject: "Math", score: 88 },
{ subject: "Physics", score: 92 },
{ subject: "English", score: 35 }
]
});
console.log(student.fullName); // "Ali Hassan"
console.log(student.averageScore); // 71.7
console.log(student.passStatus); // "Pass"
console.log(student.location); // "Lahore, Pakistan"
// All four appear in API responses too, thanks to toJSON: { virtuals: true }
res.json(student);Quick Reference
| Concept | Syntax |
|---|---|
| Define a getter virtual | schema.virtual('name').get(function () { return ...; }) |
| Define a setter virtual | .set(function (value) { ...; }) |
| Include in JSON | { toJSON: { virtuals: true } } |
| Include in toObject | { toObject: { virtuals: true } } |
| Cannot query by virtual | Use a real, stored field instead |
this inside virtual | Refers to the document |
Use virtuals for values that are cheap to compute and always derived from data already on the document — fullName, passStatus, formatted dates, display labels. If a value is expensive to compute, needs to be queried, or depends on data outside the document, store it as a real field instead — using middleware to keep it updated, as covered in the Middleware file.