Skip to main content

Benchmarks

This report runs under Node.js. The same suite also runs under Bun — see Benchmarks (Bun), kept as a separate report rather than merged into this one, since 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.

Generated by npm run bench:report. Do not hand-edit — re-run the command instead.

Contents​

Methodology​

These numbers come from benchmark/ (npm run bench), comparing PostgreJS against pg (node-postgres) and postgres (postgres.js) against the same server, on the same machine, with the same workload. Each scenario is written once per library, using that library's own fastest calling convention - only the mechanism differs, never the work. benchmark/README.md says how to reproduce them.

Each (library, scenario) pair runs in a child process of its own, one at a time, three times over; the tables report the median of the three.

Reading a table​

Rows are in a fixed library order rather than fastest-first, so a library sits in the same place in every scenario. A bolded value is the best one or tied with it - the tie band is how far the leader's own repeats of that column moved, so nothing is called slower than the noise the leader itself shows.

What survives a re-run is the ordering, not the absolute numbers: the same scenario on the same machine, hours later, has been seen to move by 2x. Read a row against the rows beside it, and not against a table of another date.

The columns​

ColumnWhat it answers
GC (ms/op)Time spent collecting, per call - how much garbage the library's own path makes.
Peak Heap (KB)The most heapUsed + external ever stood above a forced-GC baseline during the run. Not what one call needs: it also holds whatever earlier calls left uncollected. external counts because the payload usually lives there - a Buffer, and a large string built from one, are both off-heap.
Retained (KB)What is still held once the run ends. A working set, not a footprint: left idle, both this client's buffers and the others' pools fall back to nothing within seconds.
Net in/out (KB/op)Bytes across the socket each way. Only on the four scenarios whose payload is the point - the two large fetches and the two Bulk Loads - since everywhere else it is small and identical across libraries.

Memory figures are noisier than latency ones; read them as a direction rather than to their last digit. Under Bun, GC (ms/op) is blank: PerformanceObserver never reports a gc entry there. Peak Heap is unaffected.

Where the comparison is not like-for-like​

Deliberate, and worth knowing before reading a row:

  • Cursor streaming: pg has no cursor API of its own, so its scenario emulates one with DECLARE/FETCH.
  • Bulk Load (binary COPY) has one row: only this client can produce the format. Read it against Bulk Load (text COPY), where all three format the same CSV and the ranking is a ranking of drivers.
  • Row shape: PostgreJS is set to objectRows: true everywhere so its rows match what the others return by default. The one normalisation in the suite.
  • Type decoding: no custom parsers or overrides anywhere - each library decodes as it does out of the box, which is what a caller gets.
  • Pooling, pipelining and prepared statements: each library uses its own mechanism and its own defaults. The numbers compare libraries as they ship, not one dial turned for all three.
  • One PostgreJS default is turned off: asyncErrorHandling, which captures a caller-preserving async stack on every call so a failure points at the line that made it. pg and postgres.js have no equivalent, so leaving it on would charge one library for a feature the others do not offer. It stays on by default in the library; it is off only here, and it is worth about 0.5-1.0 KB a call on a small statement.
  • pg-native is out of scope: it needs a system libpq and native compilation, which CI cannot assume. It could be added later behind its own flag.
  • A scenario that needs more warmup or more repeats than the rest says so in its own description.

Environment​

  • Run date: 2026-10-05T17:29:57.699Z
  • Runtime: Node.js v24.15.0
  • OS: Darwin 25.6.0 (darwin/arm64)
  • CPU: Apple M5 Pro (18 logical cores)
  • RAM: 48.0 GB total
  • PostgreSQL: PostgreSQL 18.6 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): pg (node-postgres) 8.23.0, PostgreJS 3.12.0, postgres (postgres.js) 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

LibraryMean (ms)p75 (ms)p99 (ms)ops/secvs. slowestGC (ms/op)Peak Heap (KB)Retained (KB)
PostgreJS (3.12.0)2.0852.1122.638485.53.96x0.01146089.111074.50
pg (node-postgres) (8.23.0)3.0943.1414.196326.42.67x0.01264246.981207.57
postgres (postgres.js) (3.4.9)8.2608.63410.367121.71.00x0.113314785.921084.07

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.

LibraryMean (ms)p75 (ms)p99 (ms)ops/secvs. slowestGC (ms/op)Peak Heap (KB)Retained (KB)
PostgreJS (3.12.0)0.1950.1970.3245208.81.02x0.00079190.91736.17
pg (node-postgres) (8.23.0)0.1970.1990.3295160.01.01x0.000917343.72735.97
postgres (postgres.js) (3.4.9)0.1990.2010.3325115.71.00x0.001014614.29954.84

Concurrent Execution​

The concurrent counterpart to Sequential Execution above. Fire 100 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. Without distinct values every response would be interchangeable and a client that matched them back to the wrong caller would still return a valid row for each, reporting a fast but wrong number instead of failing.

The distinct literal costs nothing on the server and does not turn this into a measurement of statement preparation: the Simple Query protocol has no server-side statement cache, so PostgreSQL parses, rewrites and plans every Query message it receives even when the text is byte-identical to the previous one. Measured, 100 concurrent calls with a constant literal land within noise of the varying one (Node -2.5% to +1.5% across repeats, Bun +0.3% - the sign flips between runs). Simple Query has no bind parameters to carry the distinct value instead, which is why this varies the text where the Extended Query counterpart varies a parameter. (concurrency=100)

LibraryMean (ms)p75 (ms)p99 (ms)ops/secvs. slowestGC (ms/op)Peak Heap (KB)Retained (KB)
PostgreJS (3.12.0)1.8261.8922.474552.01.01x0.018015222.37784.02
pg (node-postgres) (8.23.0)1.8411.9142.478547.91.00x0.015222198.821304.19
postgres (postgres.js) (3.4.9)1.8211.8972.550554.61.01x0.029842931.783197.87

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)

LibraryMean (ms)p75 (ms)p99 (ms)ops/secvs. slowestGC (ms/op)Peak Heap (KB)Retained (KB)
PostgreJS (3.12.0)1.1931.2451.994862.11.63x0.027917109.162087.91
pg (node-postgres) (8.23.0)1.9391.9363.949533.21.00x0.133064887.013578.95
postgres (postgres.js) (3.4.9)1.6571.6164.511631.81.17x0.118467398.966107.13

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 int4 value bound as a real query parameter (select $1::int4), 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.

The cast is int4, not a narrower integer type, on purpose. Libraries differ in whether they declare parameter types at all: PostgreJS names an OID for every parameter in Parse (int4 for a JS number), while pg and postgres.js send none and let the server infer each one from context. With a narrower target - $1::int2 - that difference stops being free: the server has to wrap PostgreJS's int4 parameter in a runtime cast that the others never pay for, and this scenario ends up measuring type-declaration policy rather than Extended Query speed. Measured with a counterbalanced A/B/B/A run (each library taking both positions in the pair, so ordering bias cancels - it is worth ~1.4 points here on its own): against pg, $1::int2 put PostgreJS 4.3% behind on average, $1::int4 0.5%, which is inside this measurement's noise. Keep the parameter's declared type and the cast's target the same.

LibraryMean (ms)p75 (ms)p99 (ms)ops/secvs. slowestGC (ms/op)Peak Heap (KB)Retained (KB)
PostgreJS (3.12.0)0.1860.1880.2745439.61.13x0.00066193.89858.90
pg (node-postgres) (8.23.0)0.2100.2080.3444922.31.00x0.000915523.37938.97
postgres (postgres.js) (3.4.9)0.1830.1840.3205559.11.14x0.000914612.50691.25

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::int4, 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.

The cast is int4, not a narrower integer type, on purpose. Libraries differ in whether they declare parameter types at all: PostgreJS names an OID for every parameter in Parse (int4 for a JS number), while pg and postgres.js send none and let the server infer each one from context. With a narrower target - $1::int2 - that difference stops being free: the server has to wrap PostgreJS's int4 parameter in a runtime cast that the others never pay for, and this scenario ends up measuring type-declaration policy rather than Extended Query speed. Measured with a counterbalanced A/B/B/A run (each library taking both positions in the pair, so ordering bias cancels - it is worth ~1.4 points here on its own): against pg, $1::int2 put PostgreJS 4.3% behind on average, $1::int4 0.5%, which is inside this measurement's noise. Keep the parameter's declared type and the cast's target the same. (concurrency=50)

LibraryMean (ms)p75 (ms)p99 (ms)ops/secvs. slowestGC (ms/op)Peak Heap (KB)Retained (KB)
PostgreJS (3.12.0)0.5260.5480.8611935.02.12x0.008633190.581344.26
pg (node-postgres) (8.23.0)1.1171.1701.511903.41.00x0.008622097.132167.19
postgres (postgres.js) (3.4.9)0.4620.4790.8692210.22.42x0.008848334.813921.35

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)

LibraryMean (ms)p75 (ms)p99 (ms)ops/secvs. slowestGC (ms/op)Peak Heap (KB)Retained (KB)
PostgreJS (3.12.0)1.5281.6252.249667.41.30x0.031210984.121572.43
pg (node-postgres) (8.23.0)1.9841.9894.173519.71.00x0.130064508.338816.76
postgres (postgres.js) (3.4.9)1.6361.5904.186638.71.21x0.115567528.596140.42

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.

Bun's SQL client already uses binary for several of these columns by default - int4, float4, float8, timestamp, timestamptz, and bytea, verified by capturing its actual Bind message over a logging TCP proxy - but int2, int8, varchar, json, and jsonb go out as text regardless, a fixed per-type choice with no exposed option (in the SQL constructor, .unsafe(), or the tagged-template call) to override it either way. Since it can't be forced into PostgreJS's "request binary for every column" shape, its result here would be neither this scenario nor a fair text-protocol one - it isn't benchmarked here for that reason.

Only PostgreJS's own result is shown. (rowTarget=1000)

LibraryMean (ms)p75 (ms)p99 (ms)ops/secvs. slowestGC (ms/op)Peak Heap (KB)Retained (KB)
PostgreJS (3.12.0)1.3491.3412.034753.41.00x0.028411225.151398.74
pg (node-postgres)Not Fully Supported———————
postgres (postgres.js)Not Supported———————
Bun.sqlNot Controllable———————

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)

LibraryMean (ms)p75 (ms)p99 (ms)ops/secvs. slowestGC (ms/op)Peak Heap (KB)Retained (KB)Net in (KB/op)Net out (KB/op)
PostgreJS (3.12.0)30.73631.37334.39832.62.03x0.704333656.079596.2112412.270.06
pg (node-postgres) (8.23.0)61.72462.83263.53716.21.01x0.633789273.7919806.0825600.230.13
postgres (postgres.js) (3.4.9)62.48163.35173.12416.11.00x0.725384513.7628108.5325600.190.06

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)

LibraryMean (ms)p75 (ms)p99 (ms)ops/secvs. slowestGC (ms/op)Peak Heap (KB)Retained (KB)Net in (KB/op)Net out (KB/op)
PostgreJS (3.12.0)17.55018.41820.69857.35.46x0.558732029.0315054.364660.470.05
pg (node-postgres) (8.23.0)95.90297.673103.32810.41.00x2.3982114268.143341.477019.260.13
postgres (postgres.js) (3.4.9)36.28037.51540.50727.62.64x1.1626133194.4022651.116818.670.06

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)

LibraryMean (ms)p75 (ms)p99 (ms)ops/secvs. slowestGC (ms/op)Peak Heap (KB)Retained (KB)
PostgreJS (3.12.0)9.7339.81711.156102.81.02x0.02324967.071195.40
pg (node-postgres) (8.23.0)9.93910.03411.851100.81.00x0.054330246.112603.20
postgres (postgres.js) (3.4.9)9.8369.93011.340101.81.01x0.040520985.621347.56

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)

LibraryMean (ms)p75 (ms)p99 (ms)ops/secvs. slowestGC (ms/op)Peak Heap (KB)Retained (KB)
PostgreJS (3.12.0)0.9581.0021.3501056.41.06x0.008815371.801163.87
pg (node-postgres) (8.23.0)1.0171.0671.527996.61.00x0.008630860.592670.10
postgres (postgres.js) (3.4.9)0.9520.9851.5921069.31.07x0.012057708.375344.61

Unit of Work​

Several different statements run as one unit, the shape a single request usually has - a few writes, a few reads, none of them repeating. Every library sends them without waiting between replies, which all three can do; what separates them is whether each statement still costs its own Sync, and with it a round of the server's implicit-transaction bookkeeping. Distinct from the Concurrent Execution scenarios above, where the same query is fired many times and a parsed plan can be shared.

Unit of Work​

Runs 20 different statements - ten writes, five reads, then five more writes - as one unit, the shape a single request usually has. Unlike the Concurrent Execution scenarios, which fire the same query many times, every statement here is different, so none of them can share a parsed plan with another.

Each library uses the fastest path it has for this. pg and postgres.js fire the statements through Promise.all() without awaiting between them, which is genuinely their best: both pipeline, so the statements travel without waiting for each other's replies. postgres.js builds them from its tagged template rather than sql.unsafe() - on this workload that is 3.5x faster for it (4.4ms against 15.5ms), so the $1 form would have measured a path its users have no reason to take. PostgreJS uses pipeline(), which goes one step further and closes all twenty with a single Sync instead of one each.

That last step is what the scenario is really measuring, and it is not a configuration difference: a Sync per statement makes the server finish an implicit transaction and answer ReadyForQuery every time, and neither other driver can avoid it - pg sends one immediately after each Execute, and postgres.js concatenates Execute and Sync into a single constant, so the two cannot come apart. Compare against Concurrent Execution (Extended Query) above to see the same libraries running the same number of calls when the statements are identical rather than different. (statementCount=20)

LibraryMean (ms)p75 (ms)p99 (ms)ops/secvs. slowestGC (ms/op)Peak Heap (KB)Retained (KB)
PostgreJS (3.12.0)0.6130.6190.7731648.54.33x0.00345548.44866.70
pg (node-postgres) (8.23.0)2.6542.6853.419381.21.00x0.012316121.581773.69
postgres (postgres.js) (3.4.9)1.9992.0372.540502.71.33x0.011120235.982521.73

Bulk Load​

COPY ... FROM STDIN, the path PostgreSQL optimises for writing many rows at once - one statement carrying a stream of rows instead of an INSERT per row or a bind per row. The two scenarios are the same 200,000 rows sent two ways: as CSV every library formats itself, and as the binary COPY format only PostgreJS can produce. Read them together - the text table is what separates the drivers, and the difference between the tables is what the format buys. Both scenarios truncate the destination inside the timed call and start from the same materialised rows, so each library pays for its own formatting rather than being handed a payload someone else built.

Bulk Load (text COPY)​

Bulk-loads 200,000 rows into an empty table over COPY ... FROM STDIN, the path PostgreSQL itself optimises for bulk writes. Each iteration truncates the destination first, so every run loads into the same empty table - the truncate is inside the timed call and costs every library the same.

Column values hug each type's widest text form, the same choice the seeded tables make: binary costs a fixed number of bytes per column whatever the value is, while text costs one per character, so a payload of small numbers would understate the format difference and unusually long ones would overstate it.

That choice moves the headline enough to state outright. On these same 200,000 rows, text against binary: with these values (10-digit int4, 19-digit int8, widest float8, 40-char varchar) 2495ms against 242ms, a 10.3x gap over ~22MB of CSV; with single-digit numbers and 4-char strings instead, 497ms against 187ms, a 2.7x gap over ~8MB. Both are honest for their own data and neither is universal - binary's cost per column doesn't move between those runs, what changes is how much text the server has to parse. Place the table against your own rows rather than reading one figure as the answer.

This is the like-for-like comparison: every library formats the same CSV payload and streams it as bytes, which is the only thing pg and postgres.js can do (see Bulk Load (binary COPY) below). PostgreJS uses its own copyFrom() byte stream here rather than copyFromRows(), so what is measured is the driver's COPY transport, not the format.

pg needs pg-copy-streams for this at all - its core Query refuses COPY IN - which is installed here so it runs its real path rather than being marked unsupported. (rowCount=200000)

LibraryMean (ms)p75 (ms)p99 (ms)ops/secvs. slowestGC (ms/op)Peak Heap (KB)Retained (KB)Net in (KB/op)Net out (KB/op)
PostgreJS (3.12.0)1498.9321500.9091510.3100.71.00x25.2829223063.6622482.280.0932387.29
pg (node-postgres) (8.23.0)1493.5881492.1731501.3820.71.00x22.0025223146.5222396.590.1832387.36
postgres (postgres.js) (3.4.9)1490.6581491.9441497.4460.71.01x21.5756246248.9345536.790.1832387.38
Bun.sqlNo COPY API—————————

Bulk Load (binary COPY)​

Bulk-loads 200,000 rows into an empty table over COPY ... FROM STDIN, the path PostgreSQL itself optimises for bulk writes. Each iteration truncates the destination first, so every run loads into the same empty table - the truncate is inside the timed call and costs every library the same.

Column values hug each type's widest text form, the same choice the seeded tables make: binary costs a fixed number of bytes per column whatever the value is, while text costs one per character, so a payload of small numbers would understate the format difference and unusually long ones would overstate it.

That choice moves the headline enough to state outright. On these same 200,000 rows, text against binary: with these values (10-digit int4, 19-digit int8, widest float8, 40-char varchar) 2495ms against 242ms, a 10.3x gap over ~22MB of CSV; with single-digit numbers and 4-char strings instead, 497ms against 187ms, a 2.7x gap over ~8MB. Both are honest for their own data and neither is universal - binary's cost per column doesn't move between those runs, what changes is how much text the server has to parse. Place the table against your own rows rather than reading one figure as the answer.

The same rows as Bulk Load (text COPY) above, loaded through PostgreJS's copyFromRows(), which encodes them into PostgreSQL's binary COPY format instead of CSV. Read the two tables together: the difference between them is what the format buys, while the text table alone is what separates the drivers.

pg and postgres.js are absent because neither can produce the format. Both can carry a binary COPY payload the caller has already encoded - so this is not a missing transport - but producing it needs a binary encoder per type, which neither has. For pg a third-party package, pg-copy-streams-binary, supplies its own encoders; it is left out here for the same reason pg-native is, being outside the driver rather than part of it. (rowCount=200000)

LibraryMean (ms)p75 (ms)p99 (ms)ops/secvs. slowestGC (ms/op)Peak Heap (KB)Retained (KB)Net in (KB/op)Net out (KB/op)
PostgreJS (3.12.0)107.718107.809124.4269.41.00x1.611272473.118965.200.2821327.95
pg (node-postgres)No binary COPY encoding—————————
postgres (postgres.js)No binary COPY encoding—————————
Bun.sqlNo COPY API—————————

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.

Bun's SQL client has no cursor/streaming API at all - no .cursor(), no .forEach(), no Symbol.asyncIterator on a query (verified against its own type declarations and empirically). It isn't benchmarked here. (rowTarget=50000, batchSize=500)

LibraryMean (ms)p75 (ms)p99 (ms)ops/secvs. slowestGC (ms/op)Peak Heap (KB)Retained (KB)
PostgreJS (3.12.0)91.91092.79295.74210.91.29x1.827111972.531903.92
pg (node-postgres) (8.23.0)118.338118.909127.3508.51.00x6.692777194.423269.00
postgres (postgres.js) (3.4.9)113.634113.929118.8158.81.04x6.300168364.834053.29
Bun.sqlNot Supported———————

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)

LibraryMean (ms)p75 (ms)p99 (ms)ops/secvs. slowestGC (ms/op)Peak Heap (KB)Retained (KB)
PostgreJS (3.12.0)6.5907.0239.969156.26.56x0.379677314.42733.91
pg (node-postgres) (8.23.0)43.20743.84153.57423.31.00x0.545317234.391722.10
postgres (postgres.js) (3.4.9)9.58112.63516.244120.94.51x0.397288132.257171.30

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::int4 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)

LibraryMean (ms)p75 (ms)p99 (ms)ops/secvs. slowestGC (ms/op)Peak Heap (KB)Retained (KB)
PostgreJS (3.12.0)6.6547.08011.165154.16.69x0.298278738.241394.73
pg (node-postgres) (8.23.0)44.49045.21050.45322.61.00x0.613018277.672031.25
postgres (postgres.js) (3.4.9)9.58212.64016.179126.64.64x0.369583374.175684.97

Raw data​

Backing raw data for the numbers above lives in benchmark/results/node/*.json (gitignored; regenerate with npm run bench).