Mongoose: MongoDB ODM
Mongoose is an Object Data Modeling (ODM) library that puts structure and behavior on top of MongoDB’s schemaless documents. You define a schema describing the shape, types, and rules of your data, and Mongoose gives you a model — a typed gateway for creating, querying, and updating documents, plus validation, lifecycle hooks, and relationships between collections. This page covers defining schemas and models, CRUD queries, validation, middleware, populating references, and managing the connection.
Installing and connecting
Mongoose runs on any maintained Node.js release; Node 20 or 22 LTS is the sensible default. Install it from npm and import it as an ES module — it also ships CommonJS, so const mongoose = require("mongoose") works unchanged.
npm install mongoose
mongoose.connect returns a promise; awaiting it ensures the connection is live before you run queries. Keep the URI in an environment variable rather than in source.
import mongoose from "mongoose";
await mongoose.connect(process.env.MONGODB_URI ?? "mongodb://127.0.0.1:27017/shop");
console.log("Mongo connected:", mongoose.connection.readyState); // 1 = connected
Output:
Mongo connected: 1
Mongoose maintains a single internal connection pool and buffers queries issued before the connection finishes, so you do not have to gate every call behind the connect promise — but awaiting it at startup surfaces bad credentials early.
Defining a schema and model
A schema maps field names to types and options. Calling mongoose.model(name, schema) compiles it into a model whose name (pluralized and lowercased) becomes the collection — here users.
const userSchema = new mongoose.Schema(
{
name: { type: String, required: true, trim: true },
email: { type: String, required: true, unique: true, lowercase: true },
age: { type: Number, min: 0 },
role: { type: String, enum: ["admin", "member"], default: "member" },
tags: [String],
},
{ timestamps: true }, // adds createdAt / updatedAt
);
const User = mongoose.model("User", userSchema);
The timestamps option auto-manages createdAt and updatedAt. Array types like tags: [String] and nested objects are declared inline.
CRUD queries
Models expose a fluent query API. Create with create (or new User(...) then .save()), read with find / findById / findOne, and update or delete with the *One / *Many helpers. Queries are thenable, so await runs them.
// Create
const ada = await User.create({ name: "Ada", email: "[email protected]", age: 36 });
// Read — filter, project, sort, limit
const admins = await User.find({ role: "admin" })
.select("name email")
.sort({ createdAt: -1 })
.limit(10)
.lean(); // plain JS objects, no model overhead
// Update — returns the modified document
const updated = await User.findByIdAndUpdate(
ada._id,
{ $set: { age: 37 }, $push: { tags: "founder" } },
{ new: true, runValidators: true },
);
// Delete
await User.deleteOne({ _id: ada._id });
console.log(updated.age, updated.tags);
Output:
37 [ 'founder' ]
Pass
{ new: true }tofindByIdAndUpdate— otherwise Mongoose returns the document as it was before the update. AddrunValidators: trueso update operations are checked against the schema, since validators only run onsave()by default.
Use .lean() for read-only queries: it skips hydrating full Mongoose documents and returns plain objects, which is markedly faster for large result sets.
Validation
Validation is declared on the schema and runs automatically on save(). Beyond built-ins like required, min, enum, and unique, you can attach a custom validate function. A failed save rejects with a ValidationError whose errors map describes each offending path.
const productSchema = new mongoose.Schema({
sku: {
type: String,
required: true,
validate: {
validator: (v) => /^[A-Z]{3}-\d{4}$/.test(v),
message: (props) => `${props.value} is not a valid SKU`,
},
},
price: { type: Number, required: true, min: [0, "price cannot be negative"] },
});
const Product = mongoose.model("Product", productSchema);
try {
await Product.create({ sku: "bad", price: -5 });
} catch (err) {
console.log(err.name);
console.log(Object.keys(err.errors));
}
Output:
ValidationError
[ 'sku', 'price' ]
Note that unique is not a validator — it is a MongoDB index. It only enforces uniqueness once the index is built, and a duplicate raises a driver error (code 11000), not a ValidationError.
Middleware (hooks)
Middleware are functions that run before (pre) or after (post) a lifecycle event such as save, validate, or a query. They are ideal for cross-cutting logic like hashing passwords, normalizing data, or auditing. Use a regular function so this binds to the document.
userSchema.pre("save", function (next) {
if (this.isModified("email")) this.email = this.email.toLowerCase();
next();
});
userSchema.post("save", function (doc) {
console.log(`Saved user ${doc._id}`);
});
Query middleware (e.g. pre("find", ...)) binds this to the query, letting you inject default filters such as soft-delete exclusion.
Population (references)
To model relationships, store another document’s _id with a ref pointing at its model. populate then replaces those ids with the full documents in a follow-up query — Mongoose’s equivalent of a join.
const orderSchema = new mongoose.Schema({
customer: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
total: Number,
});
const Order = mongoose.model("Order", orderSchema);
const order = await Order.findOne()
.populate("customer", "name email") // only pull these fields
.lean();
console.log(order.customer.name);
Output:
Ada
You can populate multiple paths and nest population. For high-traffic reads where the related data is small and rarely changes, consider embedding the data directly instead of referencing it.
Connection management
In a long-running server, connect once at startup and reuse the pooled connection — never open a new connection per request. Listen for connection events to log problems, and close cleanly on shutdown so in-flight operations drain.
mongoose.connection.on("error", (err) => console.error("Mongo error:", err.message));
mongoose.connection.on("disconnected", () => console.warn("Mongo disconnected"));
process.on("SIGINT", async () => {
await mongoose.connection.close();
process.exit(0);
});
The pool size is tunable via the maxPoolSize connect option (default 100); size it to your concurrency and database limits.
Best practices
- Define every collection with an explicit schema and lean on built-in validators (
required,enum,min) before writing custom ones. - Use
.lean()for read-only queries to skip document hydration and cut memory and CPU. - Pass
{ new: true, runValidators: true }to update helpers so you get the updated doc and schema checks. - Put cross-cutting logic (hashing, normalization, auditing) in
pre/postmiddleware rather than scattering it across controllers. - Choose between
populate(references) and embedding based on read patterns and how often the related data changes. - Connect once at startup, reuse the pool, and close the connection on
SIGINT/SIGTERM. - Keep the connection URI and credentials in environment variables, not in source.