NodeJSMongoose Schemas & Models

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

String, Number, Boolean, Date

Primitives

Schema.Types.ObjectId + ref

Reference to another document

[Type]

Array of that type

Nested { ... }

Embedded sub-object

Schema.Types.Mixed

Anything — no schema enforcement

Schema.Types.Decimal128

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 DB
Virtuals 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 search
Disable `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).