Mongoose Schemas & Models
The schema is where Mongoose brings discipline to MongoDB. A well-designed schema declares types, validation, defaults, and relationships so that bad data simply can't enter your collection — the database becomes self-defending. This page covers field types and options, built-in and custom validation, defaults, indexes, virtuals, the middleware hooks that power features like password hashing, and how to model relationships.
Field types and options
JS
const productSchema = new Schema({
name: { type: String, required: true, trim: true, maxlength: 200 },
slug: { type: String, required: true, unique: true, index: true },
price: { type: Number, required: true, min: 0 },
tags: [{ type: String }], // array of strings
category: { type: String, enum: ['book', 'toy', 'food'] },
inStock: { type: Boolean, default: true },
meta: { type: Schema.Types.Mixed }, // anything (no casting)
owner: { type: Schema.Types.ObjectId, ref: 'User' }, // reference
}, { timestamps: true })Type | Use |
|---|---|
| Primitives |
| Reference to another document |
| Array of that type |
Nested | Embedded sub-object |
| Anything — no schema enforcement |
| Exact decimals (money) |
Use `Decimal128`, not `Number`, for money
JavaScript numbers are IEEE-754 floats — `0.1 + 0.2 !== 0.3` — so storing currency as `Number` accumulates rounding errors in totals and tax. For money, use `Schema.Types.Decimal128` (or store integer *cents*). This is the same float trap discussed for [numbers in JS](/nodejs/numbers); it matters most where financial accuracy is non-negotiable.
Validation — built-in and custom
JS
const userSchema = new Schema({
email: {
type: String,
required: [true, 'Email is required'], // custom message
unique: true,
lowercase: true,
match: [/^\S+@\S+\.\S+$/, 'Invalid email'], // regex validator
},
age: {
type: Number,
min: [0, 'Age cannot be negative'],
validate: { // custom validator
validator: (v) => Number.isInteger(v),
message: 'Age must be a whole number',
},
},
})`unique` is an INDEX, not a validator — and it has caveats
`unique: true` does **not** validate; it tells MongoDB to build a unique *index*. So a duplicate doesn't produce a Mongoose validation error — it throws a MongoDB **E11000 duplicate key** error at write time, which you must catch separately and map to a [409 Conflict](/nodejs/api-error-responses). Also, the index only exists once built; on an existing collection with dupes it silently fails to build. Don't rely on `unique` for user-facing validation messages — check explicitly or handle E11000.
Schema validation runs on `.save()` — and on updates only if you ask
Validators run automatically on `.save()` and `Model.create()`. As noted for [updates](/nodejs/mongoose), `findOneAndUpdate`/`updateOne` skip them unless you pass `{ runValidators: true }`. Treat schema validation as a *last line of defense* behind request-level [validation](/nodejs/validation-intro) — both layers are worth having.
Defaults
JS
{
status: { type: String, default: 'pending' },
createdAt: { type: Date, default: Date.now }, // pass the FUNCTION, not Date.now()
uuid: { type: String, default: () => crypto.randomUUID() },
}`default: Date.now` — pass the function, not `Date.now()`
Write `default: Date.now` (a reference) so Mongoose calls it *at document-creation time*. Writing `default: Date.now()` calls it **once at schema-definition time**, freezing every document to the server's start-up timestamp. The same applies to any dynamic default: pass a function, not its result.
Virtuals — computed, not stored
JS
userSchema.virtual('fullName').get(function () {
return `${this.firstName} ${this.lastName}`
})
// Include virtuals when serializing to JSON:
userSchema.set('toJSON', { virtuals: true })
const u = await User.findById(id)
u.fullName // "Ada Lovelace" — derived, never written to the DBVirtuals derive values without occupying storage
A virtual is a property computed from other fields (`fullName`, `isAdult`, an `url`). It isn't stored or queryable, but it's handy for presentation. Virtuals are *excluded from JSON by default* — enable `toJSON: { virtuals: true }` if your API responses should include them.
Middleware hooks — the password-hashing pattern
JS
import bcrypt from 'bcrypt'
// Hash the password automatically before every save — if it changed:
userSchema.pre('save', async function (next) {
if (!this.isModified('password')) return next() // avoid re-hashing
this.password = await bcrypt.hash(this.password, 12)
next()
})
// Strip the hash from API output:
userSchema.set('toJSON', {
transform: (doc, ret) => { delete ret.password; return ret },
})Guard `pre('save')` hashing with `isModified` — or you double-hash
A `pre('save')` hook runs on *every* save, including updates to unrelated fields. Without the `if (!this.isModified('password'))` guard, editing a user's name re-hashes the already-hashed password, locking them out. Always check `isModified`. (Hooks fire on `.save()`, not on `findOneAndUpdate` — so password changes should go through `.save()`.) Full treatment in [password hashing](/nodejs/password-hashing).
Modeling relationships
JS
// REFERENCE (normalized) — store an ObjectId, resolve with populate():
const postSchema = new Schema({
title: String,
author: { type: Schema.Types.ObjectId, ref: 'User' }, // 1:1 / N:1
tags: [{ type: Schema.Types.ObjectId, ref: 'Tag' }], // N:M
})
// EMBED (denormalized) — sub-documents live inside the parent:
const orderSchema = new Schema({
items: [{ sku: String, qty: Number, price: Number }], // bounded array
})Reference for unbounded/shared data; embed for bounded/owned data
The [embed-vs-reference decision](/nodejs/mongodb-intro) is expressed in the schema: `ref` + `ObjectId` for references (resolved later via [populate](/nodejs/mongoose-queries)), nested sub-schemas for embedding. Reference when the related data is large, shared, or grows without bound; embed when it's small, owned, and read together. Getting this right up front saves painful migrations later.
Indexes in the schema
JS
userSchema.index({ email: 1 }, { unique: true })
postSchema.index({ author: 1, createdAt: -1 }) // compound, for a common query
productSchema.index({ name: 'text', description: 'text' }) // full-text searchDisable `autoIndex` in production
Mongoose builds declared indexes automatically on startup (`autoIndex`), which is convenient in development but can hammer a large production collection at boot. In production, set `autoIndex: false` and create indexes deliberately as part of your [migration](/nodejs/database-migrations)/deploy process. Either way, index the fields behind your common queries — [unindexed queries scan everything](/nodejs/mongodb-intro).
Next
Query effectively and resolve references: [Mongoose Queries & Population](/nodejs/mongoose-queries).