06 · PostgreSQL with Kysely¶
Almost every real API needs durable storage, and PostgreSQL is the default choice for most new backends: relational, transactional, mature, and flexible (JSON columns, full-text search, strong constraints). This lesson connects Node to Postgres through two layers:
pg(node-postgres) — the driver. It speaks Postgres's wire protocol and manages a connection pool.- Kysely — a type-safe query builder on top of the driver. You write queries in JavaScript that map almost one-to-one onto SQL; Kysely compiles them to parameterized SQL strings.
We present Kysely because it keeps you close to SQL (so the SQL knowledge transfers) while removing string concatenation. Other common options:
| Tool | Style | Notes |
|---|---|---|
pg alone |
raw SQL strings with $1 params |
Maximum control, no abstraction |
| Knex | query builder | Long-established, JavaScript-first |
| Drizzle | SQL-like builder + schema in TS | Schema-as-code, TS-first |
| Prisma | ORM with its own schema file and generated client | High-level, strong tooling, less SQL control |
| TypeORM / Sequelize / MikroORM | class-based ORMs | Entity classes, unit-of-work patterns |
You need a PostgreSQL server to run this against. Install it locally or run it in a
container (docker run -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres). The outputs
below were produced by running the same Kysely code against PGlite, a WebAssembly
build of Postgres that runs inside Node — handy for tests, and used for the Level 2
project's test suite.
Connecting¶
import { Kysely, PostgresDialect } from 'kysely';
import pg from 'pg';
export function createDb({ connectionString, pool } = {}) {
return new Kysely({
dialect: new PostgresDialect({ pool: pool ?? new pg.Pool({ connectionString, max: 10 }) }),
});
}
Create one Kysely instance (one pool) per process at startup and pass it to your
routers. Call db.destroy() on shutdown to close connections.
Schema and migrations¶
Tables are created by migrations: versioned scripts applied in order, recorded in a
table so each runs once. Kysely includes a Migrator that runs migration files exporting
up (and optionally down):
import { sql } from 'kysely';
export async function up(db) {
await db.schema.createTable('users')
.addColumn('id', 'serial', c => c.primaryKey())
.addColumn('email', 'text', c => c.notNull().unique())
.addColumn('created_at', 'timestamptz', c => c.notNull().defaultTo(sql`now()`))
.execute();
await db.schema.createTable('tasks')
.addColumn('id', 'serial', c => c.primaryKey())
.addColumn('user_id', 'integer', c => c.notNull().references('users.id').onDelete('cascade'))
.addColumn('title', 'text', c => c.notNull())
.addColumn('done', 'boolean', c => c.notNull().defaultTo(false))
.execute();
await db.schema.createIndex('tasks_user_id_idx').on('tasks').column('user_id').execute();
}
export async function down(db) {
await db.schema.dropTable('tasks').execute();
await db.schema.dropTable('users').execute();
}
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { Migrator, FileMigrationProvider } from 'kysely/migration';
import { createDb } from '../src/db.js';
const db = createDb({ connectionString: process.env.DATABASE_URL });
const migrator = new Migrator({
db,
provider: new FileMigrationProvider({
fs, path, migrationFolder: path.join(import.meta.dirname, '../migrations'),
}),
});
const { error, results } = await migrator.migrateToLatest();
for (const r of results ?? []) console.log(`${r.status}: ${r.migrationName}`);
await db.destroy();
if (error) { console.error(error); process.exit(1); }
Running it the first time printed Success: 2025-01-10-create-users-and-tasks; a second
run prints nothing, because the migration is already recorded. (In the Kysely release
used here, the migration classes are imported from the kysely/migration subpath; older
releases exported them from kysely directly. If an import fails, check your version's
docs.)
Constraints (not null, unique, foreign keys) belong in the database, not only in
your validation code: they hold even when two requests race or a script bypasses the API.
Worked example: queries¶
const user = await db.insertInto('users').values({ email: 'ada@example.com' })
.returning(['id', 'email']).executeTakeFirstOrThrow();
console.log(user);
await db.insertInto('tasks').values([
{ user_id: user.id, title: 'write migration' },
{ user_id: user.id, title: 'add index', done: true },
]).execute();
const q = db.selectFrom('tasks')
.innerJoin('users', 'users.id', 'tasks.user_id')
.select(['tasks.id', 'tasks.title', 'users.email'])
.where('tasks.done', '=', false)
.orderBy('tasks.id');
console.log(q.compile().sql);
console.log(await q.execute());
const res = await db.updateTable('tasks').set({ done: true }).where('id', '=', 1).executeTakeFirst();
console.log('updated rows:', res.numUpdatedRows);
try {
await db.insertInto('users').values({ email: 'ada@example.com' }).execute();
} catch (e) { console.log('duplicate:', e.code, e.message); }
{ id: 1, email: 'ada@example.com' }
select "tasks"."id", "tasks"."title", "users"."email" from "tasks" inner join "users" on "users"."id" = "tasks"."user_id" where "tasks"."done" = $1 order by "tasks"."id"
[ { id: 1, title: 'write migration', email: 'ada@example.com' } ]
updated rows: 1n
duplicate: 23505 duplicate key value violates unique constraint "users_email_key"
Look at the compiled SQL: the value false became $1. Kysely never splices values
into SQL text; it sends them separately as parameters. That is what makes it immune to
SQL injection for values. (Identifiers — table and column names — are a different story:
never build them from user input.)
numUpdatedRows is a BigInt (1n), since row counts can exceed 2^53.
Transactions¶
When several writes must succeed or fail together, use a transaction. Everything inside
uses trx instead of db; if the callback throws, Kysely issues ROLLBACK:
await db.transaction().execute(async (trx) => {
const u = await trx.insertInto('users').values({ email: 'grace@example.com' })
.returning('id').executeTakeFirstOrThrow();
await trx.insertInto('tasks').values({ user_id: u.id, title: 'first task' }).execute();
});
Aggregates work as you would expect:
const counts = await db.selectFrom('tasks')
.select(({ fn }) => ['user_id', fn.countAll().as('n')])
.groupBy('user_id').orderBy('user_id').execute();
One gotcha: with the real pg driver, count(*) returns a Postgres bigint, which pg
gives you as a string ('2') to avoid precision loss. Convert with Number() when
you know it's small. (PGlite returned plain numbers in our run, one of the small ways an
emulated database can differ from the real one.)
Escape hatch: raw SQL¶
For queries the builder doesn't express nicely, use the sql tag — still parameterized:
import { sql } from 'kysely';
const { rows } = await sql`
select date_trunc('day', created_at) as day, count(*)::int as n
from tasks where user_id = ${userId}
group by 1 order by 1`.execute(db);
How It Actually Works¶
The pool. Opening a Postgres connection involves a TCP handshake, optional TLS, and
authentication — and each connection is a separate backend process on the server with
its own memory. So you don't connect per query. pg.Pool keeps up to max connections
open. A query borrows an idle connection, sends the query, and returns it to the pool.
If all are busy, the query waits in a queue. Transactions pin one connection for
their whole duration — which is why Kysely hands you a trx object: it's bound to that
connection, so BEGIN, your statements, and COMMIT all go over the same socket.
Pool sizing is a trade-off: total connections across all your Node processes must stay
under the server's max_connections. Ten processes with max: 20 want 200 connections.
Beyond that point, poolers like PgBouncer multiplex many clients onto fewer server
connections.
Parameters on the wire. For parameterized queries pg uses Postgres's extended
query protocol: a Parse message with the SQL containing $1, $2, a Bind message with
the values sent separately, then Execute. The server never parses values as SQL, so a
value like '; drop table users; -- is just a string.
Non-blocking I/O. Each connection is a TCP socket handled by the event loop, not the
thread pool. While a query runs on the server, your process serves other requests.
Rows arrive as bytes, and pg parses them into JavaScript values using type OIDs (int4 →
number, bool → boolean, timestamptz → Date, int8/numeric → string).
Common mistakes¶
- Creating a new pool per request — connection exhaustion within minutes.
- String-building SQL with template literals (not the
sqltag) → injection. - Forgetting
whereon update/delete. Kysely will happily update every row. - Holding a transaction open across slow work (HTTP calls) — it pins a connection and holds locks.
- N+1 queries — looping over 100 users and querying each one's tasks. Use a join or
a single
where user_id in (...). - No indexes on foreign keys you filter by (
tasks.user_id).
Exercise¶
- Add a
projectstable and aproject_idforeign key ontasksvia a new migration. Writedowntoo, and run up → down → up. - Implement
GET /projects/:id/statsreturning counts of done and open tasks in one query usingcount(*) filter (where done). - Write a transfer-style operation: move all open tasks from one project to another and
log the move in a
task_eventstable, atomically. Force an error in the middle and confirm nothing changed. - Use
EXPLAIN(via thesqltag) on the task-list query with and without theuser_idindex on a table of 100,000 rows. Compare the plans.