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.

Frequently Asked Questions

Do I need Mongoose to use MongoDB with Node? No, the official driver works directly. Mongoose adds schemas, validation, defaults and population, which most applications end up wanting. For simple scripts the plain driver is lighter.
Why does my update return the old document? findByIdAndUpdate returns the pre-update document by default. Pass { new: true } to get the updated one back.
Why is validation not running on my update? Mongoose skips validators on update operations unless you pass runValidators: true. This surprises people whose create path validates correctly while updates do not.
Should I embed or reference related documents? Embed data owned by the parent and read alongside it. Reference data that is shared between documents or grows without limit. Embedding duplicates data, so updates must touch every copy.
Why does the unique option not give a validation error? Because unique builds a database index rather than a Mongoose validator. A duplicate raises a MongoDB error with code 11000, which you should catch and translate into a useful response.