Databases from Node.js
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences
of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were
checked against the official documentation and the npm registry on 2026-10-03, and every example below was run on all three lines against
PostgreSQL 17, MongoDB 8 and Redis 8 running in Docker. Node.js itself ships exactly one database client, the node:sqlite module
(Stability 1.2, Release candidate), which has its own page: Built-in SQLite. Everything else is a
userland package: the drivers used here are pg 8.23, mongodb 7.7, redis (node-redis) 6.3 and ioredis 6.0, none of which is part of
Node.js, so they follow their own release and support policies (mongodb 7.7 requires Node.js 20.19 or newer, redis and ioredis require
Node.js 20 or newer, pg 8.23 accepts Node.js 16 or newer). This page owns the Node.js side of the story: how a long-lived process holds
connections, how it must treat user input, and how the event loop is affected. The database engines themselves are documented elsewhere in
this site and are linked, not repeated. The running scenario is the Bookshelf application that grows across this section, whose
architecture is described in Introduction & Architecture.
Choosing and connecting
A database driver is mostly a socket client (it speaks the database’s wire protocol over node:net or node:tls), so every query is
asynchronous I/O that the event loop multiplexes: waiting for a query costs no thread. What does cost
CPU on your single JavaScript thread is the work the driver does around the wire: parsing large result sets, converting types and
serialising documents. That is why Don’t Block the Event Loop applies to database code
too: select only the columns you need, page large results, and stream them when they are big.
| Store | Official or de-facto client | Pool model | Where the engine is documented |
|---|---|---|---|
PostgreSQL |
|
Explicit |
|
MongoDB |
|
One |
|
Redis |
|
One multiplexed connection per client; pipelining by default usage |
|
SQLite |
|
None: an in-process library, no network |
Connection settings are configuration, not code. Read them from the environment (see
CLI Options, Environment & Config File); the --env-file flag loads a
.env file and is stable since v24.10.0 and v22.21.0. The examples below assume this file, which must never be committed:
.env (placeholders only)DATABASE_URL=postgres://app_user:change-me@127.0.0.1:5432/bookshelf
MONGODB_URL=mongodb://127.0.0.1:27017
REDIS_URL=redis://127.0.0.1:6379
PostgreSQL with pg
pg exposes two ways to run SQL. client.query() uses one dedicated connection; pool.query() borrows an idle connection from a Pool,
runs the statement and returns the connection. A web server must use a Pool: opening a connection per request costs a TCP handshake,
optionally TLS, and a PostgreSQL backend process, and a single shared Client serialises every request behind one connection.
Pooling
The pool keeps at most max connections (10 by default). When all are busy, further requests wait in a queue; connectionTimeoutMillis
bounds that wait (0, the default, waits forever), and idleTimeoutMillis closes connections that sit idle (10 seconds by default). Size the
pool against the database, not against your traffic: PostgreSQL’s max_connections is shared by every replica of your service, so ten
replicas with a pool of 10 need 100 connections. Attach an 'error' listener to the pool, because an idle connection that the server
drops emits an event on the pool, and an unhandled 'error' event terminates the process. Close the pool with pool.end() during a
graceful shutdown (see Process, Signals & Exit Codes).
Parameterised queries
Pass values as the second argument and reference them as $1, $2 and so on. The value travels separately from the SQL text, so it can
never change the meaning of the statement. String concatenation or template literals that build SQL from input are SQL injection (CWE-89).
Placeholders can only stand for values: identifiers such as table or column names cannot be parameters, so validate them against an allow-list.
Transactions
A transaction must run on one connection, so pool.query('BEGIN') is a bug (the next statement may be served by a different connection).
Check a client out with pool.connect(), run BEGIN, your statements and COMMIT, roll back on any error, and always release the client in
a finally block; a client that is never released leaks a pool slot until the application stalls.
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10, // default is 10
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
});
pool.on('error', (err) => console.error('idle client error', err.message));
await pool.query(`CREATE TABLE IF NOT EXISTS books (
id serial PRIMARY KEY, title text NOT NULL, stock int NOT NULL DEFAULT 0)`);
// Parameterised query: the hostile-looking value is stored as plain data.
const title = "Dune'); DROP TABLE books;--";
const { rows } = await pool.query(
'INSERT INTO books (title, stock) VALUES ($1, $2) RETURNING id, title',
[title, 3],
);
console.log(rows[0]);
// Transaction: one client, BEGIN ... COMMIT, always release.
async function withTransaction(fn) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
try {
await withTransaction(async (c) => {
await c.query('UPDATE books SET stock = stock - 1 WHERE id = $1', [1]);
throw new Error('payment failed'); // the UPDATE is rolled back
});
} catch (e) { console.log('rolled back:', e.message); }
await pool.end();
{ id: 1, title: "Dune'); DROP TABLE books;--" }
rolled back: payment failed
Source: node-postgres: Pooling, Queries and Transactions
For what a transaction guarantees (isolation levels, locking, deadlocks) see SQL Transactions; for finding the queries that make your pool wait, see Diagnosing Slow Queries.
Migrations
A migration is a numbered, versioned change to the schema that is applied once, in order, and recorded. Never let the application create or
alter tables implicitly on every start in production, and never edit a migration that has already shipped; add a new one. The concepts and
the tool landscape are covered in Choosing a Migration Tool. In the Node.js
ecosystem the common choices are the migration commands of your ORM or query builder (Prisma Migrate, drizzle-kit, Knex and Sequelize
migrations, TypeORM migrations) and standalone SQL-file runners; with raw pg you can write the runner yourself, and the script below
shows the three properties every runner needs: a record of what ran, one transaction per migration, and a lock so that two instances that
start together do not race.
import { readdir, readFile } from 'node:fs/promises';
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const client = await pool.connect(); // advisory locks belong to a session
try {
await client.query('SELECT pg_advisory_lock(727274)'); // one runner at a time
await client.query(`CREATE TABLE IF NOT EXISTS schema_migrations (
name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`);
const done = new Set(
(await client.query('SELECT name FROM schema_migrations')).rows.map((r) => r.name),
);
const dir = new URL('./migrations/', import.meta.url);
for (const file of (await readdir(dir)).filter((f) => f.endsWith('.sql')).sort()) {
if (done.has(file)) continue;
await client.query('BEGIN');
try {
await client.query(await readFile(new URL(file, dir), 'utf8'));
await client.query('INSERT INTO schema_migrations (name) VALUES ($1)', [file]);
await client.query('COMMIT');
console.log('applied', file);
} catch (err) {
await client.query('ROLLBACK');
throw err;
}
}
} finally {
await client.query('SELECT pg_advisory_unlock(727274)');
client.release();
await pool.end();
}
With two files, 001_authors.sql and 002_book_author.sql, in migrations/, the first run prints applied 001_authors.sql and
applied 002_book_author.sql, and the second run prints nothing (observed).
Source: PostgreSQL: Advisory locks
Some statements, such as CREATE INDEX CONCURRENTLY, cannot run inside a transaction block, so a real runner needs a per-migration opt-out.
MongoDB with the official driver
Create one MongoClient for the lifetime of the process and reuse it. The client owns the connection pool to every server it monitors
(maxPoolSize defaults to 100 per server) and discovers the topology, so creating a client per request is the most common performance bug.
The client connects lazily on first use in current drivers, so await client.connect() is optional; call client.close() on shutdown. Collections and
databases are cheap handles, not connections. Results come back as a cursor: iterate it with for await (the cursor is an async iterable)
or call toArray() only when the result is known to be small.
MongoDB has no SQL injection, but it has its own: operator injection. A query is an object, so if you pass a request body straight into a
filter, the client can send { "$ne": null } where you expected a string, and the filter matches every document (a classic login bypass).
Validate the shape and type of every field before it reaches a filter (a schema library or an explicit typeof check), and never pass
req.body as a filter. Multi-document transactions require a replica set or sharded cluster, so a standalone mongod in development cannot
run them; see MongoDB Transactions and Drivers & Tooling.
import { MongoClient } from 'mongodb';
// One client per process: it owns the connection pool.
const client = new MongoClient(process.env.MONGODB_URL, {
maxPoolSize: 20,
serverSelectionTimeoutMS: 5_000,
});
try {
const reviews = client.db('bookshelf').collection('reviews');
await reviews.createIndex({ bookId: 1, createdAt: -1 });
await reviews.insertOne({ bookId: 1, rating: 5, text: 'Great', createdAt: new Date() });
// Iterate the cursor instead of loading everything with toArray().
for await (const doc of reviews.find({ bookId: 1 }).sort({ createdAt: -1 }).limit(10)) {
console.log(doc.text);
}
// DANGER: an object from a JSON body is an operator, not a string.
const hostile = { $ne: null };
console.log(await reviews.countDocuments({ text: hostile }), 'matched by injected operator');
// Safe: reject anything that is not the expected primitive type.
const text = typeof hostile === 'string' ? hostile : undefined;
if (text === undefined) console.log('rejected non-string input');
} finally {
await client.close();
}
Great
1 matched by injected operator
rejected non-string input
Redis with node-redis and ioredis
Both libraries multiplex many commands over one connection, so unlike PostgreSQL you rarely need a pool: create one client, connect() it
once and share it. Always attach an 'error' listener, because the client reconnects on its own and reports transport errors as events;
without a listener the first dropped connection throws. node-redis 6 closes with close(). The commonest use of Redis from a web service
is the cache-aside pattern: read the cache, on a miss load from the database and store the result with a time to live. Always set an
expiry so that entries cannot live forever, and keep a stored value small, because a large JSON string is parsed on your single thread.
Redis offers atomic multi-command groups with multi() (a MULTI/EXEC transaction) and batching with pipelines; their semantics are in
Pipelining, Transactions & Scripting, and the client choice and
client-side caching in Clients & Client-Side Caching.
import { createClient } from 'redis';
const redis = createClient({ url: process.env.REDIS_URL });
redis.on('error', (err) => console.error('redis error', err.message));
await redis.connect();
async function getBook(id, load) { // cache-aside
const key = `book:${id}`;
const hit = await redis.get(key);
if (hit) return { ...JSON.parse(hit), cached: true };
const book = await load(id);
await redis.set(key, JSON.stringify(book), { EX: 60 }); // always expire
return { ...book, cached: false };
}
const load = async (id) => ({ id, title: 'Dune' }); // stands in for a SQL query
console.log(await getBook(1, load));
console.log(await getBook(1, load));
const [count, expiry] = await redis.multi().incr('hits').expire('hits', 60).exec();
console.log(count, expiry);
await redis.close();
{ id: 1, title: 'Dune', cached: false }
{ id: 1, title: 'Dune', cached: true }
1 1
ioredis has the same shape with a different API: new Redis(url) connects immediately, commands such as set(key, value, 'EX', 60) take
positional arguments, pipeline() batches commands and exec() returns an array of [error, result] pairs, and the client is closed with
disconnect() or quit(). Redis’s own ioredis guide recommends node-redis for new projects and keeps ioredis documented for existing ones.
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
await redis.set('greeting', 'hello', 'EX', 60);
console.log(await redis.get('greeting')); // hello
console.log(await redis.pipeline().incr('n').incr('n').exec()); // [ [ null, 1 ], [ null, 2 ] ]
await redis.del('greeting', 'n');
redis.disconnect();
Source: Redis: ioredis guide
SQLite
SQLite is a library, not a server: the database is a file (or memory) opened inside your process, so there is no network, no pool and no
authentication, and a query runs on the calling thread. node:sqlite is synchronous (DatabaseSync), which is ideal for local tools,
tests, caches and small services and a hazard for a busy server, because every query blocks the event loop until it returns. Move to a server
database when you need several application instances writing to the same data, replication, fine-grained access control, or a concurrent
write rate that SQLite’s single writer cannot serve. The full API (statements, transactions, user functions, sessions, backup) is on
Built-in SQLite; the third-party better-sqlite3 package fills the same niche with a similar
synchronous API on versions of Node.js that predate node:sqlite.
ORMs and query builders: a map
You can stay with raw drivers, or add a layer. The trade-off is control and transparency against productivity and type safety. This page does not teach any of them; the table points at official documentation, and the NestJS section shows full integrations on the same Bookshelf domain. Versions move quickly, so check each project’s own release notes before starting.
| Tool | Kind | What it is best known for | Where to start |
|---|---|---|---|
Prisma |
ORM, schema-first |
A declarative schema file, generated typed client and its own migration tool; supports SQL databases and MongoDB. |
Prisma documentation; NestJS: Prisma, Drizzle, MikroORM & Sequelize |
Drizzle |
SQL-first ORM and query builder |
Schema written in TypeScript, queries that read like SQL, |
|
Sequelize |
Promise-based ORM |
Long-standing SQL ORM with models, associations and migrations; a large installed base. |
|
TypeORM |
Decorator-based ORM |
Entities as TypeScript classes, repositories and migrations. |
|
Mongoose |
MongoDB object modelling |
Schemas, validation, middleware and population on top of the official MongoDB driver. |
|
Knex |
SQL query builder |
A fluent, composable query builder with migrations and seeds; no model layer. |
A practical rule: use the raw driver for a few queries and for performance-critical paths, a query builder when you want composable SQL with less string handling, and an ORM when the domain model is rich and the team values generated types and migrations. Every one of them still sits on top of a driver and a pool, so everything about pool sizing, transactions and injection on this page still applies.
The IPv6 localhost pitfall
localhost is a name, and a name can resolve to several addresses: typically ::1 (IPv6) and 127.0.0.1 (IPv4). Node.js resolves host
names with dns.lookup(), which delegates to the operating system, and since v17 returns the addresses in the order the resolver gave them
(verbatim) instead of preferring IPv4; dns.getDefaultResultOrder() shows the setting, and dns.setDefaultResultOrder() or the
--dns-result-order option changes it. On many machines localhost therefore resolves to ::1 first. If the database only listens on
IPv4 (a Docker port published on 127.0.0.1, a PostgreSQL started with listen_addresses = '127.0.0.1'), a client that tries ::1 gets
ECONNREFUSED. The reverse also happens when a service listens only on ::1. The symptom is confusing: the database is up, psql works,
yet Node.js reports ECONNREFUSED ::1:5432, or only some machines in the team fail. Since v20 the net module tries every address in turn
(autoSelectFamily, "Happy Eyeballs", on by default and disabled with --no-network-family-autoselection), which hides most
occurrences; but a driver that resolves the name itself, a proxy, or a container network can still reproduce it. The robust fix is to be
explicit: use 127.0.0.1 or ::1 in connection strings for local development, make the server listen on the family you intend, and
use service names in container networks.
import dns from 'node:dns';
import net from 'node:net';
console.log('default result order:', dns.getDefaultResultOrder());
console.log(await dns.promises.lookup('localhost', { all: true }));
const server = net.createServer().listen(0, '127.0.0.1', async () => { // IPv4 only
const { port } = server.address();
for (const host of ['127.0.0.1', '::1']) {
await new Promise((resolve) => {
const s = net.connect({ host, port });
s.on('connect', () => { console.log(host, 'connected'); s.destroy(); resolve(); });
s.on('error', (e) => { console.log(host, e.code); resolve(); });
});
}
server.close();
});
default result order: verbatim
[ { address: '::1', family: 6 }, { address: '127.0.0.1', family: 4 } ]
127.0.0.1 connected
::1 ECONNREFUSED
Operational checklist
| Concern | Practice |
|---|---|
Connection lifetime |
Create pools and clients once at start-up, share them, and close them in your shutdown handler after you stop accepting requests. |
Pool sizing |
Budget connections across all replicas; bound waiting with a timeout so overload becomes a fast error instead of a growing queue. |
Timeouts |
Set connection, query ( |
Injection |
Parameterise SQL, validate the type of every value placed in a MongoDB filter, and allow-list identifiers. |
Secrets and TLS |
Read connection strings from the environment or a secret store, never from source control, and require TLS to any non-local database. |
Retries |
Retry only idempotent operations, with backoff; never retry a transaction body that has side effects outside the database. |
Health checks |
A readiness endpoint should run a cheap query ( |
What changed recently
| Version | Change |
|---|---|
Node.js v17.0.0 |
|
Node.js v20.0.0 |
|
Node.js v22.5.0 |
|
Node.js v24.10.0 and v22.21.0 |
|
mongodb 7.x |
Requires Node.js 20.19 or newer; drivers follow the MongoDB compatibility matrix, so upgrade the driver with the server. |
The version rows for Node.js were taken from the version history of the respective documentation pages; the driver rows come from each package’s own documentation and release notes on the date above and should be rechecked before an upgrade.
Where the books are outdated
The two reference books (Efficient Node.js by Samer Buna and Node.js Projects by Jonathan Wexler, both O’Reilly 2025) use database clients in their sample applications. As with any printed material, the client versions shown there age quickly: before copying a connection snippet, check the package’s current major version on npm and its own guide, prefer the promise API over callback-style examples, create the pool or client once at start-up rather than per request, read connection strings from the environment, and treat any example that builds SQL or MongoDB filters from request data as insecure until it is parameterised or validated as described above.
References
-
Node.js API: Command-line options, https://nodejs.org/docs/latest/api/cli.html
-
Node.js API: DNS, https://nodejs.org/docs/latest/api/dns.html
-
Node.js API: Net, https://nodejs.org/docs/latest/api/net.html
-
Node.js API: SQLite, https://nodejs.org/docs/latest/api/sqlite.html
-
node-postgres documentation, https://node-postgres.com/
-
PostgreSQL documentation: Explicit locking (advisory locks), https://www.postgresql.org/docs/current/explicit-locking.html
-
MongoDB Node.js driver documentation, https://www.mongodb.com/docs/drivers/node/current/
-
Redis: Node.js (node-redis) guide, https://redis.io/docs/latest/develop/clients/nodejs/
-
Redis: ioredis guide, https://redis.io/docs/latest/develop/clients/ioredis/
-
Prisma documentation, https://www.prisma.io/docs
-
Drizzle ORM documentation, https://orm.drizzle.team/
-
Sequelize documentation, https://sequelize.org/
-
TypeORM documentation, https://typeorm.io/
-
Mongoose documentation, https://mongoosejs.com/
-
Knex.js documentation, https://knexjs.org/
-
O’Reilly: Efficient Node.js, Samer Buna, https://www.oreilly.com/library/view/efficient-node-js/9781098145187
-
O’Reilly: Node.js Projects, Jonathan Wexler, https://www.oreilly.com/library/view/node-js-projects/9781098173135/