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.

Table 1. Which client for which store
Store Official or de-facto client Pool model Where the engine is documented

PostgreSQL

pg (node-postgres), with pg-pool built in

Explicit Pool object; you borrow and release clients

SQL reference and Choosing the Right Database

MongoDB

mongodb (the official Node.js driver)

One MongoClient owns a pool per server; implicit

MongoDB reference and drivers and tooling

Redis

redis (node-redis, maintained by Redis) or ioredis

One multiplexed connection per client; pipelining by default usage

Redis reference and clients and client-side caching

SQLite

node:sqlite (built in, Release candidate)

None: an in-process library, no network

Built-in SQLite

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.

A pool, a parameterised query and a transaction helper (run on Node.js 26, 24 and 22)
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();
Output (observed)
{ id: 1, title: "Dune'); DROP TABLE books;--" }
rolled back: payment failed

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.

A minimal SQL-file migration runner for pg (run on Node.js 26, 24 and 22)
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).

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.

The MongoClient lifecycle, a cursor, and operator injection (run on Node.js 26, 24 and 22)
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();
}
Output (observed, with one review stored)
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.

Cache-aside and a transaction with node-redis (run on Node.js 26, 24 and 22)
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();
Output (observed)
{ 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.

The same idea with ioredis (run on Node.js 26, 24 and 22)
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();

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.

Table 2. Data-access tools for Node.js
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, drizzle-kit for migrations.

Drizzle documentation

Sequelize

Promise-based ORM

Long-standing SQL ORM with models, associations and migrations; a large installed base.

Sequelize documentation

TypeORM

Decorator-based ORM

Entities as TypeScript classes, repositories and migrations.

TypeORM documentation; NestJS: Data Overview & TypeORM

Mongoose

MongoDB object modelling

Schemas, validation, middleware and population on top of the official MongoDB driver.

Mongoose documentation; NestJS: MongoDB with Mongoose

Knex

SQL query builder

A fluent, composable query builder with migrations and seeds; no model layer.

Knex documentation

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.

Observing what localhost resolves to, and which family is reachable (run on Node.js 26, 24 and 22)
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();
});
Output (observed on macOS; the order depends on the operating system’s resolver)
default result order: verbatim
[ { address: '::1', family: 6 }, { address: '127.0.0.1', family: 4 } ]
127.0.0.1 connected
::1 ECONNREFUSED

Operational checklist

Table 3. Habits that prevent most production incidents
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 (statement_timeout in PostgreSQL, maxTimeMS in MongoDB) and server-selection timeouts; a database call with no timeout can hold a request forever.

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 (SELECT 1, PING) with a short timeout.

What changed recently

Table 4. Notable changes for database code
Version Change

Node.js v17.0.0

dns.lookup() keeps the resolver’s address order (verbatim) instead of putting IPv4 first, which made the localhost pitfall common.

Node.js v20.0.0

autoSelectFamily is enabled by default for net.connect(), so a hostname with several addresses is tried in turn.

Node.js v22.5.0

node:sqlite is added; it needs no flag since v22.13.0 and v23.4.0, and reached Release candidate in v25.7.0.

Node.js v24.10.0 and v22.21.0

--env-file and process.loadEnvFile() are no longer experimental.

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