mongoose
The standard MongoDB object modeling layer (ODM) for Node.js, built on top of the official MongoDB driver. You define schemas that add structure to schemaless collections: types, validators, defaults, getters and setters, indexes, middleware hooks, instance methods, and populate for pseudo-joins between collections. Documents you load become full model instances with save(), change tracking, and validation. It supports Node.js with alpha-level Deno support, and Mongoose 9.0 shipped in November 2025.
Still the default ODM for MongoDB plus Node and dependable at that job, with very active maintenance. If your project is TypeScript-heavy or your database choice is not settled, evaluate Prisma before committing.
Use it if
- You are on MongoDB with Node.js and want schema validation, casting, and defaults enforced at the application layer
- You use lifecycle hooks (pre-save, pre-find), virtuals, and populate rather than hand-writing that plumbing over the raw driver
- Your team benefits from the plugin ecosystem (pagination, soft delete, autopopulate) and a well-trodden path with years of Stack Overflow answers
- You want one place where document shape, indexes, and business rules for a collection live together
- You are TypeScript-first; you end up describing every model twice (interface plus schema) and keeping them in sync by hand, unless you add another layer like Typegoose
- You might not stay on MongoDB; Prisma and other multi-database ORMs keep the exit door open, Mongoose does not
- You need raw driver throughput; hydrating full Mongoose documents has real overhead, and teams end up sprinkling .lean() on most read paths anyway
- You dislike the magic: command buffering that hides a dead connection until timeouts fire, automatic collection-name pluralization, and hook-driven side effects all surprise people in production
Setup reality
npm install mongoose, no native builds, works everywhere Node does. The friction is conceptual, not install-time. The default connection is a singleton: models registered via mongoose.model() are bound to it, and if you use createConnection() you must register models on that connection or saves silently target a connection that was never opened (the README calls this out explicitly). Command buffering means your app boots fine with a wrong URI and only fails later with timeouts. Collection names are auto-pluralized (model Ticket writes to tickets). Majors arrive regularly, 9.0 in November 2025, each with a documented list of backwards breaking changes you actually need to read before upgrading.
Patterns
Connect to MongoDBconnect-database
const mongoose = require("mongoose");
await mongoose.connect("mongodb://127.0.0.1:27017/my_database");Commands are buffered until the connection opens, so a bad URI does not fail at startup; it fails later as operation timeouts. Use 127.0.0.1 instead of localhost if local connects hang.
Define a schema and modeldefine-schema-model
const { Schema, model } = require("mongoose");
const postSchema = new Schema({
title: { type: String, required: true },
author: { type: Schema.Types.ObjectId, ref: "User" },
views: { type: Number, default: 0, min: 0 },
createdAt: { type: Date, default: Date.now },
});
const BlogPost = model("BlogPost", postSchema);The model name is singular; Mongoose pluralizes it for the collection, so model("Ticket", ...) reads and writes the tickets collection.
Create and save a documentcreate-document
const post = await BlogPost.create({ title: "Hello" });
// or the two-step form
const draft = new BlogPost({ title: "Draft" });
draft.views = 1;
await draft.save();Validation runs on save/create by default; a failed validator rejects the promise with a ValidationError listing every bad path.
Find with filters, sort, and limitquery-documents
const recent = await BlogPost.find({ views: { $gte: 10 } })
.sort({ createdAt: -1 })
.limit(20)
.select("title views");
const one = await BlogPost.findById(id);Queries are thenables, not promises; awaiting works, but calling .then() twice re-executes the query.
Fast read-only queries with lean()lean-reads
const rows = await BlogPost.find({ published: true }).lean();lean() returns plain JS objects instead of Mongoose documents: much faster and lighter, but no getters, virtuals, defaults, or save().
Atomic find-and-updateupdate-document
const updated = await BlogPost.findOneAndUpdate(
{ _id: id },
{ $inc: { views: 1 } },
{ new: true, runValidators: true }
);Without new: true you get the pre-update document back; update validators only run when you pass runValidators, and they see the update, not the whole doc.
Populate referenced documentspopulate-references
const post = await BlogPost.findById(id)
.populate("author", "name email");
console.log(post.author.name);populate is extra queries under the hood, not a server-side join; populating in a loop over N docs is a classic N+1 problem.
Pre and post middlewaremiddleware-hooks
postSchema.pre("save", function (next) {
this.slug = slugify(this.title);
next();
});
postSchema.pre("deleteOne", { document: true }, async function () {
await Comment.deleteMany({ post: this._id });
});In document middleware `this` is the document; in query middleware (find, updateOne by default) `this` is the query object. Getting that wrong is the top middleware bug.
Embedded subdocumentsembedded-subdocuments
const post = await BlogPost.findById(myId);
post.comments.push({ title: "My comment" });
await post.save();
// remove one
post.comments[0].deleteOne();
await post.save();Subdocuments get their own validators, defaults, and middleware, but they only persist when the parent document is saved.
Multi-document transactiontransactions
const session = await mongoose.startSession();
await session.withTransaction(async () => {
await Account.updateOne({ _id: from }, { $inc: { balance: -100 } }, { session });
await Account.updateOne({ _id: to }, { $inc: { balance: 100 } }, { session });
});
await session.endSession();Transactions require a replica set or mongos; they throw on a plain standalone mongod, which is exactly what most local dev setups run.
Schema field literally named typenested-type-field
new Schema({
asset: {
name: String,
type: { type: String }, // works
},
});A bare `type: String` inside a nested object makes Mongoose treat the whole parent as a String path; wrap it as { type: String } to get a real nested field.
Models on a separate connectionmultiple-connections
const conn = mongoose.createConnection(process.env.ANALYTICS_URI);
const Event = conn.model("Event", eventSchema);
await new Event({ name: "pageview" }).save();Register the model on the connection (conn.model), not mongoose.model; otherwise it binds to the default connection that may never have been opened.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| mongodb | npm | You want the official driver with no ODM layer: full control, no schema magic, best performance |
| prisma | npm | You want generated TypeScript types from one schema file and the option to move between databases later |
| @typegoose/typegoose | npm | You are committed to Mongoose but want class-based models so TypeScript types and schemas stay as one definition |