Mongoose adds schemas, casting, validation, middleware, change tracking, and model-based queries to the MongoDB Node.js driver.
A schema is an application contract, not a replacement for database indexes, authorization, or validation performed by other writers.
Production code should reuse connections, project only needed fields, inspect query plans, and use transactions only for invariants spanning multiple operations.
Mongoose is an Object Data Modeling (ODM) library for MongoDB and Node.js. It provides a schema-based solution to model your application data, with built-in type casting, validation, query building, and business logic hooks. Think of it as an ORM for MongoDB.
Use Mongoose when document lifecycle behavior and a consistent application model justify the abstraction. Use the MongoDB driver directly when a thin data-access layer, driver-first features, or minimum abstraction is more important. Both approaches still require MongoDB data modeling and index design.
Install Mongoose with npm install mongoose, load the connection string from environment or secret configuration, and connect once during application startup. mongoose.connect uses the default connection and an internal driver pool; opening a connection for each request adds latency and exhausts sockets.
Modern Mongoose does not need the former useNewUrlParser or useUnifiedTopology options. Set bounded server-selection and operation timeouts that match the service contract. Listen for connection errors after startup and close the pool during graceful process shutdown.
const mongoose = require("mongoose")
async function connectDatabase() {
mongoose.connection.on("error", error => {
console.error("MongoDB connection error", error)
})
await mongoose.connect(process.env.MONGODB_URI, {
serverSelectionTimeoutMS: 5000,
maxPoolSize: 20
})
console.log("MongoDB connected")
}
async function shutdown(signal) {
console.log(`Closing MongoDB after ${signal}`)
await mongoose.disconnect()
process.exit(0)
}
process.on("SIGTERM", () => shutdown("SIGTERM"))
process.on("SIGINT", () => shutdown("SIGINT"))
connectDatabase().catch(error => {
console.error("Initial connection failed", error)
process.exit(1)
})
MongoDB connected, followed by Closing MongoDB after SIGTERM or SIGINT during graceful shutdown.
The process creates one pooled default connection, applies a bounded selection timeout, and disconnects on normal termination signals.
Define the document shape, required values, defaults, enums, and application-level validators in a Schema. Casting happens before validation. Validation runs before save middleware by default, but update operations need runValidators: true when their changed values should be checked.
unique is shorthand for creating a unique index; it is not a validator and duplicate-key failures come from MongoDB. Build and monitor indexes deliberately in deployment. Mongoose validation also cannot repair documents written directly by another client, so use MongoDB schema validation when every writer must obey a database-level rule.
const { Schema, model } = require("mongoose")
const userSchema = new Schema({
name: {
type: String,
required: [true, "Name is required"],
minlength: 2,
maxlength: 100,
trim: true
},
email: {
type: String,
required: true,
unique: true,
lowercase: true,
match: [/^\S+@\S+\.\S+$/, "Invalid email format"]
},
age: {
type: Number,
min: [0, "Age cannot be negative"],
max: [150, "Age seems too high"]
},
role: {
type: String,
enum: ["admin", "editor", "user"],
default: "user"
},
active: {
type: Boolean,
default: true
},
hobbies: [String],
address: {
street: String,
city: String,
zip: String
},
createdAt: {
type: Date,
default: Date.now
}
}, {
timestamps: true // auto-adds createdAt and updatedAt
})
// Create the model (maps to "users" collection)
const User = model("User", userSchema)
module.exports = User
Valid documents can be saved as users; missing required fields or invalid enum, range, and pattern values produce validation errors.
The schema combines casting and validation rules, while the model supplies the collection-facing API. The unique email option requires a database index and duplicate-key error handling.
Build filters from allow-listed request fields and pass values as data, not executable query fragments. Apply projection, sort, a stable tie-breaker, and a bounded limit before awaiting a list query. Offset pagination is simple for small result sets; keyset pagination scales better for deep, changing collections.
findOneAndUpdate is a database update operation and returns the pre-update document unless returnDocument: "after" or the supported Mongoose equivalent is requested. Include runValidators: true where update validation is required. Check the returned value because no matching document is a normal outcome, not an exception.
// CREATE
const alice = new User({ name: "Alice", email: "alice@example.com", age: 29 })
await alice.save()
// Or use create() shorthand
const bob = await User.create({ name: "Bob", email: "bob@example.com", age: 34 })
// READ
const allUsers = await User.find()
const activeUsers = await User.find({ active: true }).sort({ name: 1 }).limit(10)
const alice = await User.findOne({ email: "alice@example.com" })
const userById = await User.findById("64a1f2c3e4b0a1b2c3d4e5f6")
// Projection - select specific fields
const names = await User.find({}, "name email -_id")
// UPDATE
await User.findByIdAndUpdate(
"64a1f2c3e4b0a1b2c3d4e5f6",
{ $set: { age: 30 } },
{ new: true, runValidators: true } // new: true returns updated doc
)
await User.updateMany({ role: "user" }, { $set: { active: false } })
// DELETE
await User.findByIdAndDelete("64a1f2c3e4b0a1b2c3d4e5f6")
await User.deleteMany({ active: false })
Each awaited operation returns a document, document list, update result, deletion result, or null according to the selected model method.
The examples show model-level CRUD. Real endpoints also need allow-listed filters, projection, bounded pagination, authorization, and distinct handling for a missing match.
Virtuals compute values that are not stored. Document middleware attaches behavior to lifecycle operations such as save, while query middleware observes query operations. Middleware registration order matters: add hooks before compiling the model, and do not assume save middleware runs for findOneAndUpdate.
populate resolves referenced documents with additional query work. Select only required fields and watch for unbounded fan-out. Embedding is often better for small data read with its parent; references fit independently changing entities. Aggregation $lookup may be clearer for server-side pipelines.
// VIRTUAL - computed property not stored in DB
userSchema.virtual("fullName").get(function() {
return `${this.firstName} ${this.lastName}`
})
// PRE HOOK - runs before save
userSchema.pre("save", async function(next) {
if (this.isModified("password")) {
this.password = await bcrypt.hash(this.password, 12)
}
next()
})
// POST HOOK - runs after save
userSchema.post("save", function(doc) {
console.log("User saved:", doc._id)
})
// POPULATE - resolve references to other collections
const orderSchema = new Schema({
userId: { type: Schema.Types.ObjectId, ref: "User" },
total: Number
})
const Order = model("Order", orderSchema)
// Populate userId with the full User document
const orders = await Order.find().populate("userId", "name email")
// orders[0].userId is now the full User object, not just an ObjectId
A populated order exposes the selected user name and email while the stored order still contains a user ObjectId reference.
The virtual is computed, save middleware runs around document persistence, and populate replaces selected ObjectId references with projected User documents.
// Mongoose Schema Types:
// String, Number, Date, Buffer, Boolean, Mixed, ObjectId, Array, Decimal128, Map
// Common field options:
// type - data type
// required - true or [true, "error message"]
// default - default value or function
// unique - creates a unique index
// index - creates an index
// sparse - sparse index
// min/max - for Number and Date
// minlength/maxlength - for String
// enum - array of allowed values
// match - regex pattern for String
// trim - remove whitespace
// lowercase - convert to lowercase
// uppercase - convert to uppercase
// immutable - cannot be changed after creation
// validate - custom validator function
// Example with custom validator
score: {
type: Number,
validate: {
validator: (v) => v >= 0 && v <= 100,
message: "Score must be between 0 and 100"
}
}
Values outside the custom score range fail Mongoose validation before a document save.
Schema types control casting, while field options apply validation, indexing, normalization, defaults, or immutability. Each option should represent an actual domain rule.
A normal query hydrates Mongoose Documents with getters, virtuals, change tracking, validation, and save methods. Add lean to read-only queries that only need plain JavaScript objects. Lean usually reduces allocation, but it intentionally skips document behavior; do not call save on the result or assume getters, defaults, and virtuals were applied.
Use a transaction when one business invariant spans multiple documents or collections and cannot be expressed with a single atomic document update. Pass the session to every participating operation and use connection.transaction or withTransaction so commit, abort, and eligible retries are handled correctly. Avoid parallel operations such as Promise.all inside one transaction.
Transactions add latency and operational requirements. Model data so one document contains values that must change atomically when practical. For counters and conditional transitions, use atomic update operators with a filter that represents the expected current state.
It is the class-like interface used to create and query documents for one schema.
No. It validates writes through Mongoose, not documents inserted by other clients.
Opening a new connection per request wastes sockets and adds latency.
Explore 500+ free tutorials across 20+ languages and frameworks.