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:
| Suite | Workload | Connections | What it isolates |
|---|---|---|---|
| Sequential | one query at a time, awaited | pool of PGMAX | Per-query client overhead. Nothing is ever in flight concurrently, so pipeline mode cannot batch anything |
| Pipelined | 10 queries issued at once with Promise.all | exactly 1 | PostgreSQL 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:
| Column | Meaning |
|---|---|
Relative latency | Geometric 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 best | Distance from the first place |
Fastest median | In how many groups it had the lowest median |
Outright wins | In 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 500groups (~5 ms) would swampLIMIT 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:
| Variable | Default | Binds on |
|---|---|---|
BENCH_TIME_MS | 9000 | The 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_QUERIES | 5000 | The 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 pggives you today. Out of the boxpgresolvespg-types@2.2.0and is correspondingly slower than the figures reported here -
The override benefits both
pgandpg-native: theyrequire('pg-types')and share a single module instance. It is not apg-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:
Type pg-types@2pg-types@4TIMESTAMP WITHOUT TIME ZONEparsed in the system timezone parsed as UTC DATEDateobjectstringIf you adopt this override in your own application, check those two first.
TIMESTAMPTZis 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
LIMITas 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; sincepg8.23.0 andpg-native3.9.0 the same is available through thepipeline: trueclient 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-nativedoes not deduplicatePREPAREwithin one batch, so 10 queries sharing a statement name would each send a prepare and the server would reject the duplicates.pgtracks in-flight names itself andpostgresmanages 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-nativeneedslibpq>= 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' │
└─────────┴──────┴────────────────────────────────────┴──────────────────┴──────────────────┴────────────────┴───────────────┘