mongodb
The official MongoDB driver for Node.js, maintained by MongoDB Inc. It is the low-level client: you get a connection pool, typed CRUD methods on collections, aggregation pipelines, transactions, change streams, and BSON handling, with first-class TypeScript generics for your document shapes. There is no schema layer, no validation, no model classes; documents go in and come out as plain objects with BSON types like ObjectId and Decimal128. Every higher-level MongoDB tool in the Node ecosystem (Mongoose included) sits on top of this driver, so learning its API pays off even if you add an ODM later.
If you use MongoDB from Node, this driver is not optional, the only question is whether you put Mongoose on top. Use it bare when you want control and few dependencies; add an ODM when your team wants schemas and hooks.
Use it if
- You want direct control over queries, indexes, and aggregation pipelines without an ODM translating for you
- You already validate data at the application boundary (zod, JSON Schema, or MongoDB server-side validation) and do not need Mongoose's schema layer
- You need driver-level features quickly: change streams, transactions, client-side field level encryption, or the newest server capabilities land here first
- You care about dependency weight: the driver has three runtime deps, all MongoDB-owned
- Your team relies on schemas, middleware hooks, and populate(); rebuilding those on the raw driver is real work and Mongoose already does it
- You are on an older Node: v7 of the driver requires Node >=20.19.0, and recent majors keep raising the floor
- You dislike frequent major versions: the driver has shipped several majors in recent years (v7 has its own upgrade guide) and each one deprecates or removes API surface
- Your data is fundamentally relational with many joins and constraints; a SQL database with an ORM will fight you less than $lookup pipelines
- You need to file bugs in GitHub Issues; the project tracks everything in MongoDB's JIRA, which adds friction
Setup reality
npm install mongodb is clean: three dependencies and no native builds by default. The catch is the optional peer dependency list (kerberos, mongodb-client-encryption, @mongodb-js/zstd, snappy, socks, gcp-metadata, aws credential providers); npm prints noisy warnings for features you may never use, and the moment you do need Kerberos auth or field-level encryption you are compiling native modules. Budget time for connection-string tuning too: pool sizes, serverSelectionTimeoutMS, and TLS options are where most production incidents hide. Node >=20.19.0 is enforced for v7.
Patterns
Connect and reuse one clientconnect
import { MongoClient } from 'mongodb'
const client = new MongoClient(process.env.MONGODB_URI)
await client.connect()
const db = client.db('shop')
const users = db.collection('users')Create one MongoClient per process and reuse it; it manages a pool internally, so per-request clients exhaust connections.
Type documents with genericstyped-collection
import { ObjectId } from 'mongodb'
interface User {
_id?: ObjectId
email: string
createdAt: Date
}
const users = db.collection<User>('users')Types are compile-time only; the driver does zero runtime validation of what you insert.
Insert one and manyinsert
const one = await users.insertOne({ email: 'a@b.com', createdAt: new Date() })
console.log(one.insertedId)
await users.insertMany([
{ email: 'c@d.com', createdAt: new Date() },
{ email: 'e@f.com', createdAt: new Date() },
], { ordered: false })ordered: false keeps inserting past duplicate-key errors instead of stopping at the first failure.
Query with filter, projection, sortfind-query
const recent = await users
.find({ createdAt: { $gte: new Date('2026-01-01') } })
.project({ email: 1 })
.sort({ createdAt: -1 })
.limit(20)
.toArray()find() returns a lazy cursor; nothing hits the server until toArray(), for-await, or next().
Look up by _idfind-by-id
import { ObjectId } from 'mongodb'
const user = await users.findOne({ _id: new ObjectId(idString) })String ids never match ObjectId fields; forgetting the wrapper is the classic silent empty-result bug.
Update with $set and upsertupdate-upsert
await users.updateOne(
{ email: 'a@b.com' },
{ $set: { plan: 'pro' }, $setOnInsert: { createdAt: new Date() } },
{ upsert: true },
)Passing a plain object without update operators throws; replaceOne is the API for full-document replacement.
Atomically update and return the docfind-one-and-update
const updated = await users.findOneAndUpdate(
{ _id: userId },
{ $inc: { credits: -1 } },
{ returnDocument: 'after' },
)Since v6 this returns the document directly (or null); older code expecting result.value breaks.
Run an aggregation pipelineaggregate
const totals = await orders.aggregate([
{ $match: { status: 'paid' } },
{ $group: { _id: '$userId', total: { $sum: '$amount' } } },
{ $sort: { total: -1 } },
{ $limit: 10 },
]).toArray()Put $match first so indexes apply; stages after $group run on unindexed in-memory data.
Create indexes at startupcreate-index
await users.createIndex({ email: 1 }, { unique: true })
await orders.createIndex({ userId: 1, createdAt: -1 })createIndex is a no-op if the identical index exists, so calling it on boot is safe and common.
Multi-document transactiontransactions
const session = client.startSession()
try {
await session.withTransaction(async () => {
await accounts.updateOne({ _id: from }, { $inc: { balance: -100 } }, { session })
await accounts.updateOne({ _id: to }, { $inc: { balance: 100 } }, { session })
})
} finally {
await session.endSession()
}Every operation inside must pass { session } or it silently runs outside the transaction; requires a replica set.
React to live data changeschange-streams
const stream = orders.watch([
{ $match: { operationType: 'insert' } },
])
for await (const change of stream) {
console.log('new order', change.fullDocument._id)
}Change streams need a replica set or Atlas; on a standalone mongod watch() errors immediately.
Batch mixed writesbulk-write
await users.bulkWrite([
{ insertOne: { document: { email: 'x@y.com', createdAt: new Date() } } },
{ updateOne: { filter: { email: 'a@b.com' }, update: { $set: { plan: 'free' } } } },
{ deleteOne: { filter: { email: 'old@z.com' } } },
])One round trip for many writes; with ordered: false the server executes what it can and reports all errors together.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| mongoose | npm | You want schemas, validation, middleware, and populate() on top of MongoDB and accept the abstraction cost |
| prisma | npm | You want a typed data layer with schema migrations across databases; its MongoDB support covers common CRUD but not the full aggregation surface |
| postgres | npm | Your data is relational; this driver plus PostgreSQL is a simpler long-term bet than modelling relations in MongoDB |