What you'll learn
Quick Answer
Mongoose defines a schema in your Node code and enforces it before writing. You get validation, defaults, types and relationships. Without it, a misspelled field is written happily and your queries silently miss those documents.
The problem with no schema
MongoDB does not require documents in a collection to have the same fields. That flexibility is genuinely useful early on and becomes a liability as a project grows.
db.students.insertOne({ name: "Asha", marks: 91 });
db.students.insertOne({ name: "Ravi", mark: 68 }); // typo
Both succeed. There is now a marks field and a mark field, and find({ marks: { $gt: 60 } }) silently omits Ravi. No error, no warning — just a report that is quietly wrong.
Mongoose puts the schema in your application code and validates before writing, so that insert fails instead.
Schema and model
const mongoose = require("mongoose");
const studentSchema = new mongoose.Schema({
name: { type: String, required: true, trim: true },
email: { type: String, required: true, unique: true, lowercase: true },
marks: { type: Number, min: 0, max: 100, default: 0 },
stream: { type: String, enum: ["Science", "Commerce", "Arts"] },
active: { type: Boolean, default: true },
}, { timestamps: true });
const Student = mongoose.model("Student", studentSchema);
The schema describes shape and rules; the model is what you call methods on. timestamps: true adds createdAt and updatedAt automatically, which you will otherwise add by hand later.
Mongoose pluralises and lowercases the model name for the collection, so Student reads and writes students. That surprises people looking for a Student collection in the database and finding nothing.
The operations you need
// create
const s = await Student.create({ name: "Asha", email: "a@x.com", marks: 91 });
// read
const all = await Student.find({ stream: "Science" });
const one = await Student.findById(id);
const first = await Student.findOne({ email: "a@x.com" });
// update -- note the option
const updated = await Student.findByIdAndUpdate(
id, { marks: 95 }, { new: true, runValidators: true });
// delete
await Student.findByIdAndDelete(id);
Two options on update that catch everyone. Without new: true, Mongoose returns the document as it was before the update, so your API responds with stale values. And validation does not run on updates unless you pass runValidators: true — so a schema that rejects marks above 100 on create will happily accept 5000 on update.
Everything returns a promise, so use await inside try/catch, or the rejection becomes an unhandled promise and the request hangs.
References and populate
For related data you either embed it or reference it. References look like this:
const courseSchema = new mongoose.Schema({ title: String, fee: Number });
const studentSchema = new mongoose.Schema({
name: String,
course: { type: mongoose.Schema.Types.ObjectId, ref: "Course" },
});
The student stores only the course's id. To fetch the full course alongside:
const students = await Student.find().populate("course", "title fee");
populate performs a second query and substitutes the referenced documents. The second argument limits which fields come back, which is worth using — populating whole documents you do not need is a common source of slow endpoints.
Embed when the data belongs to the parent and is read with it, such as an address. Reference when it is shared or grows without bound. This is the same trade-off as normalization, with the same consequence: embedding duplicates data, so updating it means updating every copy.
Practical notes
Handle validation errors properly. Mongoose throws a ValidationError containing per-field messages. Return 400 with those messages rather than a generic 500 — the client can then show which field was wrong.
try {
await Student.create(req.body);
} catch (err) {
if (err.name === "ValidationError") {
return res.status(400).json({ errors: err.errors });
}
next(err);
}
unique is not validation. It creates a database index, and a duplicate produces a MongoDB error with code 11000 rather than a Mongoose validation error. Handle that code separately or duplicate signups return a confusing 500.
Connect once at startup, not per request. Mongoose maintains a connection pool; reconnecting per request exhausts it quickly.
Add indexes for fields you query often. Schema flexibility does not remove the need — see database indexing explained.
