When we started building a new Node.js service backed by MongoDB, we ran into a decision most teams hit early: use the official MongoDB Driver directly, or reach for Mongoose?
Both are valid. Both are widely used. Both handle the same four CRUD operations — create, read, update, and delete.
But they don't feel the same to work with. And the choice shapes how your codebase grows over months and years, not just how your first endpoint gets written.
This is how we evaluated the two options, what each one looks like in practice, and the reasoning that settled it for us.
The Problem We Were Trying to Solve
We were building a Node.js API that needed to:
Store user records and product data
Validate input before it ever hit the database
Evolve its schema over time as features shipped
Stay readable for developers who joined the project later
Nothing exotic. But those requirements pointed toward specific trade-offs between the raw driver and an ODM.
Before comparing them, a quick definition.
What CRUD Means in MongoDB
CRUD stands for Create, Read, Update, Delete. In MongoDB, each operation maps to a method:
Operation | Meaning | MongoDB Method |
|---|---|---|
Create | Add new data |
|
Read | Retrieve data |
|
Update | Change existing data |
|
Delete | Remove data |
|
Say we have a users collection with documents like this:
{
"name": "Shayon",
"email": "shayon@example.com",
"age": 25
}We wanted to create a new user, find one, update their age, and eventually delete them. Both the Driver and Mongoose handle all four operations. The difference is how they handle them.
Option 1: The Official MongoDB Driver
The MongoDB Driver is the lower-level option. It talks straight to MongoDB with minimal abstraction between your code and the database.
npm install mongodbConnecting to MongoDB
import { MongoClient } from "mongodb";
const client = new MongoClient("mongodb://localhost:27017");
await client.connect();
const db = client.db("shop");
const users = db.collection("users");MongoClient manages the connection. From there, we pick the shop database and the users collection. That's it — no schemas, no models, just a handle on your data.
Creating a Document
const result = await users.insertOne({
name: "Shayon",
email: "shayon@example.com",
age: 25,
});
console.log(result.insertedId);MongoDB generates an _id automatically if you don't supply one.
Reading Documents
One document:
const user = await users.findOne({
email: "shayon@example.com",
});The empty filter {} matches every document in the collection.
Updating a Document
await users.updateOne(
{ email: "shayon@example.com" },
{ $set: { age: 26 } }
);The first object selects the document. The $set operator defines the change.
Deleting a Document
await users.deleteOne({
email: "shayon@example.com",
});The Complete Driver CRUD Example
Here's everything in one runnable script:
import { MongoClient } from "mongodb";
const client = new MongoClient("mongodb://localhost:27017");
async function main() {
await client.connect();
const db = client.db("shop");
const users = db.collection("users");
// CREATE
const result = await users.insertOne({
name: "Shayon",
email: "shayon@example.com",
age: 25,
});
console.log("Created:", result.insertedId);
// READ
const user = await users.findOne({
email: "shayon@example.com",
});
console.log("Found:", user);
// UPDATE
await users.updateOne(
{ email: "shayon@example.com" },
{ $set: { age: 26 } }
);
console.log("User updated");
// DELETE
await users.deleteOne({
email: "shayon@example.com",
});
console.log("User deleted");
await client.close();
}
main().catch(console.error);What We Liked About the Driver
Direct control. Nothing happens that you didn't write.
Fewer dependencies. One package, no schema layer.
Full MongoDB feature access. Every operator, aggregation stage, and index option is available without translation.
Predictable performance. No middleware or schema validation running behind the scenes.
What Gave Us Pause
No schema enforcement. Nothing stops bad data from being written.
Validation is manual. You write and maintain every check yourself.
More boilerplate. Every operation is explicit — good for clarity, slower for development.
Repeated patterns. Field names and document structures get duplicated across the codebase.
Option 2: Mongoose, the ODM Approach
Mongoose is an ODM — an Object Document Mapper. It sits between your application and the MongoDB Driver, adding schemas, models, validation, and middleware.
bash
npm install mongooseDefining a Schema
First, declare the structure of your documents:
import mongoose from "mongoose";
const userSchema = new mongoose.Schema({
name: { type: String, required: true },
email: { type: String, required: true },
age: { type: Number, required: true },
});Creating a Model
const User = mongoose.model("User", userSchema);The User model exposes methods for working with the collection.
Connecting to MongoDB
await mongoose.connect("mongodb://localhost:27017/shop");CRUD with the User Model
Create:
const user = await User.create({
name: "Shayon",
email: "shayon@example.com",
age: 25,
});Read:
const user = await User.findOne({
email: "shayon@example.com",
});
const users = await User.find({});Update:
await User.updateOne(
{ email: "shayon@example.com" },
{ $set: { age: 26 } }
);Delete:
await User.deleteOne({
email: "shayon@example.com",
});The Complete Mongoose CRUD Example
import mongoose from "mongoose";
const userSchema = new mongoose.Schema({
name: { type: String, required: true },
email: { type: String, required: true },
age: { type: Number, required: true },
});
const User = mongoose.model("User", userSchema);
async function main() {
await mongoose.connect("mongodb://localhost:27017/shop");
// CREATE
const user = await User.create({
name: "Shayon",
email: "shayon@example.com",
age: 25,
});
console.log("Created:", user);
// READ
const foundUser = await User.findOne({
email: "shayon@example.com",
});
console.log("Found:", foundUser);
// UPDATE
await User.updateOne(
{ email: "shayon@example.com" },
{ $set: { age: 26 } }
);
console.log("User updated");
// DELETE
await User.deleteOne({
email: "shayon@example.com",
});
console.log("User deleted");
await mongoose.connection.close();
}
main().catch(console.error);What We Liked About Mongoose
Schema definition. The structure of every document is declared once.
Built-in validation.
required: truealone eliminates a whole class of bugs.Readable models.
User.create()andUser.findOne()read like domain logic, not database calls.Middleware hooks. Pre-save and post-save logic lives in one place.
Documentation and community. Well-established, with answers to most problems a search away.
What Gave Us Pause
Extra abstraction. Debugging sometimes means tracing through Mongoose internals.
Opinionated defaults. Behavior you didn't write can surprise you.
Performance overhead. Schema validation and middleware add work on every operation.
Not required by MongoDB. Mongoose is optional — MongoDB itself doesn't need it.
Driver vs Mongoose: The Same Operations, Different Surroundings
Put the methods side by side and something jumps out:
Operation | MongoDB Driver | Mongoose |
|---|---|---|
Create |
|
|
Read one |
|
|
Read many |
|
|
Update |
|
|
Delete |
|
|
The method names are nearly identical. The difference isn't the operations — it's what surrounds them.
Architecturally, the Driver gives you a direct path:
text
Node.js Application
↓
MongoDB Driver
↓
MongoDB
Mongoose inserts one more layer:
text
Node.js Application
↓
Mongoose
↓
MongoDB Driver
↓
MongoDB
One terminology note while we're here: because MongoDB is a document database, ODM is the accurate term — not ORM. Mongoose is an Object Document Mapper, not an Object Relational Mapper. The distinction matters when you're reading documentation and evaluating other libraries.
How We Actually Made the Decision
We scored each option against the four requirements we started with:
Requirement | Driver | Mongoose |
|---|---|---|
Store user and product data | ✅ | ✅ |
Validate input before write | ⚠️ | ✅ |
Evolve schema over time | ⚠️ | ✅ |
Stay readable for new developers | ⚠️ | ✅ |
The Driver handled storage perfectly. But validation, schema evolution, and readability all required us to build our own layers on top.
That's the turning point, and it's the honest answer to the Driver-vs-Mongoose question: the Driver doesn't remove work — it defers it.
Choose the Driver, and eventually you write your own validation functions, field-name constants, input sanitization, and type checks. You can do all of it well. But it's code you now own and maintain for the life of the project.
Mongoose packages those same concerns into a layer we didn't have to build.
Readability was the requirement that tipped it for us. Martin Fowler put it plainly years ago: "Any fool can write code that a computer can understand. Good programmers write code that humans can understand." User.findOne({ email }) reads like domain logic. A raw collection query paired with a hand-rolled validation helper reads like plumbing. When we pictured a new developer joining the team six months in, the choice got easier.
If you are thinking to learn node.js, read this article before taking any dicision
When We'd Still Choose the Raw Driver
Mongoose isn't always the answer. We identified five scenarios where we'd reach for the Driver instead.
1. High-throughput, low-abstraction workloads. Logs, metrics, or events where speed matters more than validation — the Driver wins.
2. Unstructured or rapidly changing data. When documents genuinely don't have a fixed shape, a schema becomes friction instead of a safety net.
3. Heavy use of advanced MongoDB features. Aggregation pipelines, change streams, and bulk operations are more direct without a translation layer.
4. Minimal dependencies. Some teams prefer fewer packages in the dependency tree. That's a legitimate engineering position.
5. Full control over queries. When performance tuning requires knowing exactly what hits the wire, the Driver is transparent.
When Mongoose Is Worth the Abstraction
And the flip side — where npm install mongoose earns its place:1. Structured, predictable data. Users, orders, products — anything with a known shape.
2. Validation matters. Schema-level validation prevents bad data at the source instead of at three in the morning.
3. Team scalability. New developers read User.findOne() faster than they read a raw query plus custom guards.
4. Schema evolution. Adding fields, defaults, and migrations is cleaner with a schema definition.
5. Application-level logic. Middleware hooks for password hashing, timestamps, and audit trails live in one predictable place.
Common Mistakes Worth Avoiding
Whichever option you pick, a few traps come up again and again.
Confusing an ODM with an ORM. MongoDB is a document database. Tools like Mongoose are ODMs, not ORMs. Getting this right matters when reading docs or comparing libraries.
Forgetting to close connections in scripts. With the Driver, that's await client.close(). With Mongoose, await mongoose.connection.close(). In production, you typically reuse connections rather than opening and closing one per request — but for one-off scripts, closing is a good habit.
Skipping update operators. MongoDB updates use operators like $set. Forget them, and you can accidentally replace an entire document instead of changing one field.
Assuming Mongoose is required. It isn't. MongoDB works perfectly well with the raw Driver. Mongoose is a convenience layer, not a dependency.
Learn more about mongodb here
The Takeaway
Both approaches perform the same CRUD operations: Create → Read → Update → Delete.
The MongoDB Driver gives you direct access and full control. Mongoose gives you structure, validation, and readability on top of that access.
For our project, we chose Mongoose — because validation, schema clarity, and team readability outweighed the extra abstraction. Your requirements table will probably look different from ours, and that's fine. Let your data, your team, and the amount of structure your application actually needs make the call.
Once the four operations feel comfortable in either approach, you're ready for what comes next: query operators, indexes, aggregation pipelines, transactions, pagination, and performance tuning.




