Benchmarks
Generated by npm run bench:report. Do not hand-edit — re-run the command instead.
Contents
- Methodology
- Environment
- Connection
- Simple Query
- Extended Query
- Cursor Streaming
- Connection Pooling
- Raw data
Methodology
These numbers are produced by benchmark/ (run via npm run bench), comparing PostgreJS against pg (node-postgres) and postgres (postgres.js) on the same server, the same machine, and the same workload. See benchmark/README.md for how to reproduce them.
Each scenario is implemented once per library, using that library's own idiomatic/fastest calling convention — not a shared lowest-common-denominator query(sql, params) call — while all three read the exact same SQL text, row counts, and concurrency/pool-size knobs from benchmark/scenarios/*.ts. Only the mechanism varies per library, not the workload.
Each (library, scenario) pair runs in its own child process, spawned sequentially (never in parallel), to avoid CPU/connection contention skewing numbers and to get clean, uncontaminated V8 JIT warm-up per run. The default matrix runs each pair --repeats=3 times; the tables below report the median across repeats, with intra-run p75/p99 latency and ops/sec from tinybench's own sample statistics.
Each table also reports GC (ms/op) and Peak Heap (KB) - allocation pressure, not just wall-clock speed. GC (ms/op) is the total time spent in garbage collection during the run (observed via node:perf_hooks, every GC pause regardless of cause), divided by the number of timed samples - a proxy for how much garbage a library's own decode/encode path churns through per call, independent of how much of it survives. Peak Heap (KB) is different, and isn't a per-call figure: each worker process is started with --expose-gc, forces a clean GC immediately before the run to get a baseline heapUsed, then polls heapUsed throughout the run and keeps the highest single sample - the most the heap ever grew above that baseline at any point while running the whole scenario, not just what's left over once it's done (a call that allocates a large temporary buffer and frees it before finishing would show a real spike here while still showing near-zero long-term growth). Both include tinybench's own warmup iterations (it doesn't expose a hook at the boundary between warmup and the timed run), and memory measurements are inherently noisier than latency ones - GC timing isn't deterministic and V8's heap growth isn't perfectly linear, so treat these as directional, not to the same precision as the latency columns. (A median-of-samples "typical heap" figure was tried and dropped: for I/O-bound scenarios almost all of the polled samples land during idle network wait rather than the brief allocation burst, so the median collapsed to ~0 even on runs with a real, multi-hundred-KB peak - it doesn't have a reliable per-call interpretation the way Peak Heap does.)
Two scenarios - Large Blob Fetch and Large Array Fetch - additionally report Network (KB/op): the bytes the server actually sent, per call, counted at the socket (Readable.push(), so all three libraries are measured identically rather than through any library's own accounting). It is reported only there because that is where it separates the libraries: PostgreJS reads those columns in the binary protocol while pg and postgres.js read them as text, and the same rows cost very different amounts on the wire in the two formats. A bytea costs exactly twice as much as text (\x-prefixed hex, two characters per byte), while an int4[] depends entirely on the values - binary spends a fixed 8 bytes per element (4-byte length prefix + 4-byte value) where text spends one byte per digit, so full-width int4s favour binary and values near zero favour text. Everywhere else the payload is small and near-identical across libraries, so the number would be noise rather than information.
Disclosed asymmetries
Some scenarios necessarily exercise each library differently. These are deliberate, not oversights:
- Pool concurrency — each library's own top-level entry point is called N times at a fixed concurrency with pool max size held equal (
pg.Pool's explicit connect/release, postgres.js's implicit auto-pipelined pool, PostgreJS'sPool.execute()). postgres.js's automatic pipelining is measured as a real feature, not normalized away — and postgrejs is given the same ability, but it has to ask: pipelining is opt-in per call there (pipeline: true), not the default, so this scenario passes it. The reason it is opt-in is a real trade rather than caution: PostgreSQL runs a connection's statements one at a time, so sharing a connection speeds up bursts of short queries but lets one slow query delay whatever is queued behind it. pg has no equivalent and runs one query per connection throughout, which is most of why it trails here. - Cursor streaming —
pghas no built-in cursor API; its scenario emulates one with rawDECLARE CURSOR/FETCH n/CLOSESQL viaclient.query()(nopg-cursordependency). This is an emulation, notpg's native path. - Prepared-statement reuse — postgres.js auto-prepares/caches transparently,
pguses a named statement, PostgreJS uses explicitprepare()/execute()/close(). Same SQL text and iteration count across all three; the three different mechanisms are shown side by side rather than forced into one shape. - Type decoding — no custom type parsers/overrides for any library; each uses its own default config (e.g.
pgreturnsint8as a string by default, postgres.js asBigInt). This is the fairest and most representative choice; differing default row shapes are an interpretation footnote, not something to fix. - Row shape — PostgreJS's
objectRowsoption is explicitly settruein every scenario so its output shape (object rows) matchespg's and postgres.js's defaults; otherwise postgrejs would gain an artificial edge from skipping key-mapping work the other two always do. This is the one deliberate normalization, called out as such. - Transport — all three connect via TCP to the same Postgres instance (no Unix-socket path is exercised).
pg-nativeis out of scope for v1 — it requires a system libpq + native compilation, not guaranteed on CI/contributor machines. It can be added later behind an opt-in--lib=pg-nativeflag without ever being in the default matrix.pg's wire pipelining —pg8.23+ added an opt-inpipeline: trueclient option (send multiple queries without waiting for each one's response before writing the next), off by default. Every*-concurrentscenario here fires N queries viaPromise.all()without awaiting each individually - exactly the pattern this flag is for - so it's enabled forpg's client here; leaving it off would benchmark its serialized fallback path instead of its real concurrent capability, understating it the same way testing PostgreJS/postgres.js without their own pipelining would. It's a no-op for every sequential (always-awaited-one-at-a-time) scenario.- Sequential Execution and Concurrent Execution (Simple Query) run 9 repeats, not the usual 3 — a single round trip here costs well under half a millisecond, small enough that one cold first-run in a fresh child process (a page fault, a scheduling hiccup) can swing a 3-repeat median by ten percent or more in either direction, as happened while chasing this exact scenario down: three repeats alone flipped which library came out ahead from one invocation to the next. Nine repeats absorbs that without pretending the noise isn't there.
- Sequential Execution's warmup is 800 iterations, not the usual 50 — the actual root cause behind the point above: at 50 warmup iterations, PostgreJS's own call graph (more, smaller functions across more files than pg's more monolithic one) wasn't consistently reaching V8's fully-optimized tier before the timed window started, so some repeats measured a partially-JIT-warmed run and others didn't - the same code, genuinely different measured speed, not noise in the usual sense. Fully warming it first (verified with up to 2000 warmup iterations, where PostgreJS won every single repeat) removes that variable; 800 was the smallest budget that still did, applied to both libraries equally.
Environment
- Run date: 2026-09-07T11:33:19.419Z
- Node.js: v24.15.0
- OS: Darwin 25.6.0 (darwin/arm64)
- CPU: Apple M1 Pro (10 logical cores)
- RAM: 16.0 GB total
- PostgreSQL: PostgreSQL 18.4 on aarch64-unknown-linux-musl, compiled by gcc (Alpine 15.2.0) 15.2.0, 64-bit
- Library versions (installed, not this repo's semver range): PostgreJS 3.0.1, pg 8.23.0, postgres 3.4.9
Connection
The fixed cost of opening (and closing) a connection - the TCP handshake, PostgreSQL startup/auth handshake, and ReadyForQuery, paid once per connection lifetime rather than once per query.
Connect
Open a fresh connection/session and close it, repeated per sample
| Library | Mean (ms) | p75 (ms) | p99 (ms) | ops/sec | vs. slowest | GC (ms/op) | Peak Heap (KB) |
|---|---|---|---|---|---|---|---|
| PostgreJS (3.0.1) | 3.709 | 3.812 | 5.384 | 271.4 | 3.64x | 0.0266 | 4167.30 |
| pg (node-postgres) (8.23.0) | 5.659 | 5.707 | 8.812 | 180.1 | 2.39x | 0.0352 | 2354.81 |
| postgres (postgres.js) (3.4.9) | 13.511 | 14.113 | 17.176 | 74.5 | 1.00x | 0.2614 | 8525.42 |
Simple Query
PostgreSQL's Simple Query sub-protocol: the client sends the SQL text as a single Query (Q) message and the server parses, plans, executes, and streams the results back in that same round trip - no separate parse/bind/describe/execute/sync steps, and no query parameters. In postgrejs this is Connection.execute(sql). It's the cheapest way to run a query the client isn't going to reuse, which is why it's also the baseline every other protocol group below is compared against. It's distinct from the Extended Query group below: Extended Query trades this single round trip for several (parse, bind, describe, execute, sync) in exchange for bind parameters and a statement the server can plan once and re-execute. The scenarios here measure that Simple Query round trip three ways: a single query on an otherwise-idle connection, many queries fired concurrently over one connection, and one query that fetches many rows.
Sequential Execution
Run select 1 one call at a time on an already-open connection.
Each call is fully awaited before the next one starts.
Every library uses its genuine Simple Query path: PostgreJS's execute(), pg's query(text) with no params, postgres.js's unsafe() with no args - always a real Simple Query message, regardless of any prepare option.
This is the baseline for the group: one round trip's plain cost, with no concurrency, no pooling, and no bind parameters. Compare it against Concurrent Execution below to see what overlapping calls on the same connection buys each library.
| Library | Mean (ms) | p75 (ms) | p99 (ms) | ops/sec | vs. slowest | GC (ms/op) | Peak Heap (KB) |
|---|---|---|---|---|---|---|---|
| PostgreJS (3.0.1) | 0.392 | 0.404 | 0.713 | 2619.4 | 1.12x | 0.0021 | 5342.23 |
| pg (node-postgres) (8.23.0) | 0.403 | 0.414 | 0.732 | 2581.8 | 1.09x | 0.0043 | 9748.70 |
| postgres (postgres.js) (3.4.9) | 0.438 | 0.453 | 0.928 | 2403.0 | 1.00x | 0.0045 | 9081.62 |
Concurrent Execution
The concurrent counterpart to Sequential Execution above. Fire 50 Simple Query calls on the SAME already-open connection without awaiting each one individually, then await them all via Promise.all().
PostgreJS pipelines these at the wire level: it writes each message without waiting for the previous one's response, then matches responses back in FIFO order, so results never cross-talk even though the caller never awaited between calls.
Each call selects a distinct literal and checks the result against it, so this scenario verifies correctness too - do the other libraries' single connections queue/pipeline correctly? - not just timing. (concurrency=50)
| Library | Mean (ms) | p75 (ms) | p99 (ms) | ops/sec | vs. slowest | GC (ms/op) | Peak Heap (KB) |
|---|---|---|---|---|---|---|---|
| PostgreJS (3.0.1) | 1.992 | 2.085 | 2.432 | 506.2 | 1.15x | 0.0161 | 9151.12 |
| pg (node-postgres) (8.23.0) | 2.220 | 2.330 | 2.720 | 453.2 | 1.03x | 0.0193 | 19789.27 |
| postgres (postgres.js) (3.4.9) | 2.291 | 2.426 | 3.083 | 442.5 | 1.00x | 0.0417 | 29656.68 |
Simple Query Fetch
Fetch 1000 mixed-type rows via each library's Simple Query path - the same protocol as Sequential Execution and Concurrent Execution above - on a single already-open connection, no pool. This measures row-fetch throughput without the parse/bind/describe overhead of the Extended Query protocol.
Excludes int8: pg, postgres.js and PostgreJS return it as genuinely different JS types by default (string, BigInt, number-or-BigInt). Timing that column would measure type-conversion choice, not decode speed. (rowTarget=1000)
| Library | Mean (ms) | p75 (ms) | p99 (ms) | ops/sec | vs. slowest | GC (ms/op) | Peak Heap (KB) |
|---|---|---|---|---|---|---|---|
| PostgreJS (3.0.1) | 2.443 | 2.452 | 3.709 | 418.4 | 1.45x | 0.0505 | 8943.48 |
| postgres (postgres.js) (3.4.9) | 3.082 | 3.063 | 7.463 | 339.2 | 1.15x | 0.2177 | 53278.96 |
| pg (node-postgres) (8.23.0) | 3.538 | 3.571 | 6.858 | 292.3 | 1.00x | 0.2772 | 62945.73 |
Extended Query
PostgreSQL's Extended Query sub-protocol: the client splits a query into separate Parse, Bind, Describe, Execute, and Sync messages instead of Simple Query's single Query message - more wire round trips per query, but it is what makes bind parameters, typed result columns, and a statement the server plans once and can re-execute possible at all (none of that exists in Simple Query). In postgrejs, Connection.query(sql) always goes through this path, and Connection.prepare(sql) additionally gives back a reusable PreparedStatement handle instead of re-sending Parse on every call. The scenarios here mirror the Simple Query group's own Sequential/Concurrent pair - a single parameterized call one at a time, then many fired concurrently over one connection - plus decoding many mixed-type rows through query() (the row count itself a bind parameter, large enough that decode work dominates the measurement) on both the text and binary wire formats, and the cost this protocol is meant to amortize away: reusing one prepared statement across many executions, sequentially and then concurrently.
Sequential Execution
The Extended Query counterpart to the Simple Query group's Sequential Execution above: one already-open connection, one call at a time, each awaited before the next starts.
Instead of a literal, it selects an int2 value bound as a real query
parameter (select $1::int2), so every library genuinely goes through
Parse/Bind/Describe/Execute/Sync rather than a single Query message.
Unlike Prepared Statement Reuse below, there is no reused or cached server-side statement here, so this isolates the one-shot per-call cost Extended Query pays on top of the Simple Query baseline. Compare it against Concurrent Execution below to see what overlapping calls buys each library here too.
| Library | Mean (ms) | p75 (ms) | p99 (ms) | ops/sec | vs. slowest | GC (ms/op) | Peak Heap (KB) |
|---|---|---|---|---|---|---|---|
| pg (node-postgres) (8.23.0) | 0.395 | 0.406 | 0.704 | 2612.6 | 1.86x | 0.0023 | 8567.19 |
| PostgreJS (3.0.1) | 0.399 | 0.412 | 0.732 | 2576.2 | 1.84x | 0.0017 | 5242.90 |
| postgres (postgres.js) (3.4.9) | 0.735 | 0.764 | 1.141 | 1383.2 | 1.00x | 0.0034 | 7389.13 |
Concurrent Execution
The concurrent counterpart to Sequential Execution above, same shape as the Simple Query group's Concurrent Execution.
Fire 50 genuine Extended Query
calls (select $1::int2, a real bind parameter, not a reused/cached
statement) on the SAME already-open connection without awaiting each one
individually, then await them all via Promise.all(). PostgreJS pipelines
these at the wire level, so results never cross-talk even though the
caller never awaited between calls.
Each call binds a distinct value and checks the result against it, so this scenario verifies correctness too - do the other libraries' single connections queue/pipeline Extended Query calls correctly? - not just timing. (concurrency=50)
| Library | Mean (ms) | p75 (ms) | p99 (ms) | ops/sec | vs. slowest | GC (ms/op) | Peak Heap (KB) |
|---|---|---|---|---|---|---|---|
| pg (node-postgres) (8.23.0) | 2.450 | 2.564 | 3.008 | 410.6 | 15.64x | 0.0245 | 20188.13 |
| PostgreJS (3.0.1) | 2.525 | 2.636 | 3.052 | 398.2 | 15.18x | 0.0236 | 9415.44 |
| postgres (postgres.js) (3.4.9) | 38.324 | 39.289 | 47.428 | 26.2 | 1.00x | 0.2081 | 14248.95 |
Mixed-Type Decode (Text Protocol)
Fetch 1000 mixed-type rows (int2/int4/ float4/float8/varchar/json/jsonb/timestamp/timestamptz/bytea) via each library's Extended Query path - PostgreJS's query(), a one-shot parameterized call, not a reused prepared statement - and decode them to JS values. The row count is itself a real bind parameter ($1), not a literal, and large enough that column-decode work dominates the measurement rather than per-call round-trip overhead.
This is the Extended Query counterpart to Simple Query Fetch above: the raw decode cost this protocol carries per call, before Prepared Statement Reuse below measures what reusing the parsed plan saves.
Excludes int8: pg, postgres.js and PostgreJS return it as genuinely different JS types by default (string, BigInt, number-or-BigInt). Timing that column would measure type-conversion choice, not decode speed.
Forces PostgreJS onto the text protocol explicitly. Its Extended Query default is binary, per-column, unlike pg and postgres.js, which are always text here - pg's binary mode is opt-in and never requested, and postgres.js has no binary protocol support at all. Without that, this scenario would compare binary decode against text decode, not decode speed. (rowTarget=1000)
| Library | Mean (ms) | p75 (ms) | p99 (ms) | ops/sec | vs. slowest | GC (ms/op) | Peak Heap (KB) |
|---|---|---|---|---|---|---|---|
| PostgreJS (3.0.1) | 3.041 | 3.184 | 4.352 | 332.4 | 1.16x | 0.0621 | 9063.35 |
| postgres (postgres.js) (3.4.9) | 3.449 | 3.350 | 8.436 | 300.0 | 1.02x | 0.2470 | 53131.95 |
| pg (node-postgres) (8.23.0) | 3.522 | 3.564 | 7.135 | 292.9 | 1.00x | 0.2826 | 60580.98 |
Mixed-Type Decode (Binary Protocol)
The same fetch as Mixed-Type Decode (Text Protocol) above - same
1000 rows, same columns, same bind-parameter
row count - but requesting binary result format instead of text. That is
PostgreJS's own Extended Query default (DEFAULT_COLUMN_FORMAT), so this
measures its binary decode path on its own terms rather than forcing it
onto text.
postgres.js has no binary protocol support at all: its Bind message
hardcodes text format codes for every column, with no option to request
binary (verified in its own source). It isn't benchmarked here.
pg's binary parser table (pg-types) is missing exactly the column types
this scenario decodes - varchar/json/jsonb/bytea are unregistered
for binary, only a handful of numeric/date/bool types are. Requesting
binary from pg would return those columns unparsed or corrupted rather
than a comparable value, so it's excluded rather than reported as a
misleading number.
Only PostgreJS's own result is shown. (rowTarget=1000)
| Library | Mean (ms) | p75 (ms) | p99 (ms) | ops/sec | vs. slowest | GC (ms/op) | Peak Heap (KB) |
|---|---|---|---|---|---|---|---|
| PostgreJS (3.0.1) | 2.590 | 2.680 | 3.638 | 391.6 | 1.00x | 0.0815 | 15444.27 |
| pg (node-postgres) | Not Fully Supported | — | — | — | — | — | — |
| postgres (postgres.js) | Not Supported | — | — | — | — | — | — |
Large Blob Fetch
Fetch 10 rows of a 1MB bytea value each (10MB total) via each library's Extended Query path, with the row count itself a real bind parameter, not a literal. The measurement is dominated by wire-transfer and decode time for a few large values, instead of per-row/per-column overhead across many small ones - the counterpart to Mixed-Type Decode above, which is many small values.
Unlike that scenario, no protocol format is forced onto anyone here: PostgreJS is left on its own Extended Query default (binary), and pg/postgres.js get whatever their own default is.
postgres.js has no binary protocol support at all (verified elsewhere in
this report), so it always fetches as text - hex-encoded on the wire,
decoded client-side. pg's binary parser table (pg-types) has no entry
for bytea either (verified live: requesting binary format for a bytea
column returns a corrupted value, not a Buffer) - undocumented and unsafe
to rely on, so pg is left on its own text default too, same as
postgres.js.
Only PostgreJS ends up genuinely exercising a binary fetch; the other two
show their real, best-available path rather than a forced or broken one -
and pay for it. bytea's text format is \x-prefixed hex, literally 2x the
wire bytes of binary's raw bytes, so pg and postgres.js transfer twice
what PostgreJS does here. (sizeBytes=1048576, rowCount=10)
| Library | Mean (ms) | p75 (ms) | p99 (ms) | ops/sec | vs. slowest | GC (ms/op) | Peak Heap (KB) | Network (KB/op) |
|---|---|---|---|---|---|---|---|---|
| PostgreJS (3.0.1) | 51.180 | 52.259 | 55.248 | 19.6 | 2.11x | 1.1140 | 1204.38 | 12800.20 |
| postgres (postgres.js) (3.4.9) | 107.748 | 110.898 | 122.660 | 9.3 | 1.00x | 0.9831 | 1197.80 | 25600.25 |
| pg (node-postgres) (8.23.0) | 107.774 | 106.999 | 124.563 | 9.3 | 1.00x | 0.9447 | 1331.34 | 25600.23 |
Large Array Fetch
Fetch 10 rows of an int4[] with 50000 elements each, via each library's Extended Query path, with the row count itself a real bind parameter, not a literal. Elements alternate between just below int4's ceiling and just above its floor, so every value is full-width and roughly half are negative. Full-width values are what show binary format's real wire advantage: binary spends a fixed 8 bytes per int4 element, while text spends one byte per digit.
This is the array counterpart to Large Blob Fetch above (one large scalar value) and Mixed-Type Decode (many small values): here it is many elements within a single column value, repeated across rows. Element count, not byte size, is what drives cost for an array, unlike a blob - see this file's own comments for the measurement that showed why - so this is sized for a clear decode-time gap without ballooning each iteration to whole seconds.
As with Large Blob Fetch, no protocol format is forced onto anyone: PostgreJS is left on its own Extended Query default (binary).
pg's binary array decode (pg-types) is registered-but-buggy rather than
simply unsupported. It does have a registered binary parser for _int4
(unlike bytea), but that parser reads every element as an unsigned bit
pattern, with no two's-complement handling for negative values. Verified
live: requesting binary format for an int4[] containing negative numbers
returns wrong values from the first negative element onward, and desyncs
the element count entirely on a larger array - a 1500-element array came
back as 1519 elements, values wrong.
So pg is left on its own text default here too, same as postgres.js (no binary protocol support at all, verified elsewhere in this report). A case where a library merely having a registered binary parser is not the same as that parser being safe to use. (elementCount=50000, rowCount=10)
| Library | Mean (ms) | p75 (ms) | p99 (ms) | ops/sec | vs. slowest | GC (ms/op) | Peak Heap (KB) | Network (KB/op) |
|---|---|---|---|---|---|---|---|---|
| PostgreJS (3.0.1) | 28.417 | 29.422 | 32.932 | 35.3 | 5.87x | 0.8229 | 11706.27 | 4666.22 |
| postgres (postgres.js) (3.4.9) | 65.209 | 66.747 | 71.953 | 15.4 | 2.56x | 1.8015 | 99258.84 | 7019.28 |
| pg (node-postgres) (8.23.0) | 166.727 | 169.657 | 178.280 | 6.0 | 1.00x | 3.3992 | 102767.80 | 7019.26 |
Prepared Statement Reuse (Sequential)
Prepare once and execute 50 times, one at a time, each awaited before the next starts. Each library uses its own prepared-statement mechanism: postgres.js auto-prepares, pg uses a named statement, PostgreJS uses explicit prepare()/execute()/close().
Compare it against the Concurrent variant below to see what overlapping executions of the same reused statement buys each library.
Excludes int8: pg, postgres.js and PostgreJS return it as genuinely different JS types by default (string, BigInt, number-or-BigInt), so timing it would measure type-conversion choice, not reuse cost. (iterations=50)
| Library | Mean (ms) | p75 (ms) | p99 (ms) | ops/sec | vs. slowest | GC (ms/op) | Peak Heap (KB) |
|---|---|---|---|---|---|---|---|
| PostgreJS (3.0.1) | 19.682 | 20.498 | 25.322 | 51.1 | 1.13x | 0.0518 | 4472.36 |
| postgres (postgres.js) (3.4.9) | 21.861 | 23.932 | 28.442 | 46.4 | 1.02x | 0.2039 | 10690.99 |
| pg (node-postgres) (8.23.0) | 22.225 | 23.916 | 30.462 | 45.8 | 1.00x | 0.1885 | 14028.02 |
Prepared Statement Reuse (Concurrent)
The concurrent counterpart to Sequential above: prepare once, then fire 50 executions of that same reused statement on the SAME already-open connection without awaiting each one individually, then await them all via Promise.all().
Each library uses its own prepared-statement mechanism: postgres.js auto-prepares, pg uses a named statement, PostgreJS uses explicit prepare()/execute()/close().
Excludes int8: pg, postgres.js and PostgreJS return it as genuinely different JS types by default (string, BigInt, number-or-BigInt), so timing it would measure type-conversion choice, not reuse cost. (concurrency=50)
| Library | Mean (ms) | p75 (ms) | p99 (ms) | ops/sec | vs. slowest | GC (ms/op) | Peak Heap (KB) |
|---|---|---|---|---|---|---|---|
| PostgreJS (3.0.1) | 1.891 | 1.986 | 2.964 | 535.7 | 1.14x | 0.0166 | 8620.96 |
| postgres (postgres.js) (3.4.9) | 1.895 | 1.980 | 2.512 | 532.9 | 1.14x | 0.0269 | 52972.44 |
| pg (node-postgres) (8.23.0) | 2.163 | 2.261 | 2.803 | 466.2 | 1.00x | 0.0249 | 27751.05 |
Cursor Streaming
A server-side cursor (postgrejs's Connection.query(sql, { cursor: true })) fetches rows in bounded batches via repeated Extended Query Execute calls against a portal, instead of the server materializing and sending the whole result set at once. The relevant metric when a result set doesn't comfortably fit in memory, not raw single-shot throughput.
Cursor Streaming
Stream 50000 rows via a server-side cursor in batches of 500. Excludes int8: pg/postgres.js/PostgreJS return it as genuinely different JS types by default (string/BigInt/number-or-BigInt), so timing it would measure type-conversion choice, not streaming throughput (rowTarget=50000, batchSize=500)
| Library | Mean (ms) | p75 (ms) | p99 (ms) | ops/sec | vs. slowest | GC (ms/op) | Peak Heap (KB) |
|---|---|---|---|---|---|---|---|
| PostgreJS (3.0.1) | 172.353 | 172.581 | 181.608 | 5.8 | 1.23x | 4.1347 | 10356.34 |
| pg (node-postgres) (8.23.0) | 210.472 | 214.305 | 215.699 | 4.8 | 1.01x | 10.4045 | 68864.31 |
| postgres (postgres.js) (3.4.9) | 212.027 | 215.413 | 219.535 | 4.7 | 1.00x | 11.7022 | 55002.84 |
Connection Pooling
The overhead each library's connection pool adds on top of the raw per-query costs measured above: running many queries concurrently through a shared pool (postgrejs's Pool.query()), and isolating the pure cost of acquiring and releasing a pooled connection from the cost of the query itself.
Pooled Simple Query
Same query as Sequential Execution/Concurrent Execution above,
run through a pool instead of a single open connection:
1000 concurrent select 1 as one
queries against a pool of max size 10,
using each library's own top-level pooled entry point. (concurrency=1000, poolSize=10)
| Library | Mean (ms) | p75 (ms) | p99 (ms) | ops/sec | vs. slowest | GC (ms/op) | Peak Heap (KB) |
|---|---|---|---|---|---|---|---|
| PostgreJS (3.0.1) | 8.991 | 9.982 | 16.795 | 116.3 | 8.12x | 0.6913 | 58801.57 |
| postgres (postgres.js) (3.4.9) | 13.412 | 16.001 | 25.043 | 80.3 | 5.45x | 0.8417 | 71615.28 |
| pg (node-postgres) (8.23.0) | 73.051 | 76.694 | 80.666 | 13.8 | 1.00x | 1.1507 | 15575.09 |
Pooled Extended Query
The Extended Query counterpart to Pooled Simple Query above: the
same 1000 concurrent calls against a
pool of max size 10, but binding a
real query parameter (select $1::int2 as one) so every library goes
through Parse/Bind/Describe/Execute/Sync instead of a single Query
message.
Each call is a one-shot statement, not a reused prepared one, so this shows what a pool costs on top of the per-call Extended Query overhead. Compare it against this group's Pooled Simple Query to see what the extra protocol round of messages adds once connections are being shared.
Read the margin here with that one-shot constraint in mind. postgres.js's
unprepared path (sql.unsafe(query, args), the same call the
single-connection Sequential Execution scenario uses, where it comes out
ahead) does not pipeline a burst the way its prepared path does. Letting
it cache the statement instead turns this into a different measurement
entirely, which is what Prepared Statement Reuse below covers. (concurrency=1000, poolSize=10)
| Library | Mean (ms) | p75 (ms) | p99 (ms) | ops/sec | vs. slowest | GC (ms/op) | Peak Heap (KB) |
|---|---|---|---|---|---|---|---|
| PostgreJS (3.0.1) | 11.456 | 12.581 | 20.833 | 89.7 | 11.78x | 0.8022 | 65935.15 |
| pg (node-postgres) (8.23.0) | 70.527 | 70.490 | 82.298 | 14.3 | 1.91x | 1.1297 | 16005.75 |
| postgres (postgres.js) (3.4.9) | 134.960 | 135.915 | 146.102 | 7.4 | 1.00x | 2.0637 | 18421.67 |
Raw data
Backing raw data for the numbers above lives in benchmark/results/*.json (gitignored; regenerate with npm run bench).