MongoDB with Node.js: Mongoose vs the Native Driver
Mongoose 5 vs the MongoDB Node.js driver 3: schemas, validation, middleware, populate, lean queries, transactions, and how to choose.
Mongoose vs native driver is one of the first decisions on any Node.js project that uses MongoDB. Teams often make it by habit rather than by need. I have shipped production services both ways, and each choice has costs that only show up months later. Here I write about how the two differ in practice, with the same tasks written both ways. I also show how I decide which one a project should use.
All code here was tested with MongoDB Node.js driver 3.5 and Mongoose 5.9 against MongoDB 4.2. The query and update syntax underneath is the same one from the CRUD tutorial.
The Two Options
The MongoDB Node.js driver is the official, thin layer between your code and the server. You work with plain JavaScript objects, and the full server API is available as soon as MongoDB ships it. There is also very little magic. Everything else, such as validation, defaults and relationships, is up to you.
Mongoose is an object document mapper built on top of that driver. You define schemas, compile them into models, and get type casting, validation, defaults, middleware hooks, virtuals and populate() for references. It brings structure to a schemaless database, and that structure costs some performance and some indirection.
Connecting Correctly
With either library, the rule is the same: create one client per process at startup and reuse it. The client manages a connection pool. Opening a new client per request exhausts server connections and adds a TLS handshake to every call.
// Native driver 3.x
const { MongoClient } = require("mongodb");
const client = new MongoClient(process.env.MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
poolSize: 20
});
await client.connect();
const db = client.db("shop");
// Mongoose 5.x
const mongoose = require("mongoose");
await mongoose.connect(process.env.MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
useCreateIndex: true,
useFindAndModify: false,
poolSize: 20
});
Those flags look like noise, but each one opts into newer behaviour. useNewUrlParser and useUnifiedTopology switch the driver to its new connection string parser and the new server discovery and monitoring engine, which handles failover far better than the old one. In Mongoose, useCreateIndex builds indexes with createIndex instead of the deprecated ensureIndex, and useFindAndModify: false makes findOneAndUpdate() use the driver’s native method. Leave them out and you get a wall of deprecation warnings at startup, which is how most people discover them.
Same Task in Mongoose and the Native Driver
Create an order, list a customer’s recent pending orders, then mark one as paid. With the driver:
const { Decimal128 } = require("mongodb");
const orders = db.collection("orders");
const { insertedId } = await orders.insertOne({
customerId: "c1", status: "pending", total: Decimal128.fromString("2297.00"), createdAt: new Date()
});
const pending = await orders.find({ customerId: "c1", status: "pending" })
.sort({ createdAt: -1 }).limit(20).toArray();
await orders.updateOne({ _id: insertedId }, { $set: { status: "paid" } });
With Mongoose, after defining the Order model shown in the next section:
const order = await Order.create({ customer: customerId, total: "2297.00", items: [{ sku: "BK-101", qty: 2 }] });
const pending = await Order.find({ customer: customerId, status: "pending" })
.sort({ createdAt: -1 }).limit(20);
await Order.updateOne({ _id: order._id }, { status: "paid" });
The Mongoose version is shorter in places. It casts the string "2297.00" to Decimal128, fills in status and timestamps from the schema, and wraps a bare update in $set for you. That last convenience is also a trap. The same code passed to the driver would replace nothing and fail with an error asking for update operators. Knowing which layer is doing what matters when you debug.
Schemas and Validation
In Mongoose the schema is the centre of everything:
const orderSchema = new mongoose.Schema({
customer: { type: mongoose.Schema.Types.ObjectId, ref: "Customer", required: true },
status: { type: String, enum: ["pending", "paid", "shipped", "cancelled"], default: "pending" },
total: { type: mongoose.Schema.Types.Decimal128, required: true },
items: [{ sku: { type: String, required: true }, qty: { type: Number, min: 1 } }]
}, { timestamps: true });
orderSchema.index({ customer: 1, createdAt: -1 });
const Order = mongoose.model("Order", orderSchema);
Creating an order with status: "lost" fails with a ValidationError naming the status path before anything reaches the server. That is helpful, but it only protects writes that go through this model.
With the driver, I put validation in the database itself using $jsonSchema, which protects the collection from every client, including scripts, other services and the shell:
await db.createCollection("payments", {
validator: { $jsonSchema: {
bsonType: "object",
required: ["orderId", "amount", "method"],
properties: {
amount: { bsonType: "decimal" },
method: { enum: ["card", "upi", "netbanking"] }
}
} }
});
Inserting a payment with method: "cash" fails with error code 121, “Document failed validation”. The schema validation documentation covers validation levels and actions, including a warn mode that logs violations without rejecting them. I use it when adding rules to an existing collection.
Middleware and Hooks
Mongoose middleware runs code before or after operations. The textbook example is hashing a password before saving:
const bcrypt = require("bcrypt");
userSchema.pre("save", async function () {
if (!this.isModified("password")) return;
this.password = await bcrypt.hash(this.password, 12);
});
The pitfall is that save middleware is document middleware. It runs on doc.save() and Model.create(), but not on Model.updateOne() or findOneAndUpdate(), which use query middleware instead. I tested this: after User.updateOne({ _id }, { password: "plain" }), the stored password was the plain string. If a hook protects an invariant, either route every write through save() or add matching query middleware. Ideally, also back the rule with database validation. The driver has no hooks at all. So the same logic lives in an explicit function you call. That is more typing but nothing is hidden.
populate vs $lookup
populate() replaces stored ids with documents from another collection:
const orders = await Order.find({ customer: customerId })
.populate("customer", "name")
.lean();
// orders[0].customer -> { _id: ObjectId(...), name: 'Asha' }
Under the hood that is a second query, run in the application, using $in on the collected ids. That is efficient for one level. Nested populates across several paths become several round trips. A populate inside a loop is the classic N+1 problem. The driver alternative is a $lookup in an aggregation, which does the join on the server in one round trip. I covered it in the aggregation pipeline tutorial. If you find yourself populating the same reference on every request, that is usually a sign to embed the fields you need. I explain this in the schema design post.
Performance: Mongoose vs the Native Driver
The main cost of Mongoose on reads is hydration. That means turning each raw result into a full Mongoose document with change tracking, getters and methods. For a page of twenty results it barely matters. For an endpoint that returns thousands of documents, it shows up in CPU and memory. lean() skips hydration and returns plain objects:
const rows = await Order.find({ status: "paid" }).select("customer total createdAt").lean();
Lean results have no save() method and no virtuals or getters unless you add plugins. So I use them for read-only endpoints. I am not going to quote a speedup figure, because it depends on document size, schema complexity and Node version. Measure it on your own hot endpoint. Run the query with and without lean() under a load tool such as autocannon, and compare latency and event loop utilisation.
Transactions in Mongoose and the Native Driver
Both libraries support multi-document transactions on replica sets and sharded clusters. With the driver, withTransaction handles commit and retries transient errors for you:
const session = client.startSession();
try {
await session.withTransaction(async () => {
const accounts = db.collection("accounts");
await accounts.updateOne({ _id: "A" }, { $inc: { balance: -200 } }, { session });
await accounts.updateOne({ _id: "B" }, { $inc: { balance: 200 } }, { session });
});
} finally {
await session.endSession();
}
Mongoose uses the same mechanism. mongoose.startSession() returns a driver session, and you pass it to every model call:
const session = await mongoose.startSession();
try {
await session.withTransaction(async () => {
await Order.updateOne({ _id: orderId }, { status: "paid" }, { session });
await Payment.create([{ orderId, amount: "2297.00", method: "upi" }], { session });
});
} finally {
session.endSession();
}
Note that Model.create() needs an array when you pass options such as a session. Forgetting the session option on one call is the most common transaction bug I see with either library. That call silently runs outside the transaction.
New Server Features and Raw Access
The driver gets new server features first, because MongoDB releases it alongside the server. The 4.2 cycle is a good example. Client-side field level encryption arrived in the driver as an autoEncryption option backed by the separate mongodb-client-encryption package, and change streams gained the startAfter option. Mongoose usually adds support for new features later, and some never get a model-level wrapper.
That is rarely a blocker, because Mongoose exposes the driver underneath. Model.collection gives you the raw driver collection for one model, and mongoose.connection.db gives you the native Db object. I reach for those for change streams, unusual commands and bulk jobs where hydration and casting would only slow things down:
const { ObjectId, Decimal128 } = require("mongodb");
const result = await Order.collection.bulkWrite([
{ updateOne: { filter: { _id: orderId }, update: { $set: { status: "shipped" } } } },
{ insertOne: { document: {
customer: new ObjectId(customerId), status: "pending",
total: Decimal128.fromString("499.00"), items: [], createdAt: new Date()
} } }
], { ordered: false });
console.log(result.modifiedCount, result.insertedCount);
The catch is visible in that code. Raw driver calls bypass Mongoose casting, defaults, validation, timestamps and middleware. So code that drops down a level has to supply correct types itself, such as real ObjectId and Decimal128 values instead of strings. The driver bulk write documentation covers ordering and error handling. A bulk write is not a transaction. Each operation succeeds or fails on its own, so I use it to cut round trips, not to guarantee atomicity.
Testing is similar for both. I run integration tests against a real mongod rather than mocking the library, using the mongodb-memory-server package to start a throwaway replica set per test run. That way transactions and change streams behave as they do in production.
TypeScript
Neither library ships its own type definitions yet. Both rely on community packages from DefinitelyTyped: @types/mongodb and @types/mongoose. With the driver types, you pass an interface to db.collection<Order>("orders") and get typed results and reasonably typed filters. With Mongoose, you declare an interface that extends mongoose.Document and pass it to mongoose.model<OrderDoc>("Order", orderSchema), which means writing the shape twice, once as a schema and once as an interface, and keeping the two in step by hand. Because the type packages are maintained separately, they sometimes lag a release by weeks. In my experience the driver’s types are a little more predictable, because there is less runtime behaviour for them to describe.
Behaviour That Surprises People
A few Mongoose defaults catch teams that move between the two libraries. Strict mode is on by default, so fields that are not in the schema are silently dropped on save. That protects you from junk data. However, it also means a new field added by one developer appears to save and then vanishes until someone updates the schema. Query casting can surprise in the other direction. A filter value that does not match the schema type is cast, and a value that cannot be cast throws a CastError instead of simply matching nothing, which is why a malformed id in a URL turns into an error rather than a 404 unless you handle it.
Errors from the server look the same through both libraries, because Mongoose passes driver errors through. A duplicate key on a unique index arrives with code: 11000 in either case. I handle it explicitly at the call site rather than letting it surface as a generic 500 error.
Decision Framework: Mongoose vs Native Driver
I choose Mongoose when the team is large or changes often and the application is a classic CRUD service with many entities. It also fits when the guardrails of a shared schema prevent more bugs than they cost. I choose the driver for services where performance and control matter more than convenience, for data pipelines and scripts, for code that needs new server features immediately, and when several services written in different languages share a collection. In that last case validation must live in the database anyway. Existing code matters too: rewriting a working Mongoose application to the driver rarely pays for itself.
Common Mongoose and Driver Mistakes
The mistake I see most often is connecting per request, usually in serverless functions or in an Express route that calls connect() each time. Next is relying only on Mongoose validation while other services or scripts write to the same collections directly. The third is forgetting lean() on read-heavy endpoints that never modify the documents they load.
Frequently Asked Questions
Is Mongoose slower than the driver?
It adds overhead, mostly from casting and hydration, because it runs on top of the driver. For typical request sizes the difference is small, and lean() removes most of it for reads. Measure your own workload before deciding on performance grounds.
Can I mix both in one project?
Yes. mongoose.connection.db returns the underlying driver Db, so you can use the driver for bulk jobs or aggregation-heavy code and Mongoose for the rest, sharing one connection pool.
Does Mongoose support transactions?
Yes, since Mongoose 5.2. Start a session with mongoose.startSession(), pass it to each operation, and let session.withTransaction() handle commits and retries. Transactions need a replica set or sharded cluster, even in development.
Do I still need database validation with Mongoose?
If anything other than your Mongoose models writes to the collection, yes. Mongoose validation is application-level, so a $jsonSchema validator on critical collections is cheap insurance against scripts and other services.
Pick one hot read endpoint in your application this week, measure it with and without lean() under realistic load. Then decide from the numbers rather than from habit. If you are just starting out, start with What Is MongoDB? and come back here when you pick your stack.
Last updated on 26 October 2026