PostgreJS is a PostgreSQL driver for Node.js and Bun built from the wire protocol up - no libpq, no native bindings,
just TypeScript talking directly to PostgreSQL. That from-scratch design is also what makes it fast and light: every
byte on the wire is handled by code written for exactly that purpose, with a binary-first protocol, shared buffers,
and row/column decoding pipelines built to avoid unnecessary allocation, instead of generic string plumbing bolted
onto a client meant for text.
The numbers back it up. In PostgreJS's own benchmark suite - run head-to-head against
pg (node-postgres) and postgres
(postgres.js) on identical workloads - PostgreJS opens a connection up to 3x faster than postgres.js, pushes
pooled queries through up to 6.5x faster than pg, and fetches large result sets nearly 5x faster than pg
too. It's also the only one of the three Node drivers with a complete binary wire protocol across every data
type, rather than falling back to text for most of them. See doc/BENCHMARKS.md for the full
methodology and every scenario.
The same suite runs under Bun, where Bun's own built-in Bun.sql joins the comparison as a fourth client - see
doc/BENCHMARKS-bun.md.
The same benchmarks show it using a fraction of the memory: peak heap usage typically runs 3-7x lower than both pg and postgres.js - as much as 7x lower when streaming cursors or fetching large arrays - and it spends a fraction of the time either of them does in garbage collection. Shared buffers and decode paths that read values straight out of the wire buffer leave far less garbage behind per row, so there's less for the GC to clean up in the first place.
Speed and memory aside, PostgreJS is also the most complete driver of the three: a dynamic sql tag for
composable, parameterized SQL, per-query type mapping, and TC39 Explicit Resource Management (using) support are
unique to it, alongside a feature set most drivers spread across several add-on packages - connection pooling,
prepared statements, server-side cursors, LISTEN/NOTIFY, bulk COPY streams, logical replication, large objects,
two-phase commit, multi-host failover, and SCRAM channel binding - all in the one package, written in
strictly-typed TypeScript from the ground up.
PostgreJS is a driver, not a framework, so what you build on top of it stays your choice:
|
SQB @sqb/postgresnative driver |
Kysely kysely-postgrejsdialect |
Drizzle ORM drizzle-postgrejsdriver |
|
TypeORM typeorm-postgrejsdriver |
Prisma prisma-postgrejsdriver adapter |
Each adapter hands the compiled SQL straight to this client, with no pg left anywhere in the chain, and each is
compared query by query against the driver it replaces - and, where the project ships a suite of its own, run
against that too. So adopting one is a change to how the connection is created and nothing else.
$ npm install postgrejs --savePlease read πΈ DOCUMENTATION πΈ for detailed usage
Upgrading from 3.5? See doc/MIGRATION-v3.5-to-v3.6.md - query() no longer stops at
100 rows, and fetchAsString now returns the server's own text.
- Language: Pure JavaScript, with no native/binary dependencies to compile or ship.
- Strictly typed: Written entirely in TypeScript, with types shipped alongside the package.
- Modern module format: Ships as ESM; Node 20.19+/22.12+ can
require()it from CommonJS code as well. - Promise-based API: Every asynchronous operation returns a promise - no callbacks to wrangle.
- Rigorously tested: A test suite covering the wire protocol, every data type, and connection-handling edge case, run on every push against PostgreSQL 12 through 18.
How PostgreJS compares to pg (node-postgres) and
postgres (postgres.js). Every row was checked against the libraries' own
source rather than their documentation β versions compared: PostgreJS 3.4.0, pg 8.23.0, postgres.js 3.4.9. β
built
in Β· π‘ partial or needs a separate package Β· β not supported.
| Feature | PostgreJS | pg | postgres.js |
|---|---|---|---|
| Packaging | |||
| Packages to install | 1 | 4 1 | 1 |
| Module system | ESM | ESM/CJS | ESM/CJS |
| Language | TS | JS 2 | JS 3 |
| Wire protocol | |||
| Protocol version | 3.2 | 3.0 | 3.0 |
| Simple Query protocol | β | β | β |
| Extended Query protocol | β | β | β |
| Text wire format | β | β | β |
| Binary wire format | β | π‘ 4 | β 5 |
| Per-column format selection | β | β | β |
| Long cancel key (opt-in) | β | β | β |
| Legacy Function Call protocol | β | β | β |
| Graceful protocol renegotiation | β | β | β |
| High-level API | |||
| Object and array row modes | β | β | β |
| Dynamic SQL helpers | β
sql tag |
β | β |
| Per-query type mapping | β | β 6 | β 6 |
| Query cancellation | β AbortSignal | β | β |
| Per-query timeout | β AbortSignal | β | β 7 |
| Reference counters | Connection / Statement | β | β |
| Caller kept in async error stacks | β | π‘ 8 | π‘ 9 |
| Error located in the SQL text | β line and mark | π‘ offset | π‘ offset |
| TC39 Explicit Resource Management | β | β | β |
| Querying | |||
| Query parameters | β | β | β |
| Parameter type casting | β | π‘ 10 | β |
| Prepared statements 24 | β automatic | β manual | β automatic |
| Statement-level rollback 25 | β automatic | β | π‘ manual |
| Scoped transaction helper 26 | β nests | β | β |
| Batch execution 21 | β | β | β |
| Multi-statement round trip 23 | β | β | β |
| Multi-statement scripts | β | β | β |
| Server-side cursors | β | π‘ 11 | β |
COPY TO / COPY FROM |
β | π‘ 12 | β |
| Binary COPY encoding 22 | β | β | β |
| Row count after a COPY | β | β | β |
| Transaction management | |||
| Transaction API | β | β | β |
| Savepoints | β | β | β |
| Two-phase commit API | β | β | π‘ 13 |
| Session management | |||
| Built-in connection pool | β | β | β implicit |
| Pipelining on one connection | β opt-in/call | β opt-in/client | β automatic |
| Graceful shutdown | β | β 14 | β |
| Multiple hosts | β | β | β |
| LISTEN/NOTIFY | β | π‘ 15 | β |
| Data types | |||
| Text encoders | 125 | generic 16 | 14 |
| Text decoders | 125 | 44 | 12 17 |
| Binary encoders | 121 27 | β | β |
| Binary decoders | 125 | 16 | β |
| Multidimensional arrays | β binary | π‘ text 18 | π‘ text |
| Security | |||
| SSL/TLS | β | β | β |
| Direct TLS negotiation (PG17) | β | β | β |
| Cleartext, MD5, SCRAM-SHA-256 | β | β | β |
SCRAM channel binding (-PLUS) |
β default | β opt-in | β |
| Beyond querying | |||
| Logical replication | β | π‘ 19 | β |
| Large object API | β | β | β |
| Native libpq bindings | β | π‘ 20 | β |
- 1 What it takes to reach the feature set above. PostgreJS and postgres.js ship everything in the one
package you import;
pgneedspg-cursorfor cursors,pg-query-streamfor row streams andpg-copy-streamsfor COPY, each installed and versioned separately. - 2 Types come from the separate
@types/pg; only thepg-protocolandpg-connection-stringsub-packages are written in TypeScript. - 3 Ships a hand-maintained
.d.ts. - 4 Results only - parameters are always stringified. Opt-in per query or per client, and all columns at
once. No binary parser is registered for
bytea, and binary arrays decode onlyint4,int8andtextelements. - 5 Both format-code counts are hardcoded to zero and parameters are stringified, so everything on the wire is text.
- 6 Global or per-client (pg) and per-instance (postgres.js), but not per query.
- 7 Connection-level timeouts only.
- 8 Restored by calling
Error.captureStackTracefrom the promise's own rejection handler, where the synchronous stack is already gone - the caller's frames come from Node's async stack traces, so they survive an awaited chain but not a.then()/.catch()one, and the callback API gets none at all. - 9 Captured at the tagged template, four frames deep;
sql.unsafe()gets none. - 10 A
typesarray on the query config does reach the Parse message, but the same field doubles as the result parser override, so any row-returning query throws inside pg's own handler. Verified usable only for statements that return no rows (pg 8.23.0). - 11 Core has the row-limit primitive; the cursor and stream APIs are separate packages.
- 12 The core
Queryrefuses COPY IN;pg-copy-streamsis required. - 13
sql.prepare(name)runsPREPARE TRANSACTIONinsidebegin, but there is no helper for the other half -COMMIT PREPARED/ROLLBACK PREPAREDhave to be written as raw SQL. - 14
end()destroys the socket when a query is still in flight, so the query is aborted rather than awaited; only a client in pipeline mode waits for drain first. - 15 On the client only - the pool does not forward notifications.
- 16 pg has no per-OID text encoders: a parameter is converted by its JavaScript type rather than by the type it is going into, so there is no count to give.
- 17 Plus every array type, whose OIDs are read from the catalog when a connection opens rather than registered ahead of time.
- 18 Its binary array decoder covers only
int4,int8andtextelements, so everything else falls back to text anyway. - 19 A connection flag exists, but nothing decodes the stream.
- 20
pg-nativeswaps the pure JavaScript protocol for libpq, and its own documentation lists what stops working with it:pg-cursor,pg-query-streamandpg-copy-streamsall "operate directly on the binary stream and therefore are incompatible" - so server-side cursors, row streaming and COPY are what it costs. - 21 One statement executed over many parameter sets behind a single
Sync, each set's row count reported separately. pg and postgres.js both emit aSyncper execution (syncBufferin pg'sconnection.js, the concatenatedExecuteUnnamedin postgres.js's), so a burst of executions costs a server round of implicit-transaction bookkeeping each, pipelined or not. Writing one multi-row statement by hand is a separate approach that all three support, and both PostgreJS and postgres.js ship value builders for it - it is faster still where it applies, but it is one statement rather than many, and PostgreSQL's 65535-parameter ceiling bounds it. - 22 Turning JavaScript rows into the binary
COPYformat, rather than carrying a payload the caller formatted first. All three can carry aCOPYstream, but only as bytes: postgres.js hands back aWritablethat wraps raw chunks inCopyData, and pg reaches the same point throughpg-copy-streams. Producing the format needs a binary encoder per type, which neither driver has - see Binary encoders above, where the same gap shows up for query parameters. The capability is still reachable with pg through a third package,pg-copy-streams-binary, which brings its own encoders; postgres.js has no equivalent. - 23 Several different statements sent under one
Sync, so they cost a single round of the server's transaction bookkeeping rather than one each, arrive as one implicit transaction, and come back as per-statement results. Distinct from the Pipelining row above, which is about not waiting between queries: all three do that, and all three still close every statement with its ownSync- pg sends one immediately after each Execute (query.js), and postgres.js concatenates Execute and Sync into a single constant (ExecuteUnnamed). Atomicity on its own is reachable anywhere with an explicitBEGIN/COMMIT; what that cannot recover is the round trip, since the per-statement Syncs and two extra statements remain. - 24 Whether a repeated query is parsed again every time. PostgreJS and postgres.js both keep a per-connection
cache keyed on the SQL and reuse a server-side statement, so a repeat costs
Bind/Executerather thanParse/Bind/Describe/Execute; pg prepares only the statements you name yourself (query.js: "named queries must always be prepared"), with nothing caching by SQL text. The two automatic ones differ in when they start: postgres.js on first sight, PostgreJS on the second use, which leaves a genuinely one-shot query at its unprepared cost. Both default to on and both takeprepare: false, which matters for PgBouncer in transaction pooling mode before 1.21, where a named statement does not survive to the next call. - 25 Whether one failed statement inside a transaction leaves the rest of the block runnable. PostgreJS puts
every statement under a savepoint of its own by default (
rollbackOnError), rolling back to it when the statement fails, and carries theSAVEPOINT/RELEASEpair inside the statement's own round trip rather than paying a round trip for each. postgres.js has savepoints, but as a scope the caller opens around a callback (savepoint(name, fn)insrc/index.js), so a statement is protected only where someone wrapped it; pg has no savepoint handling inlib/at all, so a failed statement leaves the block aborted until the caller rolls back themselves. - 26 Whether the library runs a callback inside a transaction for you, committing or rolling back on the way
out. PostgreJS has
transaction(fn)on both a connection and the pool, and a call made inside one already open takes a savepoint, so nesting needs nothing from the caller. postgres.js hassql.begin(fn)(src/index.js), where a nested scope is opened explicitly withsql.savepoint(fn)instead. pg has no such helper inlib/-BEGINandCOMMITare statements the caller sends, and getting a transaction onto one connection is the caller's problem too. - 27 Every registered type but
tsvectorandtsquery, which have none deliberately: the server's own input parser is what sorts and deduplicates a vector and what defines a query's grammar, so encoding either here would make the same text mean one thing written as a literal and another bound as a parameter. They are sent as text instead, which is exact.
PostgreJS implements the full PostgreSQL wire protocol from scratch, with no dependency on pg/libpq.
doc/BENCHMARKS.md compares it against pg (node-postgres) and postgres (postgres.js)
across connection setup, simple/prepared queries, mixed-type decoding, bulk fetches, cursor streaming and pool
concurrency, each library run through its own idiomatic fast path. The numbers there are reproducible on your own
machine via npm run bench against the repo's own
docker/docker-compose.yml Postgres instance; see benchmark/README.md for details.
The same suite runs under Bun via npm run bench:bun, where it also measures Bun's own built-in Bun.sql client as
a fourth library. Those results live in doc/BENCHMARKS-bun.md and are deliberately kept
in a separate report rather than merged into the Node one: GC instrumentation, cursor streaming and binary-format
control all differ enough between the two runtimes that a single table would conflate a library difference with a
runtime difference.
Both reports list libraries in a fixed order rather than fastest-first, and mark every result statistically tied with the leader rather than bolding a lone winner - several scenarios separate the leading drivers by about a percent, which is less than the run-to-run spread of the measurement itself.
You can report bugs and discuss features on the GitHub issues page When you open an issue please provide version of NodeJS and PostgreSQL server.
- Node.js >= 20.x
- Bun - the same test suite runs under Bun in CI against PostgreSQL 18 on every push, so Bun is a supported target
rather than an untested coincidence. (Coverage is collected on the Node matrix only:
c8instruments V8's coverage APIs, which JavaScriptCore doesn't have.)
PostgreJS is available under the BSD 3-Clause license.