A pure-TypeScript Postgres driver that speaks the wire protocol directly —
no libpq, no native addons, no runtime FFI. It runs unchanged on
Node.js, Bun, and Perry,
where the same TypeScript source is ahead-of-time compiled to a native
binary via LLVM — giving you a real standalone executable that talks to
Postgres with no JS runtime attached.
Perry is a TypeScript-to-native compiler: it lowers a strict subset of TS through LLVM into a statically-linked binary.
@perry/postgresis the reference driver used by Tusk (the Perry-native Postgres GUI), and the showcase for Perry's systems capabilities — every socket read, TLS handshake, and crypto op goes through perry-stdlib rather than a Rust or C shim.
bun add @perry/postgres
npm install @perry/postgres
pnpm add @perry/postgresimport { connect, sql } from '@perry/postgres';
const conn = await connect('postgres://alice:secret@db.example.com:5432/myapp');
const { rows } = await conn.query<{ id: number; name: string }>(
sql`SELECT id, name FROM users WHERE active = ${true}`
);
for (const user of rows) console.log(user.id, user.name);
await conn.close();Most Node drivers wrap libpq or ship a platform-specific .node addon.
That's a non-starter for two use cases we care about:
-
Compiling to a native binary with Perry. Perry produces a statically-linked executable via LLVM; there is no Node runtime at execution time, so any driver that assumes V8 / N-API /
require('pg-native')is unusable.@perry/postgresuses only APIs that exist on both Perry's stdlib and Node core (Buffer,net.Socket,crypto.*,tls.connect), so the same TypeScript source runs on a JS runtime and compiles to a native binary. -
Driving a GUI (Tusk). ORMs lose column metadata and coerce types for ergonomics. A database client has to render
numeric(30,8)exactly, surfaceattnum/tableOidso users can edit-in-place, and exposeNOTICE,ParameterStatus, backend PIDs, and structuredErrorResponsefields. This driver returns raw rows plus full column descriptors, and never silently coerces.
- Wire protocol v3 — Postgres 13 through 17 in CI; the protocol itself has been stable since 7.4.
- Authentication — SCRAM-SHA-256, MD5, cleartext, trust.
- TLS —
sslmode=disable | require | verify-ca | verify-full, mid-stream upgrade handled transparently on both Perry and Node. - Simple and extended query —
Query,Parse/Bind/Execute/Sync, portals,Describe. - 20 type codecs — integers, floats,
numeric(precision-preservingDecimal), booleans, text family,bytea,uuid,json,jsonb, the full date/time family with microsecond precision, and 1-d arrays of any of the above — both text and binary formats. - Structured
PgError— every documentedErrorResponsefield: SQLSTATE, position, detail, hint, schema/table/column/constraint. - Cancel protocol — fresh socket + PID/secret handshake; the connection remains reusable afterwards.
- Events —
NOTICE,ParameterStatus,LISTEN/NOTIFY. - Connection pool, transactions, and a
sqltagged-template helper. - libpq URLs and
PG*env vars —DATABASE_URL,PGHOST,PGPORT,PGUSER,PGPASSWORD,PGDATABASE,PGSSLMODE,PGAPPNAME,PGCONNECT_TIMEOUTall work out of the box. - Zero native dependencies. Pure TypeScript over
Buffer,node:net,node:tls, andnode:crypto. Nothing to rebuild per platform. - No lossy
numericcoercion. Values land in aDecimalwrapper that round-trips exact string form — none of the9999999999.99 → 9999999999.989999drift common to float-backed drivers.
| Runtime | Status | Notes |
|---|---|---|
| Perry (AOT → native) ≥ 0.5.24 | supported | Same source, compiled via LLVM. TLS upgrade uses socket.upgradeToTLS from perry-stdlib. No JS runtime at execution time. Every built-in codec round-trips correctly (verified end-to-end against real Postgres via examples/perry-smoke-codecs.ts). |
| Node.js ≥ 22 | supported | Uses node:net, node:tls, node:crypto, Buffer. |
| Bun ≥ 1.3 | supported | Fully works except a known Bun bug in tls.connect({socket}) for in-place upgrade; the corresponding tests run on Node via npm run test:tls:node. |
The only API divergence across these runtimes is the mid-stream TLS upgrade.
It's isolated in src/transport/upgrade-tls.ts
— a ~15-line feature-detect. Nothing else in the driver knows or cares
which runtime it's on.
import { connect } from '@perry/postgres';
// 1. libpq-format URL.
const conn = await connect('postgres://user:pw@host:5432/db?sslmode=verify-full');
// 2. Explicit options.
const conn = await connect({
host: 'localhost',
port: 5432,
user: 'alice',
database: 'myapp',
password: 'secret',
ssl: { mode: 'verify-full' },
applicationName: 'tusk',
connectTimeoutMs: 10_000,
});
// 3. URL with targeted overrides.
const conn = await connect({
url: process.env.DATABASE_URL!,
password: process.env.DB_PASSWORD, // overrides the URL's password
});
// 4. Bare options + env vars.
// PGHOST / PGPORT / PGUSER / PGPASSWORD / PGDATABASE /
// PGSSLMODE / PGAPPNAME / PGCONNECT_TIMEOUT fill in missing fields.
const conn = await connect({ user: 'alice' });// Simple query — any DDL / SET / multi-statement text.
await conn.query('CREATE TEMP TABLE t(id int, name text)');
// Parameterized — extended protocol.
const r = await conn.query<{ id: number; name: string | null }>(
'SELECT id, name FROM users WHERE id = $1',
[42]
);
// Tagged template — the typical app pattern.
import { sql } from '@perry/postgres';
const id = 42;
const r = await conn.query(sql`
SELECT id, name FROM users WHERE id = ${id}
`);
// Composable fragments.
const where = active ? sql`WHERE active` : sql``;
const r = await conn.query(sql`SELECT * FROM users ${where} LIMIT ${10}`);
// Dynamic identifiers. ONLY for caller-controlled values — never user input.
import { raw } from '@perry/postgres';
await conn.query(sql`SELECT * FROM ${raw(tableName)} WHERE id = ${id}`);const r = await conn.query('SELECT id, name FROM users');
r.rows; // [{ id: 1, name: 'alice' }, ...] ← decoded objects
r.rowsArray; // [[1, 'alice'], ...] ← decoded positional
r.rowsRaw; // [[<Buffer>, <Buffer>], ...] ← raw wire bytes (GUI-grade)
r.fields; // [{ name, typeOid, formatCode, tableOid, attnum, typmod, ... }]
r.command; // 'SELECT 2'
r.rowCount; // 2r.rows is the obvious shape for application code. r.rowsRaw is what
Tusk's grid uses when it wants to render bytes byte-for-byte without a
round-trip through Buffer.
| Postgres type | TypeScript value |
|---|---|
int2, int4 |
number |
int8 |
bigint — always. int8 exceeds Number.MAX_SAFE_INTEGER. |
float4, float8 |
number — NaN, Infinity, -Infinity all round-trip. |
numeric |
Decimal — string-backed; .toString(), .toNumber(). Exact. |
bool |
boolean |
text, varchar, bpchar, name |
string |
bytea |
Buffer — both hex and legacy octal text formats decoded. |
uuid |
canonical lowercase string with dashes |
json, jsonb |
parsed JS value |
date, time, timetz, timestamp, timestamptz, interval |
typed objects with .toString(), .toDate(), microsecond fields |
| 1-d arrays of any of the above | Array<T> with null for SQL NULLs |
Perry-native caveat: wrapper types (Decimal, PgDate, PgTime,
PgTimestamp, PgInterval) decode correctly but String(value) /
value.toString() currently doesn't dispatch to the wrapper's
overridden method on Perry. Read the underlying field directly
(v.value for PgDate, v.raw on PgTime / PgTimestamp /
PgInterval, v._s for Decimal) — that's what
examples/perry-smoke-codecs.ts does. On Node and Bun, toString()
works as expected.
import { Decimal } from '@perry/postgres';
const r = await conn.query('SELECT $1::numeric', ['99999999999999.99']);
r.rows[0]['?column?'] instanceof Decimal; // true
String(r.rows[0]['?column?']); // '99999999999999.99' — exactconst orderId = await conn.transaction(async (tx) => {
await tx.query(sql`INSERT INTO orders (user_id) VALUES (${userId})`);
const r = await tx.query(sql`SELECT currval('orders_id_seq') AS id`);
return r.rows[0].id;
});
// COMMIT on resolve, ROLLBACK on throw.import { createPool } from '@perry/postgres';
const pool = createPool({
url: process.env.DATABASE_URL,
max: 20, // default 10
idleTimeoutMs: 30_000, // close idle connections after 30s
acquireTimeoutMs: 30_000, // wait at most 30s for a slot when full
});
// Acquire + query + release in one call.
const r = await pool.query(sql`SELECT now()`);
// Multi-statement — borrow the connection yourself.
await pool.withConnection(async (conn) => {
await conn.query('SET search_path TO app');
return conn.query('SELECT * FROM widgets');
});
// Pooled transaction.
await pool.transaction(async (tx) => {
await tx.query(sql`UPDATE accounts SET balance = balance - ${amt} WHERE id = ${from}`);
await tx.query(sql`UPDATE accounts SET balance = balance + ${amt} WHERE id = ${to}`);
});
pool.size(); // { total, idle, waiting }
await pool.end();const long = conn.query('SELECT pg_sleep(60)');
setTimeout(() => conn.cancel(), 1000);
try {
await long;
} catch (e) {
// PgError with code '57014' (query_canceled).
console.error(e.code, e.message);
}
// `conn` is reusable — ReadyForQuery restored.import { PgError } from '@perry/postgres';
try {
await conn.query('SELECT * FROM nope');
} catch (e) {
if (e instanceof PgError) {
e.code; // '42P01'
e.severity; // 'ERROR'
e.message; // 'relation "nope" does not exist'
e.position; // '15'
e.hint; // ...
e.detail; // ...
e.schema; // table / column / constraint fields, etc.
}
}conn.on('notice', (n) => console.warn(n.severity, n.message));
conn.on('parameter', (key, value) => console.log('PG set', key, '=', value));
await conn.query('LISTEN job_done');
conn.on('notification', (n) => {
console.log('NOTIFY received on', n.channel, ':', n.payload);
});import { registerType } from '@perry/postgres';
registerType<{ x: number; y: number }>(POINT_OID, {
oid: POINT_OID,
name: 'point',
text: {
decode(buf) {
const [x, y] = buf.toString().slice(1, -1).split(',').map(Number);
return { x: x, y: y };
},
encode(v) {
return Buffer.from(`(${v.x},${v.y})`);
},
},
});src/
├── protocol/ wire framing + message writer/reader
├── auth/ SCRAM-SHA-256, MD5, cleartext
├── transport/ net.Socket wrapper + Perry-vs-Node TLS upgrade
├── types/ OID → codec registry, 20 built-in codecs
├── error.ts structured PgError
├── notice.ts NoticeResponse parsing
├── cancel.ts fresh-socket CancelRequest
├── url.ts libpq connection-string parser
├── env.ts PG* environment-variable resolver
├── sql.ts `sql` tagged template + `raw()` escape hatch
├── pool.ts Connection pool
├── register-defaults.ts registers every built-in codec; called once from connect()
├── connection.ts Connection: lifecycle, simple + extended query
└── index.ts public barrel exports
Every source file respects the subset Perry's compiler can lower to LLVM:
- No
?.or??— use explicitif (x === undefined)branching. - No
obj[variable]dynamic key access. - No
for...ofover arrays — usefor (let i = 0; i < arr.length; i++). - No regex —
indexOf/ char-code checks. - No
{ key }shorthand — write{ key: key }. - No capturing
this.methodin closures — module-levelMap<id, State>holds connection state, with named module-level handlers.
These read like quirks in a JS runtime, but they are what makes the same source compile to a single-binary native executable on Perry.
- PerryTS/perry — the TypeScript-to-native compiler (LLVM backend) and runtime / stdlib.
- TuskQuery — Tusk, the Perry-native Postgres GUI that consumes this driver.
Numbers from bench/run-all.sh against a local Postgres 16 (127.0.0.1:55432,
unix socket loopback, no SSH tunnel — RTT removed from the picture).
50 timed iterations + 5 warmups per workload. p50 wall time:
| workload | @perry/postgres node | @perry/postgres bun | @perry/postgres perry-native | pg (node) | postgres.js (node) | tokio-postgres (rust) |
|---|---|---|---|---|---|---|
SELECT 1 |
64µs | 87µs | 3 ms | 104µs | 58µs | 94µs |
| param 1-row | 97µs | 107µs | 3 ms | 152µs | 125µs | 101µs |
| 1000 × 20 | 3.5 ms | 3.4 ms | 42 ms | 2.5 ms | 2.9 ms | 2.8 ms |
| 10000 × 20 | 35.7 ms | 34.2 ms | 764 ms | 20.4 ms | 27.7 ms | 26.6 ms |
Notes:
-
pgis fastest by a meaningful margin because it returnsint8/numeric/date/time/timestampas raw strings unless the caller opts into a parser — nobigint, noDecimal, noPgDateper cell. Most of our remaining gap is the wrapper-allocation cost on those types. We chose to wrap by default because the GUI use case (Tusk) needs them typed; if you don't, those wrappers are pure overhead — passparseTypes: 'minimal'toconnect()to opt out and get the same string-as-default shapepguses (~5% off our default on mixed workloads, ~20% off on result sets dominated by int8 / numeric / date columns):const conn = await connect({ host: 'db.example.com', user: 'app', database: 'myapp', parseTypes: 'minimal', // int8/numeric/date/time/timestamp/interval → string });
-
postgres.jssits between us andpg; same reason it's slower thanpg(some parsing) and same reason it's faster than us (less parsing). We're within ~1.1–1.3× of it on bulk results — typically faster on Bun, slightly slower on Node. -
tokio-postgres(Rust release build withrust_decimal) is in the same range aspostgres.json bulk results: 26.7 ms on 10000×20 vs our 34.9 ms (Node) / 32.5 ms (Bun). The bench reads every cell into an ownedi64/String/Decimal/boolso the lazyrow.get<T>path doesn't make Rust look unfairly fast. On tiny queries Rust wins by a wide margin (35µs vs ~60µs) because there's no V8 JIT warm-up tax. Bulk decoding, where most consumers live, is dominated by the codec choices — language matters less than what you parse into. -
Perry-native has a constant ~3 ms per query overhead vs ~100µs on the JS hosts — that's the AOT runtime's promise / async / FFI per-call cost rather than anything driver-level. Bulk decode is currently ~10–20× slower than the JS hosts on bulk results (42 ms vs 3.5 ms on 1000×20; 764 ms vs 36 ms on 10000×20) — every row builds a wrapper-heavy object chain in Perry's arena GC and there's real per-cell cost there the JS JITs optimise out. Correctness landed first across PerryTS/perry #32 / #33 / #34 / #35 / #36 (module-level globals weren't registered as GC roots, so
CONN_STATESwas getting swept mid-decode). Speed is landing in increments — 0.5.29 shipped a ~14% cut on the 10k-row workload by gating the shape-clone on aGC_FLAG_SHAPE_SHAREDbit and moving the accessor-descriptorStringallocation to a lazy path. Requires Perry ≥ 0.5.29.
Reproduce: bench/run-all.sh after npm install inside bench/
(plus cargo on PATH for the Rust runner) and either pointing
PGHOST / PGPORT etc. at any Postgres or starting the local one
in the same shape (postgres -p 55432). The script captures raw
output to bench/results/all.txt and a sorted summary to
bench/results/summary.md.
bun test # everything
bun test tests/unit # pure unit tests (no DB)
bun test tests/integration # docker-compose matrix
npm run test:tls:node # TLS suite on Node (Bun has a known tls.connect bug)v0.2.0 — pre-1.0. The public surface is stable but not frozen. The driver passes the in-process mock-server matrix end-to-end. A real-Postgres Docker matrix (13 / 14 / 15 / 16 / 17) is the next milestone.
MIT.