Define document structure with Mongoose schemas, create models, and understand data validation.
A schema defines the shape of documents within a MongoDB collection. While MongoDB itself does not require any structure, Mongoose schemas let you enforce structure at the application level. This is one of the biggest reasons developers choose Mongoose over the native driver — you get the flexibility of NoSQL with the predictability of defined data structures.
A schema maps directly to a MongoDB collection. When you define a User schema, every document created through that schema will conform to the rules you set. Think of a schema as a blueprint or a contract: it specifies what fields a document can have, what types those fields must be, and what constraints they must satisfy.
Schema Types — Mongoose supports the following types for schema fields:
String — text valuesNumber — integers and floating-point numbersBoolean — true or falseDate — JavaScript Date objectsArray — arrays of values (can be typed: [String] for an array of strings)ObjectId — MongoDB ObjectId, used for referencing other documentsMixed — any type (opt-out of type checking for this field)Buffer — binary dataMap — key-value pairs with typed valuesSchema.Types.Decimal128 — high-precision decimal numbersEach field in your schema can have options that add validation and behavior:
required: true — the field must have a value (cannot be null/undefined)unique: true — no two documents can have the same value for this fielddefault: value — automatically assign this value if none is providedmin: 0 / max: 150 — minimum/maximum for Numbersminlength: 2 / maxlength: 100 — minimum/maximum length for Stringsenum: ['user', 'admin'] — value must be one of these optionstrim: true — remove whitespace from beginning/end of Stringslowercase: true / uppercase: true — convert String case automaticallymatch: /regex/ — String must match this regular expressionvalidate: { validator: fn, message: 'Error message' } — custom validationconst mongoose = require('mongoose');
// Define the User schema with types, validation, and defaults
const userSchema = new mongoose.Schema({
// Required string with trimming and length limits
name: {
type: String,
required: [true, 'Name is required'], // Custom error message
trim: true,
minlength: [2, 'Name must be at least 2 characters'],
maxlength: [50, 'Name cannot exceed 50 characters']
},
// Required unique email with lowercase conversion
email: {
type: String,
required: [true, 'Email is required'],
unique: true, // No two users can share an email
lowercase: true, // Automatically convert to lowercase
trim: true,
match: [/^\S+@\S+\.\S+$/, 'Please enter a valid email']
},
// Optional number with min/max range
age: {
type: Number,
min: [0, 'Age cannot be negative'],
max: [150, 'Age must be realistic']
},
// Enum field with a default value
role: {
type: String,
enum: {
values: ['user', 'admin', 'moderator'],
message: '{VALUE} is not a valid role'
},
default: 'user'
},
// Array of strings
hobbies: [String],
// Nested object (sub-document)
address: {
street: String,
city: String,
country: { type: String, default: 'Unknown' }
},
// Auto-set creation date
createdAt: {
type: Date,
default: Date.now // Function reference, called when doc is created
}
});
// Create the model from the schema
const User = mongoose.model('User', userSchema);A model is a constructor function that Mongoose compiles from a schema. It is the primary interface you use to interact with a MongoDB collection. While a schema defines the shape and rules for documents, a model provides the actual methods for creating, reading, updating, and deleting documents in the database.
You create a model by passing a name and a schema to mongoose.model():
const User = mongoose.model('User', userSchema);The first argument ('User') is the singular name of the collection. Mongoose automatically creates a collection with the pluralized, lowercased version of this name. So 'User' maps to a users collection, 'Post' maps to posts, 'BlogComment' maps to blogcomments. This convention is automatic, but you can override it in the schema options if needed.
Once you have a model, you can create new documents from it. A model instance (a single document) is called a document. Creating a document is like calling new on a constructor:
const alice = new User({ name: 'Alice', email: 'alice@example.com' });
await alice.save(); // Saves to the database
// Or use the shorthand:
const bob = await User.create({ name: 'Bob', email: 'bob@example.com' });The model also provides static methods for querying the entire collection: User.find(), User.findById(), User.findOne(), User.findByIdAndUpdate(), User.findByIdAndDelete(), User.countDocuments(), and many more. These are the workhorses of your data layer.
Important convention: model names are always singular and PascalCase (User, BlogPost, OrderItem). The corresponding collection names are automatically plural and lowercase (users, blogposts, orderitems). Keep your models in separate files: models/User.js, models/Post.js, etc.
Validation is one of the most powerful features Mongoose adds on top of MongoDB. It ensures that bad data never reaches your database. Mongoose runs validators automatically when you call save() or create(). If any validator fails, the operation throws a ValidationError with details about what went wrong.
Built-in Validators:
required — Works on all types. The field must have a value. Empty strings, null, and undefined all fail.min / max — Numbers only. Ensures the value falls within a range: age: { type: Number, min: 0, max: 150 }.minlength / maxlength — Strings only. Controls string length: name: { type: String, minlength: 2, maxlength: 100 }.enum — Strings only. Value must be in a whitelist: role: { type: String, enum: ['user', 'admin'] }.match — Strings only. Value must match a regular expression: email: { type: String, match: /^\S+@\S+\.\S+$/ }.Custom Validators:
For rules that built-in validators cannot express, you write custom validator functions:
phone: {
type: String,
validate: {
validator: function(value) {
return /^\d{10}$/.test(value); // Must be exactly 10 digits
},
message: 'Phone number must be 10 digits'
}
}Handling Validation Errors:
When validation fails, Mongoose throws an error with an errors object. Each key is the field name, and each value contains the error message and other details. You typically catch this in a try/catch block and return a 400 Bad Request response to the client with the validation details. Always validate on the server side even if you also validate on the frontend — client-side validation can be bypassed, but server-side validation backed by Mongoose cannot.
Mongoose schemas support several powerful options and customization features that go beyond basic field definitions.
Timestamps — The most commonly used schema option. Adding { timestamps: true } as the second argument to new mongoose.Schema() automatically creates and maintains two fields: createdAt (set when the document is first saved) and updatedAt (updated every time the document is modified). This saves you from manually managing these fields:
const postSchema = new mongoose.Schema({ title: String, body: String }, { timestamps: true });
// Documents now automatically have createdAt and updatedAtInstance Methods — Custom methods available on individual document instances. You define them on schema.methods. They have access to the document via this:
userSchema.methods.getPublicProfile = function() {
return { name: this.name, email: this.email };
// Excludes sensitive fields like password
};
const user = await User.findById(id);
const profile = user.getPublicProfile();Static Methods — Custom methods on the model itself (not on individual documents). Useful for custom queries:
userSchema.statics.findByEmail = function(email) {
return this.findOne({ email: email.toLowerCase() });
};
const user = await User.findByEmail('ALICE@example.com');Virtual Properties — Computed fields that are not stored in the database. They are calculated on-the-fly from existing fields:
userSchema.virtual('fullName').get(function() {
return `${this.firstName} ${this.lastName}`;
});
// user.fullName returns 'Alice Johnson' but is not in the DBMiddleware (Hooks) — Run code before or after specific operations. The pre('save') hook is commonly used for password hashing:
userSchema.pre('save', async function(next) {
if (this.isModified('password')) {
this.password = await bcrypt.hash(this.password, 10);
}
next();
});These features make Mongoose much more than a simple database wrapper — it is a full-featured data modeling tool.
What does the 'required: true' option do in a Mongoose schema?