The official driver gives you direct, flexible access to MongoDB, but it doesn't know what your data is supposed to look like. Every route has to check that a price is a number, set default values and remember which fields exist. Mongoose is an ODM (Object Document Mapper) for Node.js that adds this structure on top of the driver.
With Mongoose you define a schema for each kind of document: its fields, their types, which are required, default values and validation rules. From the schema you create a model, a class you use to create, query, update and delete documents. Mongoose casts values to the right types, validates documents before saving, and adds conveniences such as automatic createdAt and updatedAt timestamps.
Mongoose also offers virtuals (computed properties), methods, middleware that runs before or after saving, and populate, which replaces stored ids with the referenced documents. In this lesson you'll use each of these, learn about lean queries for speed, and see when the plain driver is the better choice.
Schemas and models
import mongoose from 'mongoose';
await mongoose.connect(process.env.MONGODB_URI);
const productSchema = new mongoose.Schema({
name: { type: String, required: true, trim: true, maxlength: 100 },
price: { type: Number, required: true, min: 0 },
category: { type: String, enum: ['kitchen', 'home', 'office'] },
tags: [String],
stock: { type: Number, default: 0 },
}, { timestamps: true });
const Product = mongoose.model('Product', productSchema);- Each field has a type (
String,Number,Date,Boolean,ObjectId, arrays and nested objects) and optional rules. timestamps: trueadds and maintainscreatedAtandupdatedAt.- The model name
'Product'maps to the collectionproducts(lowercase and plural). - Mongoose casts values: the string
'24'becomes the number 24 for a Number field, and invalid values fail validation.
Queries and validation
const lamp = await Product.create({ name: 'Lamp', price: 24, category: 'home' });
const cheap = await Product.find({ price: { $lt: 10 } }).sort({ price: 1 }).lean();
const one = await Product.findById(id);
await Product.updateOne({ _id: id }, { $inc: { stock: 5 } });
await Product.deleteOne({ _id: id });- Query filters and update operators are the same as MongoDB's.
createandsavevalidate the document; problems throw aValidationErrorwhoseerrorsobject lists each field.- Updates validate only when you pass
{ runValidators: true }. .lean()returns plain JavaScript objects instead of full Mongoose documents, which is much faster for read-only results such as API responses.
Virtuals, methods, middleware and populate
- Virtuals are computed properties that aren't stored:
schema.virtual('priceWithVat').get(function () { return this.price * 1.2; }). - Methods add behaviour to documents (
schema.methods.isInStock = function () { ... }); statics add functions to the model (Product.findCheap()). - Middleware (hooks) run around operations:
schema.pre('save', function () { ... })can normalise data or hash a password before saving. - References store an id with
{ type: Schema.Types.ObjectId, ref: 'User' }, and.populate('author')fetches and inserts the referenced document.
Unknown fields, cast errors and update return values
Mongoose's schema doesn't just describe your data; it changes what gets saved. By default schemas are strict: fields that aren't in the schema are silently dropped when you create or save a document.
const p = await Product.create({ name: 'Lamp', price: 24, colour: 'red' });
// stored as { _id, name: 'Lamp', price: 24, __v: 0 }: colour is goneThat protects the collection from junk, but it also explains a common puzzle: a new field "doesn't save" because you forgot to add it to the schema. The __v field is Mongoose's version key, used to detect conflicting changes to arrays; leave it alone.
Query filters behave differently. In Mongoose 7 and later the strictQuery option is off by default, so a filter on an unknown field, such as { colour: 'red' }, is sent to MongoDB as written. If you turn strictQuery on, unknown fields are removed from filters instead, which can turn a filter into {} and match everything, so test carefully before enabling it.
Mongoose casts query values to the schema's types before sending them. When that's impossible, the query rejects with a CastError. Product.findById('abc') throws a CastError for path _id, because 'abc' can't become an ObjectId, so handle it as a "not found" or "bad request" rather than a server error.
Finally, findByIdAndUpdate and findOneAndUpdate return the document as it was before the update by default. Pass { new: true } to get the updated version, and add runValidators: true if the update should be validated:
const updated = await Product.findByIdAndUpdate(id, { price: 31 }, { new: true, runValidators: true });MongoDB Advanced Syntax
Mongoose essentials
const userSchema = new Schema({
email: { type: String, required: true, unique: true, lowercase: true },
role: { type: String, enum: ['user', 'admin'], default: 'user' },
}, { timestamps: true });
userSchema.virtual('isAdmin').get(function () { return this.role === 'admin'; });
userSchema.pre('save', async function () { /* runs before save */ });
const User = model('User', userSchema);
const postSchema = new Schema({ title: String, author: { type: Schema.Types.ObjectId, ref: 'User' } });
await Post.find().populate('author', 'email').lean();
await User.updateOne({ _id }, { $set: { role: 'admin' } }, { runValidators: true });MongoDB Advanced Summary
- Mongoose adds schemas, models, validation, defaults and casting on top of the MongoDB driver.
- Schema fields set types and rules such as required, min, max, enum and default; timestamps adds dates.
- Models provide create, find, findById, updateOne and deleteOne; save and create validate.
- Virtuals, methods, statics and pre/post middleware keep logic with the model.
- ref and populate replace stored ids with documents; lean returns fast plain objects.
MongoDB Advanced Examples
Each example shows the code, the output it produces and what each part does.
A schema with validation and defaults
import mongoose from 'mongoose';
await mongoose.connect(process.env.MONGODB_URI ?? 'mongodb://localhost:27017/mongoose_demo');
await mongoose.connection.db.dropDatabase();
const Product = mongoose.model('Product', new mongoose.Schema({
name: { type: String, required: true, trim: true },
price: { type: Number, required: true, min: 0 },
category: { type: String, enum: ['kitchen', 'home', 'office'], default: 'home' },
stock: { type: Number, default: 0 },
}, { timestamps: true }));
const lamp = await Product.create({ name: ' Desk lamp ', price: '24.5' });
console.log(lamp.name, lamp.price, typeof lamp.price, lamp.category, lamp.stock, lamp.createdAt instanceof Date);
try {
await Product.create({ name: 'Rug', price: -5, category: 'garden' });
} catch (err) {
console.log(err.name);
for (const [field, e] of Object.entries(err.errors).sort()) console.log(` ${field}: ${e.kind}`);
}
console.log(Product.collection.collectionName, await Product.countDocuments());
await mongoose.disconnect();Desk lamp 24.5 number home 0 true
ValidationError
category: enum
price: min
products 1Line-by-line explanation
- Lines 6 to 11The schema defines types, rules, defaults and automatic timestamps.
- Line 13trim cleans the name, the string price is cast to a number, and defaults fill in category and stock.
- Line 17Invalid documents are rejected before anything is sent to the database.
- Line 20err.errors lists each failing field with the rule that failed.
- Line 23The Product model stores documents in the products collection.
Virtuals, methods and middleware
import mongoose from 'mongoose';
await mongoose.connect(process.env.MONGODB_URI ?? 'mongodb://localhost:27017/mongoose_demo');
await mongoose.connection.db.dropDatabase();
const userSchema = new mongoose.Schema({
email: { type: String, required: true, lowercase: true, trim: true },
first: String,
last: String,
logins: { type: Number, default: 0 },
});
userSchema.virtual('fullName').get(function () { return `${this.first} ${this.last}`; });
userSchema.methods.recordLogin = function () { this.logins += 1; return this.save(); };
userSchema.statics.findByEmail = function (email) { return this.findOne({ email: email.toLowerCase() }); };
userSchema.pre('save', function () {
if (this.isModified('email')) console.log(`saving email ${this.email}`);
});
const User = mongoose.model('User', userSchema);
await User.create({ email: ' Ada@Example.COM ', first: 'Ada', last: 'Lovelace' });
const ada = await User.findByEmail('ADA@example.com');
console.log(ada.fullName);
await ada.recordLogin();
await ada.recordLogin();
console.log((await User.findById(ada._id).lean()).logins);
console.log('fullName' in (await User.findById(ada._id).lean()));
await mongoose.disconnect();saving email ada@example.com
Ada Lovelace
2
falseLine-by-line explanation
- Line 7lowercase and trim normalise every email before it's saved.
- Line 12A virtual computes fullName from stored fields without storing it.
- Line 13An instance method adds behaviour to each user document.
- Line 14A static adds a reusable query to the model.
- Lines 15 to 17Middleware runs before every save; here it only logs when the email changed, so later saves are silent.
- Line 26Lean results are plain objects, so virtuals aren't included.
References and populate
import mongoose from 'mongoose';
const { Schema, model } = mongoose;
await mongoose.connect(process.env.MONGODB_URI ?? 'mongodb://localhost:27017/mongoose_demo');
await mongoose.connection.db.dropDatabase();
const Author = model('Author', new Schema({ name: String, country: String }));
const Book = model('Book', new Schema({
title: String,
year: Number,
author: { type: Schema.Types.ObjectId, ref: 'Author' },
}));
const le = await Author.create({ name: 'Ursula K. Le Guin', country: 'US' });
await Book.create([
{ title: 'A Wizard of Earthsea', year: 1968, author: le._id },
{ title: 'The Dispossessed', year: 1974, author: le._id },
]);
const raw = await Book.findOne({ year: 1968 }).lean();
console.log(raw.author instanceof mongoose.Types.ObjectId);
const books = await Book.find().sort({ year: 1 }).populate('author', 'name -_id').lean();
for (const b of books) console.log(`${b.title} (${b.year}) by ${b.author.name}`);
await mongoose.disconnect();true
A Wizard of Earthsea (1968) by Ursula K. Le Guin
The Dispossessed (1974) by Ursula K. Le GuinLine-by-line explanation
- Line 11ref tells Mongoose which model the stored id refers to.
- Lines 20 to 21Without populate, the author field is just an ObjectId.
- Line 23populate fetches the referenced authors in one extra query and inserts the chosen fields.
- Line 23lean keeps the result as plain, fast JavaScript objects.
Common MongoDB Advanced Mistakes
Expecting updates to validate
await Product.updateOne({ _id }, { $set: { price: -10 } }); // saved, no validationawait Product.updateOne({ _id }, { $set: { price: -10 } }, { runValidators: true }); // ValidationErrorFull documents for read-only API responses
res.json(await Product.find({ category })); // builds heavy Mongoose documents for nothingres.json(await Product.find({ category }).limit(50).lean());Arrow functions in methods and middleware
userSchema.methods.fullName = () => `${this.first} ${this.last}`; // this isn't the documentuserSchema.methods.fullName = function () { return `${this.first} ${this.last}`; };MongoDB Advanced in Real Projects
Mongoose in Node applications
Many Express and NestJS projects define a Mongoose model per collection in a models/ folder, with validation, indexes, timestamps and password-hashing middleware in the schema. Read-heavy endpoints use .lean() and projections, admin screens use populate to show related records, and teams combine Mongoose schemas with request validation libraries such as Zod so bad input is rejected before it reaches the model.
MongoDB Advanced FAQ
What is the difference between Mongoose and the MongoDB Node.js driver?
The official mongodb driver sends your queries straight to the database and returns plain objects. Mongoose is built on top of it and adds schemas, casting, validation, defaults, middleware, virtuals and populate. Mongoose suits apps with many models and business rules; the driver is lighter and faster for simple or high-volume work.
Why does Mongoose pluralise my collection name?
Mongoose derives the collection name from the model name by lowercasing and pluralising it, so a Product model uses products. To choose the name yourself, pass it as the third argument, mongoose.model('Product', schema, 'catalogue'), or set the collection option on the schema. This matters when you connect Mongoose to an existing database.
What is the __v field in Mongoose documents?
__v is the version key Mongoose adds to every document it creates. It's incremented when array fields are changed through save(), so Mongoose can detect that two copies of a document edited the same array at once. You can rename it or turn it off with the versionKey schema option, but most apps simply hide it in API responses.
Why is my Mongoose update not running validation?
Validators run automatically on create() and save(), but not on update queries such as updateOne and findByIdAndUpdate. Pass { runValidators: true } in the options to enable them. Even then, update validators only check the fields in the update and some rules behave differently, so complex rules are often easier with findById, change, then save().
When should I use lean() in Mongoose?
Use .lean() for read-only queries, such as API responses and reports, where you don't need to call save() or use document methods. It returns plain JavaScript objects, skipping the work of building full Mongoose documents, so queries are faster and use less memory. Leave it off when you're going to modify and save the documents.
MongoDB Advanced Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
Complete the schema option that adds createdAt and updatedAt automatically.
new mongoose.Schema({ name: String }, { : true });
What is Mongoose?
Which collection does mongoose.model('Order', schema) use by default?
What does .lean() do?
What does populate('author') do?
MongoDB Advanced Quiz
5 questions to check what you learned.
Which option makes a field mandatory?
- mandatory: true
- required: true
- notNull: true
- must: true
What is a virtual in Mongoose?
- A hidden collection
- A fake document
- A computed property that isn't stored in the database
- An index
When does pre('save') middleware run?
- After every query
- When the app starts
- Only on delete
- Before a document is saved
Do updateOne calls run schema validators by default?
- Yes
- Only for required fields
- No, pass { runValidators: true }
- Only with lean
Why must schema methods use function rather than arrow functions?
- Arrow functions don't get the document as this
- Arrow functions are slower
- Mongoose doesn't support ES6
- To allow async