Using Mongoose: Schemas and Models
MongoDB documents are schema-flexible by design, but most applications still want predictable shapes, validated fields, and a convenient API for working with data in JavaScript. Mongoose is the most widely used Object Document Mapper (ODM) for MongoDB in Node.js: it sits on top of the official MongoDB driver and lets you define a Schema describing what a document should look like, then compile that schema into a Model you use to create, query, and update documents. This lesson covers how schemas and models actually work, how to define one well, and the mistakes that trip up almost every new Mongoose developer.
Overview / How it works
A raw MongoDB collection has no enforced structure — one document in db.users could have an age field and the next could be missing it entirely. That flexibility is powerful, but in application code it usually causes bugs: typos in field names silently create new fields instead of erroring, and there’s no single place that describes what a "user" document is supposed to contain. Mongoose solves this at the application layer (not the database layer — MongoDB itself still has no schema unless you add $jsonSchema validation) by introducing two related concepts:
Schema — a JavaScript object that describes the shape of a document: field names, their types, validation rules (required, min/max, enum, custom validators), default values, and options like automatic timestamps. A schema is just a blueprint; it does not talk to the database.
Model — a compiled wrapper around a schema, bound to a specific collection, that gives you the actual CRUD API (find, create, updateOne, and so on). Calling mongoose.model("User", userSchema) compiles the schema into the User model, which by convention maps to the users collection (Mongoose lowercases the name and pluralizes it).
Under the hood, every document you get back from a Mongoose model is an instance of that model — a JavaScript object with methods (.save(), .toObject(), custom instance methods you define) layered on top of the plain BSON data the driver returns. When you call .save() or Model.create(), Mongoose first runs the document through the schema’s validators in your application, before ever sending a write to MongoDB. If validation fails, no network call happens at all — you get a ValidationError back immediately. This is a key difference from the raw driver: the driver will happily insert whatever object you give it, while Mongoose enforces your schema’s rules first.
Mongoose also uses the schema to build native MongoDB indexes. Fields marked unique: true or explicitly given schema.index(...) definitions are synced to real indexes on the collection the first time a model connects (controlled by the autoIndex option) — so a Mongoose "unique" constraint is enforced by an actual unique index in MongoDB, not just in JavaScript.
Syntax
const schema = new mongoose.Schema(
{
fieldName: {
type: String, // or Number, Date, Boolean, ObjectId, Array, etc.
required: true, // rejects the write if missing
default: "someValue", // used when the field is omitted
unique: true, // builds a unique index on this field
trim: true, // (String only) strips whitespace
enum: ["a", "b"], // restricts allowed values
min: 0, // (Number/Date only) minimum value
max: 100 // (Number/Date only) maximum value
}
},
{
timestamps: true // adds createdAt / updatedAt automatically
}
);
const Model = mongoose.model("CollectionSingularName", schema);
| Piece | Purpose |
|---|---|
type |
The BSON/JS type Mongoose casts the value to (String, Number, Date, Boolean, mongoose.Schema.Types.ObjectId, Array, nested object, or another schema) |
required |
Validation rule — write fails if the field is missing or null |
default |
Value applied automatically when the field is not supplied |
unique |
Not a validator — it tells Mongoose to build a unique index on this field |
{ timestamps: true } |
Schema-level option that adds and auto-maintains createdAt/updatedAt Date fields |
Examples
Example 1: Defining a schema and model
import mongoose from "mongoose";
const { Schema, model } = mongoose;
const userSchema = new Schema(
{
name: { type: String, required: true, trim: true },
email: { type: String, required: true, unique: true, lowercase: true },
age: { type: Number, min: 0, max: 120 },
role: { type: String, enum: ["admin", "editor", "viewer"], default: "viewer" }
},
{ timestamps: true }
);
const User = model("User", userSchema);
export default User;
This defines a User model backed by the users collection. name and email are required, email gets a unique index and is lowercased before saving, role defaults to "viewer" and can only be one of three values, and timestamps adds createdAt/updatedAt without you writing any extra code.
Example 2: Creating and validating a document
const user = await User.create({
name: " Priya Shah ",
email: "PRIYA@Example.com",
age: 29
});
console.log(user);
{
_id: new ObjectId("66b1f2a9c1d4e5f6a7b8c9d0"),
name: 'Priya Shah',
email: 'priya@example.com',
age: 29,
role: 'viewer',
createdAt: 2026-08-03T10:15:00.000Z,
updatedAt: 2026-08-03T10:15:00.000Z,
__v: 0
}
Mongoose trimmed the whitespace from name, lowercased email per the schema, applied the role default, and stamped createdAt/updatedAt automatically — all before the document ever reached MongoDB. The __v field is Mongoose’s internal version key, used to detect conflicting concurrent updates to arrays.
Example 3: Querying and updating through the model
const admins = await User.find({ role: "admin" })
.sort({ createdAt: -1 })
.limit(10);
const updated = await User.findOneAndUpdate(
{ email: "priya@example.com" },
{ $set: { role: "editor" } },
{ new: true, runValidators: true }
);
console.log(updated.role);
editor
find() returns an array of full User instances (not plain objects) that you can call .save() on later. findOneAndUpdate is given { new: true } so it returns the updated document instead of the pre-update one, and { runValidators: true } so the schema’s enum/required/min/max rules are re-checked on this update, not just on the original insert.
How it works step by step
When you call User.create({...}), Mongoose performs several steps entirely in your Node.js process before touching the network: it builds a document instance from your plain object, applies defaults for any missing fields, casts values to the types declared in the schema (a numeric string like "29" becomes the number 29), and runs every field’s validators. Only if all validators pass does Mongoose serialize the document to BSON and send an insertOne (or insertMany) command to MongoDB through the underlying driver — the same wire protocol call you’d get from raw driver code. On the way back, MongoDB’s response (including the generated _id) is merged back into the document instance you already hold. For updates issued directly through query methods like updateOne/findOneAndUpdate, Mongoose does not run full document validation by default, because it isn’t loading a full document to validate — it’s sending an update expression straight to MongoDB, which is why runValidators: true exists as an explicit opt-in.
Common Mistakes
Mistake 1: Redefining a model and getting OverwriteModelError. If a module that calls mongoose.model("User", userSchema) gets imported twice (common with hot-reloading or circular imports), Mongoose throws because a model named User is already compiled.
// BAD: throws "OverwriteModelError: Cannot overwrite `User` model once compiled"
const User = mongoose.model("User", userSchema);
// GOOD: reuse the existing compiled model if it already exists
const User = mongoose.models.User || mongoose.model("User", userSchema);
Mistake 2: Assuming validators run on every write. Validators only run automatically on save(), create(), and insertMany(). Query-based updates skip them unless you ask for it, so an invalid role can silently slip through updateOne.
// BAD: enum validator on `role` is silently skipped
await User.updateOne({ _id: userId }, { $set: { role: "superadmin" } });
// GOOD: opt in to validation on the update itself
await User.updateOne(
{ _id: userId },
{ $set: { role: "superadmin" } },
{ runValidators: true }
);
Best Practices
- Put each model’s schema and model creation in its own file, and guard against re-compilation with
mongoose.models.X || mongoose.model(...). - Always add
runValidators: true(and oftencontext: "query"for validators that reference other fields) onupdateOne/updateMany/findOneAndUpdatecalls that must enforce schema rules. - Use
{ timestamps: true }instead of hand-rollingcreatedAt/updatedAtfields. - Prefer
{ new: true }onfindOneAndUpdatewhen your code needs the post-update document, since the default returns the pre-update one. - Set
autoIndex: falsein production and build indexes explicitly during deploys — letting Mongoose build indexes on every app boot is slow and risky on large collections. - Keep embedded sub-schemas for data that’s small and bounded (an address on a user); reference by
ObjectIdand populate for data that’s large, shared, or unbounded (a product catalog).
Practice Exercises
- Define a
Productschema withname(required string),price(required number, minimum 0), andcategory(string, one of a fixed set of allowed values viaenum). Create one document and confirm a negativepriceis rejected with aValidationError. - Add
{ timestamps: true }to an existing schema, insert a document, and inspect the returned object to confirmcreatedAtandupdatedAtare present and equal on creation. - Write an
updateOnecall that changes a field guarded by anenumto an invalid value, first withoutrunValidators(it succeeds, which is the bug) and then withrunValidators: true(it should throw aValidationError).
Summary
- A Mongoose schema is a blueprint describing field types, validation, and defaults; it does not talk to MongoDB by itself.
- A model is a schema compiled into a class bound to a collection, giving you
find,create,updateOne, and similar methods. - Validation and casting happen in Node.js on
save()/create()before any write is sent to MongoDB; query-based updates skip validation unless you passrunValidators: true. unique: truein a schema results in a real unique index in MongoDB, not just an in-app check.- Guard model compilation with
mongoose.models.X || mongoose.model(...)to avoidOverwriteModelErroron re-import.
