Step 07 of 12

PostgreSQL from Node

Use PostgreSQL from TypeScript with node-postgres: pools, parameterised queries, typed rows, transactions, migrations and a repository layer.

18 min 9 sections

In-memory arrays vanish on restart and can't be shared between server processes. Real data lives in a database, and for most backends that database is PostgreSQL. This lesson connects a Node API to PostgreSQL with pg (node-postgres), the most widely used driver: connection pools, safe parameterised queries, typing rows, transactions, schema migrations, and a repository layer that keeps SQL out of your route handlers. It assumes you can write basic SQL; the SQL roadmap teaches it from scratch.

Running PostgreSQL locally

The quickest way is a container:

bash
docker run -d --name orders-db -p 5432:5432 \
  -e POSTGRES_USER=orders -e POSTGRES_PASSWORD=orders -e POSTGRES_DB=orders postgres:17
ini
# .env
DATABASE_URL=postgres://orders:orders@localhost:5432/orders
bash
npm install pg
npm install --save-dev @types/pg

Connection pools

Opening a PostgreSQL connection is expensive (a TCP and TLS handshake, authentication, a new server process), so you don't open one per request. A pool keeps a set of open connections and lends them out:

ts
// src/db.ts
import pg from 'pg'
import { config } from './config.ts'

export const pool = new pg.Pool({
  connectionString: config.DATABASE_URL,
  max: 10,                        // at most 10 connections from this process
  idleTimeoutMillis: 30_000,      // close connections idle for 30 s
  connectionTimeoutMillis: 5_000, // fail if no connection is free within 5 s
})

pool.on('error', (err) => {
  // An idle connection broke (database restarted, network blip). The pool replaces it.
  console.error({ msg: 'idle database connection error', err })
})

pool.query(...) borrows a connection, runs the query, and returns the connection automatically.

How big should the pool be? Smaller than you'd think. PostgreSQL handles each connection with a separate process, and a few dozen active ones saturate a typical server. Five application instances with max: 10 already means 50 connections. Requests beyond the pool size simply wait briefly for a free connection, which is better than overloading the database.

Parameterised queries

Pass values separately from the SQL text, as $1, $2 placeholders:

ts
const { rows } = await pool.query(
  'SELECT id, customer_email, status FROM orders WHERE customer_email = $1 AND status = $2',
  [email, 'paid'],
)

The driver sends the SQL and the values to PostgreSQL separately, so a value can never be interpreted as SQL. Never build SQL with template strings:

ts
// SQL injection: email = "x' OR '1'='1" returns every order in the table
await pool.query(`SELECT * FROM orders WHERE customer_email = '${email}'`)

Placeholders work for values only. Table names, column names and sort directions can't be parameters; if they come from user input, map them through a fixed allowlist (const column = { newest: 'created_at', total: 'total_cents' }[sort]).

Typing rows

pool.query accepts a row type, but it's a claim, not a check (the database returns whatever the SQL selects):

ts
type OrderRow = {
  id: number
  customer_email: string
  status: 'pending' | 'paid' | 'shipped' | 'cancelled'
  total_cents: number
  created_at: Date
}

const { rows } = await pool.query<OrderRow>('SELECT * FROM orders WHERE id = $1', [id])
const order = rows[0] // OrderRow | undefined (with noUncheckedIndexedAccess)

Know how pg converts PostgreSQL types to JavaScript:

PostgreSQLJavaScript from pgWhy
integer, real, double precisionnumberFits safely
bigint, numericstringCan exceed JavaScript's safe integer range or lose decimal precision
timestamptz, dateDate
booleanboolean
json, jsonbparsed object
text[] and other arraysarrays

The bigint surprise catches everyone: SELECT count(*) returns bigint, so rows[0].count is "42", a string. Cast in SQL (count(*)::int) or convert explicitly. For money, store integer cents in an integer or bigint column, never floats.

Map rows to your own domain types at the edge of the data layer, so snake_case columns and driver quirks don't leak into the rest of the app:

ts
export type Order = { id: number; customerEmail: string; status: OrderRow['status']; totalCents: number; createdAt: Date }

const toOrder = (r: OrderRow): Order => ({
  id: r.id,
  customerEmail: r.customer_email,
  status: r.status,
  totalCents: Number(r.total_cents),
  createdAt: r.created_at,
})

Migrations

The schema changes over time, and every environment (your laptop, CI, production) must get the same changes in the same order. Migrations are numbered SQL files applied once each, recorded in a table:

sql
-- migrations/001_create_orders.sql
CREATE TABLE orders (
  id             integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  customer_email text        NOT NULL,
  status         text        NOT NULL DEFAULT 'pending'
                             CHECK (status IN ('pending', 'paid', 'shipped', 'cancelled')),
  total_cents    integer     NOT NULL CHECK (total_cents >= 0),
  created_at     timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE order_items (
  order_id   integer NOT NULL REFERENCES orders (id) ON DELETE CASCADE,
  sku        text    NOT NULL,
  quantity   integer NOT NULL CHECK (quantity > 0),
  unit_cents integer NOT NULL CHECK (unit_cents >= 0),
  PRIMARY KEY (order_id, sku)
);

CREATE INDEX orders_customer_email_idx ON orders (customer_email);

A small runner applies pending files at startup, each in its own transaction:

ts
// src/migrate.ts
import { readdir, readFile } from 'node:fs/promises'
import path from 'node:path'
import type pg from 'pg'

export async function migrate(pool: pg.Pool, dir = path.join(import.meta.dirname, '..', 'migrations')) {
  const client = await pool.connect()
  try {
    await client.query('SELECT pg_advisory_lock(727272)') // one migrator at a time across instances
    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<{ name: string }>('SELECT name FROM schema_migrations')).rows.map((r) => r.name))
    const files = (await readdir(dir)).filter((f) => f.endsWith('.sql')).sort()

    for (const file of files) {
      if (done.has(file)) continue
      const sql = await readFile(path.join(dir, file), 'utf8')
      await client.query('BEGIN')
      try {
        await client.query(sql)
        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 new Error(`migration ${file} failed`, { cause: err })
      }
    }
  } finally {
    await client.query('SELECT pg_advisory_unlock(727272)')
    client.release()
  }
}

Rules that keep migrations safe: never edit a migration that has run anywhere (add a new one instead), and make changes in backwards-compatible steps when old and new code run side by side during a deploy (add a column as nullable, backfill, then add the constraint). Tools such as node-pg-migrate, Drizzle Kit or Prisma Migrate do the same job with more features.

Transactions

A transaction makes several statements succeed or fail together. With a pool, every statement in a transaction must run on the same connection, so check one out with pool.connect():

ts
// src/db.ts (continued)
export async function withTransaction<T>(fn: (client: pg.PoolClient) => Promise<T>): Promise<T> {
  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() // always return the connection, or the pool slowly runs dry
  }
}
ts
const order = await withTransaction(async (tx) => {
  const { rows } = await tx.query<{ id: number }>(
    'INSERT INTO orders (customer_email, total_cents) VALUES ($1, $2) RETURNING id',
    [input.customerEmail, totalCents],
  )
  const orderId = rows[0]!.id
  for (const item of input.items) {
    await tx.query('INSERT INTO order_items (order_id, sku, quantity, unit_cents) VALUES ($1, $2, $3, $4)', [
      orderId, item.sku, item.quantity, prices[item.sku],
    ])
  }
  return orderId
})

If any insert fails, ROLLBACK removes the order too: no half-written orders. The most common pool bug is a code path that checks out a client and never releases it; after max such requests, every query waits forever. Always release in finally, or use a helper like this one.

A repository layer

Keep SQL in one module per feature, exposing plain functions with domain types. Routes and services then never see SQL or rows:

ts
// src/orders/repository.ts
import { pool } from '../db.ts'

export async function findOrder(id: number): Promise<Order | undefined> {
  const { rows } = await pool.query<OrderRow>('SELECT * FROM orders WHERE id = $1', [id])
  return rows[0] && toOrder(rows[0])
}

export async function listOrders(opts: { status?: Order['status']; limit: number; afterId?: number }): Promise<Order[]> {
  const { rows } = await pool.query<OrderRow>(
    `SELECT * FROM orders
     WHERE ($1::text IS NULL OR status = $1)
       AND ($2::int IS NULL OR id > $2)
     ORDER BY id
     LIMIT $3`,
    [opts.status ?? null, opts.afterId ?? null, opts.limit],
  )
  return rows.map(toOrder)
}

listOrders uses keyset pagination (id > last seen id) rather than OFFSET, which stays fast on large tables, and optional filters written as $1 IS NULL OR ... so one query handles every combination.

Writing SQL by hand gives you full control and no surprises. Query builders and ORMs trade some of that for convenience and type inference:

ToolStyleNotes
pg with SQLRaw SQLFull control; you write row types yourself
KyselyType-safe query builderSQL-shaped API, types inferred from your schema
DrizzleQuery builder plus schema in TypeScriptMigrations generated from the schema
PrismaORM with its own schema languageMost abstraction; generated client

All of them sit on the same ideas: pools, parameters, transactions and migrations.

Practice

Back the orders API with PostgreSQL:

  1. Write the migration above plus the runner, and run migrations at startup.
  2. Implement createOrder(input) in a transaction (order plus items, with prices from a fixed PRICES map; an unknown SKU throws a validation error before anything is written), findOrder(id) returning the order with its items, and listOrders with keyset pagination.
  3. Add GET /orders/:id, GET /orders?status=&after=&limit=, POST /orders routes using the schemas from the previous lesson.
  4. Prove the transaction works: make inserting the second item fail (for example, the same SKU twice violates the primary key) and check no order row remains.
  5. Prove the pool releases connections: create a pool with max: 2, run 20 createOrder calls concurrently, and check they all succeed.
Show solution
ts
// src/orders/repository.ts
import { pool, withTransaction } from '../db.ts'

export const PRICES: Record<string, number> = { 'PEN-01': 120, 'BAG-01': 3500, 'NOTE-01': 350 }

export type OrderStatus = 'pending' | 'paid' | 'shipped' | 'cancelled'
type OrderRow = { id: number; customer_email: string; status: OrderStatus; total_cents: number; created_at: Date }
type ItemRow = { sku: string; quantity: number; unit_cents: number }

export type Order = {
  id: number
  customerEmail: string
  status: OrderStatus
  totalCents: number
  createdAt: Date
  items?: { sku: string; quantity: number; unitCents: number }[]
}

const toOrder = (r: OrderRow): Order => ({
  id: r.id, customerEmail: r.customer_email, status: r.status, totalCents: r.total_cents, createdAt: r.created_at,
})

export class UnknownSkuError extends Error {}

export async function createOrder(input: { customerEmail: string; items: { sku: string; quantity: number }[] }) {
  for (const item of input.items) if (!(item.sku in PRICES)) throw new UnknownSkuError(`unknown SKU ${item.sku}`)
  const total = input.items.reduce((sum, i) => sum + PRICES[i.sku]! * i.quantity, 0)

  return withTransaction(async (tx) => {
    const { rows } = await tx.query<{ id: number }>(
      'INSERT INTO orders (customer_email, total_cents) VALUES ($1, $2) RETURNING id',
      [input.customerEmail, total],
    )
    const orderId = rows[0]!.id
    for (const item of input.items) {
      await tx.query('INSERT INTO order_items (order_id, sku, quantity, unit_cents) VALUES ($1, $2, $3, $4)', [
        orderId, item.sku, item.quantity, PRICES[item.sku],
      ])
    }
    return orderId
  })
}

export async function findOrder(id: number): Promise<Order | undefined> {
  const { rows } = await pool.query<OrderRow>('SELECT * FROM orders WHERE id = $1', [id])
  if (!rows[0]) return undefined
  const items = await pool.query<ItemRow>('SELECT sku, quantity, unit_cents FROM order_items WHERE order_id = $1 ORDER BY sku', [id])
  return { ...toOrder(rows[0]), items: items.rows.map((i) => ({ sku: i.sku, quantity: i.quantity, unitCents: i.unit_cents })) }
}

export async function listOrders(opts: { status?: OrderStatus; limit: number; afterId?: number }) {
  const { rows } = await pool.query<OrderRow>(
    `SELECT * FROM orders WHERE ($1::text IS NULL OR status = $1) AND ($2::int IS NULL OR id > $2) ORDER BY id LIMIT $3`,
    [opts.status ?? null, opts.afterId ?? null, opts.limit],
  )
  const items = rows.map(toOrder)
  return { items, nextAfter: items.length === opts.limit ? items.at(-1)!.id : null }
}
ts
// check-db.ts
import { pool } from './src/db.ts'
import { migrate } from './src/migrate.ts'
import { createOrder, findOrder, listOrders } from './src/orders/repository.ts'

await migrate(pool)

const id = await createOrder({ customerEmail: '[email protected]', items: [{ sku: 'PEN-01', quantity: 3 }, { sku: 'BAG-01', quantity: 1 }] })
console.log(await findOrder(id))

const before = (await pool.query('SELECT count(*)::int AS n FROM orders')).rows[0].n
try {
  await createOrder({ customerEmail: '[email protected]', items: [{ sku: 'PEN-01', quantity: 1 }, { sku: 'PEN-01', quantity: 2 }] })
} catch (err) {
  console.log('failed as expected:', (err as Error).message)
}
const after = (await pool.query('SELECT count(*)::int AS n FROM orders')).rows[0].n
console.log({ before, after }) // equal: the order insert was rolled back

const results = await Promise.all(
  Array.from({ length: 20 }, (_, i) => createOrder({ customerEmail: `c${i}@example.com`, items: [{ sku: 'NOTE-01', quantity: 1 }] })),
)
console.log(`${results.length} concurrent orders created`)
console.log((await listOrders({ limit: 5 })).nextAfter)
await pool.end()
text
applied 001_create_orders.sql
{ id: 1, customerEmail: '[email protected]', status: 'pending', totalCents: 3860, createdAt: 2026-11-01T..., items: [ { sku: 'BAG-01', quantity: 1, unitCents: 3500 }, { sku: 'PEN-01', quantity: 3, unitCents: 120 } ] }
failed as expected: duplicate key value violates unique constraint "order_items_pkey"
{ before: 1, after: 1 }
20 concurrent orders created
6

Why is the fifth order's ID 6 and not 5? The failed order's INSERT took ID 2 before the transaction rolled back, and identity (sequence) values are never handed back, even on rollback. That's deliberate: it keeps ID generation fast under concurrency. So IDs are unique and increasing, but not gapless: never use them to count rows or as invoice numbers that must be consecutive.

With max: 2, the 20 concurrent createOrder calls queue for the two connections and all complete, because withTransaction releases each connection in finally. Remove that release() and the third call waits forever (until connectionTimeoutMillis fails it), which is exactly the leak to look for when an API mysteriously stops answering. findOrder makes two queries; fetching items for a list of orders that way would be an N+1 problem, solved by one query with WHERE order_id = ANY($1).

Key takeaways

  • Share one pg.Pool per process, keep it small, and handle the pool's error event.
  • Always pass values as $1, $2 parameters; allowlist identifiers such as column names that can't be parameters.
  • Row type arguments are claims; know that bigint and numeric arrive as strings, and map rows to domain types in the data layer.
  • Apply numbered SQL migrations in order, once each, under a lock, and never edit one that has run.
  • Run transactions on one checked-out client, roll back on error, and always release the client in finally.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.Why does SELECT count(*) FROM orders return "42" (a string) through pg?

  2. 2.Inside withTransaction(async (tx) => ...), you call pool.query('INSERT ...'). What's the problem?

  3. 3.After a deploy, the API gradually stops responding, and the logs show queries waiting for a connection. What's the most likely cause?

  4. 4.A migration has already run in production and contains a mistake. What should you do?

Finished reading?

Mark it complete to fill in your roadmap.