Mongoose

Add structure to MongoDB in Node.js with Mongoose: define schemas with types, defaults and validation, create models, run queries, add virtuals, methods and middleware, link documents with populate, and know when to use lean queries or the plain driver.

Lesson 20 of 43Advanced19 min read

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

JavaScript
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: true adds and maintains createdAt and updatedAt.
  • The model name 'Product' maps to the collection products (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

JavaScript
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.
  • create and save validate the document; problems throw a ValidationError whose errors object 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.

JavaScript
const p = await Product.create({ name: 'Lamp', price: 24, colour: 'red' });
// stored as { _id, name: 'Lamp', price: 24, __v: 0 }: colour is gone

That 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:

JavaScript
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.

Example 1

A schema with validation and defaults

Node.js
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();
Output
Desk lamp 24.5 number home 0 true
ValidationError
  category: enum
  price: min
products 1
Line-by-line explanation
  1. Lines 6 to 11The schema defines types, rules, defaults and automatic timestamps.
  2. Line 13trim cleans the name, the string price is cast to a number, and defaults fill in category and stock.
  3. Line 17Invalid documents are rejected before anything is sent to the database.
  4. Line 20err.errors lists each failing field with the rule that failed.
  5. Line 23The Product model stores documents in the products collection.
Example 2

Virtuals, methods and middleware

Node.js
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();
Output
saving email ada@example.com
Ada Lovelace
2
false
Line-by-line explanation
  1. Line 7lowercase and trim normalise every email before it's saved.
  2. Line 12A virtual computes fullName from stored fields without storing it.
  3. Line 13An instance method adds behaviour to each user document.
  4. Line 14A static adds a reusable query to the model.
  5. Lines 15 to 17Middleware runs before every save; here it only logs when the email changed, so later saves are silent.
  6. Line 26Lean results are plain objects, so virtuals aren't included.
Example 3

References and populate

Node.js
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();
Output
true
A Wizard of Earthsea (1968) by Ursula K. Le Guin
The Dispossessed (1974) by Ursula K. Le Guin
Line-by-line explanation
  1. Line 11ref tells Mongoose which model the stored id refers to.
  2. Lines 20 to 21Without populate, the author field is just an ObjectId.
  3. Line 23populate fetches the referenced authors in one extra query and inserts the chosen fields.
  4. Line 23lean keeps the result as plain, fast JavaScript objects.

Common MongoDB Advanced Mistakes

Expecting updates to validate

Don't
await Product.updateOne({ _id }, { $set: { price: -10 } });                         // saved, no validation
Do
await Product.updateOne({ _id }, { $set: { price: -10 } }, { runValidators: true }); // ValidationError

Full documents for read-only API responses

Don't
res.json(await Product.find({ category }));          // builds heavy Mongoose documents for nothing
Do
res.json(await Product.find({ category }).limit(50).lean());

Arrow functions in methods and middleware

Don't
userSchema.methods.fullName = () => `${this.first} ${this.last}`;   // this isn't the document
Do
userSchema.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.

Fill in the blankseasy

Complete the schema option that adds createdAt and updatedAt automatically.

new mongoose.Schema({ name: String }, { : true });
Multiple choiceeasy

What is Mongoose?

Multiple choicemedium

Which collection does mongoose.model('Order', schema) use by default?

Multiple choicemedium

What does .lean() do?

Multiple choicehard

What does populate('author') do?

MongoDB Advanced Quiz

5 questions to check what you learned.

  1. Which option makes a field mandatory?

    • mandatory: true
    • required: true
    • notNull: true
    • must: true
  2. What is a virtual in Mongoose?

    • A hidden collection
    • A fake document
    • A computed property that isn't stored in the database
    • An index
  3. When does pre('save') middleware run?

    • After every query
    • When the app starts
    • Only on delete
    • Before a document is saved
  4. Do updateOne calls run schema validators by default?

    • Yes
    • Only for required fields
    • No, pass { runValidators: true }
    • Only with lean
  5. 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