node-postgres (pg)
Production patterns for node-postgres (pg): pooling, safe queries, and transactions in TypeScript on Node 24.
Search across all documentation pages
pg)Production patterns for node-postgres (pg): pooling, safe queries, and transactions in TypeScript on Node 24.
Quick-reference recipe card - copy-paste ready.
import pg from "pg";
export const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
connectionTimeoutMillis: 5_000,
idleTimeoutMillis: 30_000,
});
export async function findOrder(id: string) {
const { rows } = await pool.query(
"SELECT id, total_cents FROM orders WHERE id = $1",
[id]
);
return rows[0] ?? null;
}When to reach for this:
// src/db/pool.ts
import pg from "pg";
export const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: Number(process.env.PG_POOL_MAX ?? 10),
ssl: process.env.NODE_ENV === "production" ? { rejectUnauthorized: true } : undefined,
});
// src/db/orders.ts
import type { Pool, PoolClient } from "pg";
import { pool } from "./pool";
export type Order = { id: string; userId: string; totalCents: number };
export async function createOrderWithItems(
userId: string,
items: { sku: string; qty: number; priceCents: number }[]
): Promise<Order> {
const client = await pool.connect();
try {
await client.query("BEGIN");
const orderRes = await client.query<{ id: string }>(
"INSERT INTO orders (user_id, total_cents) VALUES ($1, $2) RETURNING id",
[userId, items.reduce((s, i) => s + i.qty * i.priceCents, 0)]
);
const orderId = orderRes.rows[0].id;
for (const item of items) {
await client.query(
"INSERT INTO order_items (order_id, sku, qty, price_cents) VALUES ($1, $2, $3, $4)",
[orderId, item.sku, item.qty, item.priceCents]
);
}
await client.query("COMMIT");
return { id: orderId, userId, totalCents: items.reduce((s, i) => s + i.qty * i.priceCents, 0) };
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
// src/routes/orders.ts (Express 5)
import express from "express";
import { createOrderWithItems } from "../db/orders";
const router = express.Router();
router.post("/", async (req, res, next) => {
try {
const { userId, items } = req.body;
const order = await createOrderWithItems(userId, items);
res.status(201).json(order);
} catch (err) {
next(err);
}
});
export default router;What this demonstrates:
BEGIN/COMMIT/ROLLBACKdb/ or repository adapters| API | Use when |
|---|---|
pool.query(sql, params) | One-off SELECT/INSERT/UPDATE |
pool.connect() + client.query | Multi-query transaction on same connection |
pool.end() | Process shutdown |
pool.query checks out a client internally and releases it// Safe
await pool.query("SELECT * FROM users WHERE email = $1", [email]);
// Unsafe - never do this
await pool.query(`SELECT * FROM users WHERE email = '${email}'`);pg sends parameters separately from the SQL textIN lists, use = ANY($1::uuid[]) with an array paramconst res = await pool.query<{ id: string; email: string }>(
"SELECT id, email FROM users WHERE id = $1",
[id]
);query<T>() types rows only; validate at boundaries with Zod when neededpg supports unnamed prepared statements per query automaticallypool.on("error", (err) => {
console.error({ msg: "idle client error", err: err.message });
});pool.waitingCount stays high (pool exhaustion)max_connections. Fix: module-level singleton pool.client.release() - pool starvation. Fix: try/finally around every connect().ssl: { rejectUnauthorized: true } with CA if required.| Alternative | Use When | Don't Use When |
|---|---|---|
| Prisma | Schema migrations and DX priority | Heavy custom SQL every endpoint |
| Drizzle | SQL-first with TypeScript inference | Team wants zero SQL visibility |
| Knex | Legacy codebase already on Knex | Greenfield with Drizzle/Prisma options |
@vercel/postgres / Neon serverless driver | Edge/serverless without pooler | Long-lived worker with local pool |
Start with 10 per Node process. Tune against Postgres max_connections and replica count. See Connection Pool Tuning.
Yes. import pg from "pg" with "type": "module" in package.json.
Use dbmate, flyway, goose, or Drizzle/Prisma migrate. Never apply ad hoc DDL in production shells.
Provide Pool as a custom provider or use TypeORM/Prisma adapters that use pg under the hood.
Postgres returns bigint as string in JS. Use ::text cast or map to string in TypeScript types.
Dedicated long-lived Client, not the pool. Rare in stateless APIs; prefer queues for events.
Testcontainers Postgres for integration tests; mock Pool with jest.mock only for trivial cases.
Separate pools for read and write URLs. Route SELECTs to replica pool in repository methods explicitly.
Prefer explicit ssl option in code over ?sslmode=require only when CA verification matters.
Use multi-row INSERT ... VALUES ($1,$2), ($3,$4) or COPY for bulk loads.
pool.end()Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.
Reviewed by Chris St. John·Last updated Jul 16, 2026