Postgres benchmark

August 9, 2026 · View on GitHub

brianc/node-postgres VS porsager/postgres

A benchmark focusing on the client-side overhead/performance of Postgres client libraries for Node.js, brianc/node-postgres VS porsager/postgres

Dependencies:

The benchmark measures:

  • PostgreSQL client overhead
  • protocol parsing
  • type conversion
  • result consumption

Two suites

Every query size is measured twice, under two different workloads:

SuiteWorkloadConnectionsWhat it isolates
Sequentialone query at a time, awaitedpool of PGMAXPer-query client overhead. Nothing is ever in flight concurrently, so pipeline mode cannot batch anything
Pipelined10 queries issued at once with Promise.allexactly 1PostgreSQL pipeline mode: how well a client keeps several requests in flight on one connection instead of paying a round trip per query

Pipelining only happens when several queries are in flight on the same connection, so the pipelined suite deliberately drives a single connection - a pool would spread the batch across PGMAX connections and no pipelining would occur. The three clients are on equal terms even though the spelling differs: pg/pg-native expose a single-connection Client (max is a pg-pool option and is not passed there), while postgres is always pool-backed, so max: 1 is its way of expressing the same thing.

In the pipelined suite one iteration resolves 10 queries, so the latency columns are per batch; the Queries/s column is the one to compare against the sequential suite.

Final ranking

Each suite, and the run as a whole, ends with a ranking table:

ColumnMeaning
Relative latencyGeometric mean of the client's median latency, normalized to the fastest client of each group. 1.000 = fastest everywhere, 1.100 = 10% slower than the group leader on average
Slower than bestDistance from the first place
Fastest medianIn how many groups it had the lowest median
Outright winsIn how many groups it won statistically (non-overlapping confidence intervals)

Two details make the aggregate honest:

  • Medians are normalized per group before being combined. Without that, the LIMIT 500 groups (~5 ms) would swamp LIMIT 1 (~0.2 ms) in any average and the ranking would silently degenerate into "who is fastest on large result sets"
  • The per-group ratios are combined with a geometric mean, not an arithmetic one. Averaging normalized numbers arithmetically is not meaningful: it yields a different ordering depending on which client happened to be the baseline, while the geometric mean is invariant to that choice

Fastest median and Outright wins are kept separate on purpose. A client can lead the median in a group while the gap is still inside the margin of error - a 3/3 versus 0/3 split says exactly that, instead of hiding it behind a single score.

Measurement budget

Two floors, overridable as environment variables. A group runs until it has spent the time and executed the queries, so whichever is reached last decides the sample count:

VariableDefaultBinds on
BENCH_TIME_MS9000The cheap groups (small results, pipelined batches). Their samples are short, so buying more costs little wall clock - and these are the groups with the worst margin of error, because per-sample overhead weighs more
BENCH_QUERIES5000The expensive groups (LIMIT 500). Their samples are long and already stable, reaching a low margin of error with far fewer of them

Letting the two floors bind on different groups is what keeps the run affordable: a single flat budget would either starve the noisy groups or overpay for the stable ones. With the defaults a full run takes roughly 5 minutes. The margin of error shrinks with the square root of the sample count, so doubling a budget tightens the confidence interval by only ~29% while doubling the wall clock. For a quicker iteration loop:

BENCH_TIME_MS=3000 BENCH_QUERIES=2000 npm run bench

Dependency override

pg pins pg-types to the exact version 2.2.0, released in August 2019. This benchmark overrides it to 4.x:

{ "overrides": { "pg-types": "^4.1.0" } }

Why. postgres (porsager/postgres) ships its own type parsers and keeps them current, so it is not held back by a 2019 dependency. Leaving pg on pg-types@2.2.0 would measure the age of a pinned transitive dependency rather than the library itself. The concrete case is integer parsing: pg-types@2 uses parseInt(value, 10), pg-types@4 uses Number — measured in isolation over 20M conversions, parseInt(value, 10) takes 747 ms against 184 ms, roughly 4x slower. End to end on LIMIT 500 the override is worth about 15%.

The upstream situation is brianc/node-postgres#2547: the upgrade is wanted but blocked on breaking changes in date handling, unrelated to integers.

What this means when reading the numbers, stated plainly:

  • This is not what npm install pg gives you today. Out of the box pg resolves pg-types@2.2.0 and is correspondingly slower than the figures reported here

  • The override benefits both pg and pg-native: they require('pg-types') and share a single module instance. It is not a pg-only advantage

  • The override changes value semantics for two types, neither of which appears in this benchmark's schema, so it cannot affect the measurements here:

    Typepg-types@2pg-types@4
    TIMESTAMP WITHOUT TIME ZONEparsed in the system timezoneparsed as UTC
    DATEDate objectstring

    If you adopt this override in your own application, check those two first. TIMESTAMPTZ is identical between the versions.

Fair benchmark

  • All libraries execute queries using prepared statements (see Prepared statement)
  • All libraries run the exact same query text with the LIMIT as a SQL literal (no bound parameters) and consume the results through the same code path
  • The garbage collector is exposed and triggered before both the warmup and the measured run of each task, so every measurement starts from a clean heap and a GC pause during warmup cannot leak into the measured run (see tinybench)
  • Each query size is measured under all 6 execution orders (every permutation of the 3 clients) and the raw samples are pooled per client, so the execution order is fully removed as a confounder - no library benefits from systematically running first (cold cache/JIT) or last (warmed shared state). The per-run time budget is divided across the permutations, keeping the total sample count and wall-clock close to a single run
  • The winner is ranked by median latency (p50) and is only crowned when its confidence interval of the mean does not overlap any rival's; otherwise the run is reported as having no clear winner
  • Queries are warmed up before measurements
  • PostgreSQL is accessed through a Unix domain socket to reduce TCP overhead
  • All libraries run with PostgreSQL pipeline mode enabled. postgres (porsager/postgres) has always pipelined internally; since pg 8.23.0 and pg-native 3.9.0 the same is available through the pipeline: true client option, so the previous asymmetry is gone and all three are compared on equal terms
  • Both budgets are expressed in executed queries, not iterations, so the two suites are constrained homogeneously despite one pipelined iteration resolving 10 queries
  • Because pipeline mode always uses the extended query protocol, every statement is sent individually (multi-statement scripts are rejected by the server in this mode)
  • Prepared statements are primed with a single query before each pipelined group: pg-native does not deduplicate PREPARE within one batch, so 10 queries sharing a statement name would each send a prepare and the server would reject the duplicates. pg tracks in-flight names itself and postgres manages its own prepared statements

The database contains a pre-populated table with 500 rows. Benchmark queries only read existing rows using LIMIT 1, LIMIT 100 and LIMIT 500, eg.:

SELECT * FROM benchmark_rows ORDER BY id LIMIT 1

The data preparation of benchmark_rows (each statement is executed separately):

CREATE TABLE IF NOT EXISTS benchmark_rows (
  id int PRIMARY KEY,
  int_value int,
  string_value text,
  null_value text,
  bool_value boolean
);

TRUNCATE benchmark_rows;

INSERT INTO benchmark_rows (id, int_value, string_value, null_value, bool_value)
  SELECT
    i,
    1337,
    'wat',
    NULL,
    false
  FROM generate_series(1, 500) i;

Run benchmark

On Docker:

docker-compose build
docker-compose up

On Ubuntu/Debian:

apt-get install libpq-dev g++ python3 make
npm install
npm run bench

pg-native needs libpq >= 1.11.0 built against PostgreSQL 14+ client libraries, otherwise pipeline mode is unavailable.

Output

Running benchmarks... 
GC is exposed
Pool size: 10
Dependencies versions:
{
  "tinybench": "6.1.2",
  "pg": "8.23.0",
  "pg-native": "3.9.0",
  "libpq": "1.11.0",
  "pg-types": "4.1.0",
  "postgres": "3.4.9"
}
Database connectivity verified through: socket at /var/run/postgresql
Pipeline mode: enabled for all clients
nodejs v26.7.0, CPU: Intel(R) Core(TM) i7-1065G7 CPU @ 1.30GHz Cores: 8, RAM: 7.57 GB
Budget per query size: >=5000 queries and >=9000 ms per client, split across 6 execution orders

=== Sequential: one query at a time, pool of 10 connections ===


query_1 (pooled over 6 execution orders)
┌─────────┬────────────────────────────────────┬──────────────────┬──────────────────┬────────────────────────┬───────────┬─────────┐
 (index) │ Task name                          │ Latency avg (ns) │ Latency med (ns) │ Throughput avg (ops/s) │ Queries/s │ Samples │
├─────────┼────────────────────────────────────┼──────────────────┼──────────────────┼────────────────────────┼───────────┼─────────┤
 0 'pg-native (brianc/node-postgres)' '195249 ± 0.29%' '175289' 5122 5122 46098
 1 'pg (brianc/node-postgres)' '209190 ± 0.37%' '187820' 4780 4780 43026
 2 'postgres (porsager/postgres)' '201126 ± 0.36%' '181810' 4972 4972 44751
└─────────┴────────────────────────────────────┴──────────────────┴──────────────────┴────────────────────────┴───────────┴─────────┘
🏆 Winner: pg-native (brianc/node-postgres) (175289 ns median)


query_100 (pooled over 6 execution orders)
┌─────────┬────────────────────────────────────┬──────────────────┬──────────────────┬────────────────────────┬───────────┬─────────┐
 (index) │ Task name                          │ Latency avg (ns) │ Latency med (ns) │ Throughput avg (ops/s) │ Queries/s │ Samples │
├─────────┼────────────────────────────────────┼──────────────────┼──────────────────┼────────────────────────┼───────────┼─────────┤
 0 'pg-native (brianc/node-postgres)' '377196 ± 0.70%' '325075' 2651 2651 30000
 1 'pg (brianc/node-postgres)' '392760 ± 0.38%' '358026' 2546 2546 30000
 2 'postgres (porsager/postgres)' '370839 ± 0.88%' '329382' 2697 2697 30000
└─────────┴────────────────────────────────────┴──────────────────┴──────────────────┴────────────────────────┴───────────┴─────────┘
🤝 No clear winner within margin of error - lowest median: pg-native (brianc/node-postgres) (325075 ns); lowest mean: postgres (porsager/postgres) (370839 ns)


query_500 (pooled over 6 execution orders)
┌─────────┬────────────────────────────────────┬──────────────────┬──────────────────┬────────────────────────┬───────────┬─────────┐
 (index) │ Task name                          │ Latency avg (ns) │ Latency med (ns) │ Throughput avg (ops/s) │ Queries/s │ Samples │
├─────────┼────────────────────────────────────┼──────────────────┼──────────────────┼────────────────────────┼───────────┼─────────┤
 0 'pg-native (brianc/node-postgres)' '725779 ± 0.23%' '684301' 1378 1378 30000
 1 'pg (brianc/node-postgres)' '812933 ± 0.31%' '772112' 1230 1230 30000
 2 'postgres (porsager/postgres)' '817713 ± 0.96%' '706210' 1223 1223 30000
└─────────┴────────────────────────────────────┴──────────────────┴──────────────────┴────────────────────────┴───────────┴─────────┘
🏆 Winner: pg-native (brianc/node-postgres) (684301 ns median)

=== Pipelined: 10 concurrent queries on a single connection ===


query_1_pipelined_x10 (pooled over 6 execution orders)
┌─────────┬────────────────────────────────────┬──────────────────┬──────────────────┬────────────────────────┬───────────┬─────────┐
 (index) │ Task name                          │ Latency avg (ns) │ Latency med (ns) │ Throughput avg (ops/s) │ Queries/s │ Samples │
├─────────┼────────────────────────────────────┼──────────────────┼──────────────────┼────────────────────────┼───────────┼─────────┤
 0 'pg-native (brianc/node-postgres)' '996238 ± 0.60%' '943910' 1004 10038 9037
 1 'pg (brianc/node-postgres)' '746295 ± 0.70%' '694810' 1340 13400 12062
 2 'postgres (porsager/postgres)' '853715 ± 0.53%' '833219' 1171 11714 10545
└─────────┴────────────────────────────────────┴──────────────────┴──────────────────┴────────────────────────┴───────────┴─────────┘
🏆 Winner: pg (brianc/node-postgres) (694810 ns median)


query_100_pipelined_x10 (pooled over 6 execution orders)
┌─────────┬────────────────────────────────────┬───────────────────┬──────────────────┬────────────────────────┬───────────┬─────────┐
 (index) │ Task name                          │ Latency avg (ns)  │ Latency med (ns) │ Throughput avg (ops/s) │ Queries/s │ Samples │
├─────────┼────────────────────────────────────┼───────────────────┼──────────────────┼────────────────────────┼───────────┼─────────┤
 0 'pg-native (brianc/node-postgres)' '1333945 ± 0.57%' '1248936' 750 7497 6749
 1 'pg (brianc/node-postgres)' '1611453 ± 1.62%' '1397369' 621 6206 5588
 2 'postgres (porsager/postgres)' '1553882 ± 1.65%' '1315256' 644 6435 5795
└─────────┴────────────────────────────────────┴───────────────────┴──────────────────┴────────────────────────┴───────────┴─────────┘
🏆 Winner: pg-native (brianc/node-postgres) (1248936 ns median)


query_500_pipelined_x10 (pooled over 6 execution orders)
┌─────────┬────────────────────────────────────┬───────────────────┬──────────────────┬────────────────────────┬───────────┬─────────┐
 (index) │ Task name                          │ Latency avg (ns)  │ Latency med (ns) │ Throughput avg (ops/s) │ Queries/s │ Samples │
├─────────┼────────────────────────────────────┼───────────────────┼──────────────────┼────────────────────────┼───────────┼─────────┤
 0 'pg-native (brianc/node-postgres)' '5917446 ± 2.11%' '4917719' 169 1690 3000
 1 'pg (brianc/node-postgres)' '6733257 ± 1.58%' '5601829' 149 1485 3000
 2 'postgres (porsager/postgres)' '6180662 ± 1.75%' '5040298' 162 1618 3000
└─────────┴────────────────────────────────────┴───────────────────┴──────────────────┴────────────────────────┴───────────┴─────────┘
🏆 Winner: pg-native (brianc/node-postgres) (4917719 ns median)


=== Final ranking ===
Relative latency is the geometric mean of each client's median latency, normalized to the fastest
client of every group: 1.000 means fastest everywhere, 1.100 means 10% slower than the group leader
on average. "Outright wins" counts only the groups whose winner was statistically clear.

Sequential (3 groups)
┌─────────┬──────┬────────────────────────────────────┬──────────────────┬──────────────────┬────────────────┬───────────────┐
│ (index) │      │ Task name                          │ Relative latency │ Slower than best │ Fastest median │ Outright wins │
├─────────┼──────┼────────────────────────────────────┼──────────────────┼──────────────────┼────────────────┼───────────────┤
│ 0       │ '🥇' │ 'pg-native (brianc/node-postgres)' │ '1.000'          │ '-'              │ '3/3'          │ '2/3'         │
│ 1       │ '🥈' │ 'postgres (porsager/postgres)'     │ '1.027'          │ '+2.7%'          │ '0/3'          │ '0/3'         │
│ 2       │ '🥉' │ 'pg (brianc/node-postgres)'        │ '1.100'          │ '+10.0%'         │ '0/3'          │ '0/3'         │
└─────────┴──────┴────────────────────────────────────┴──────────────────┴──────────────────┴────────────────┴───────────────┘

Pipelined x10 (3 groups)
┌─────────┬──────┬────────────────────────────────────┬──────────────────┬──────────────────┬────────────────┬───────────────┐
│ (index) │      │ Task name                          │ Relative latency │ Slower than best │ Fastest median │ Outright wins │
├─────────┼──────┼────────────────────────────────────┼──────────────────┼──────────────────┼────────────────┼───────────────┤
│ 0       │ '🥇' │ 'pg (brianc/node-postgres)'        │ '1.084'          │ '-'              │ '1/3'          │ '1/3'         │
│ 1       │ '🥈' │ 'postgres (porsager/postgres)'     │ '1.090'          │ '+0.5%'          │ '0/3'          │ '0/3'         │
│ 2       │ '🥉' │ 'pg-native (brianc/node-postgres)' │ '1.108'          │ '+2.2%'          │ '2/3'          │ '2/3'         │
└─────────┴──────┴────────────────────────────────────┴──────────────────┴──────────────────┴────────────────┴───────────────┘

Overall (6 groups)
┌─────────┬──────┬────────────────────────────────────┬──────────────────┬──────────────────┬────────────────┬───────────────┐
│ (index) │      │ Task name                          │ Relative latency │ Slower than best │ Fastest median │ Outright wins │
├─────────┼──────┼────────────────────────────────────┼──────────────────┼──────────────────┼────────────────┼───────────────┤
│ 0       │ '🥇' │ 'pg-native (brianc/node-postgres)' │ '1.052'          │ '-'              │ '5/6'          │ '4/6'         │
│ 1       │ '🥈' │ 'postgres (porsager/postgres)'     │ '1.058'          │ '+0.5%'          │ '0/6'          │ '0/6'         │
│ 2       │ '🥉' │ 'pg (brianc/node-postgres)'        │ '1.092'          │ '+3.8%'          │ '1/6'          │ '1/6'         │
└─────────┴──────┴────────────────────────────────────┴──────────────────┴──────────────────┴────────────────┴───────────────┘