Bun.sql vs pg: What the Benchmark Numbers Hide — The Real Footprint Native Bindings Leave on Query Latency and Connection Pooling
When Bun first appeared, the phrase "4x faster than Node.js" set the community ablaze. Honestly, I was initially swayed by that number too — but it turned out to be a claim limited to the HTTP processing layer. Independent analyses show that benchmarks including actual PostgreSQL queries converge to roughly a 3% difference. So is Bun.sql, newly introduced in Bun 1.2, just a marketing number?
I suspected as much at first. But the deeper I dug into the architecture behind the benchmark numbers, the clearer it became that this story isn't simply about "how much faster" — it's about "which workloads behave differently, and in what ways." For typical CRUD APIs, index design matters far more than driver choice, but for internal services handling an explosive volume of small queries, there are user-reported cases of significant throughput differences attributed to query pipelining (though these are single reports without reproducible condition specifications, so the absolute numbers are hard to trust at face value).
This article examines exactly where that difference comes from, and whether it's meaningful at the scale of your service. It's aimed at those considering migrating a Node.js service running on the pg driver to Bun, or those already using Bun and weighing when to adopt Bun.sql.
The Performance Gap Created by Implementation Differences
What Makes Bun.sql Different
Bun.sql is not an external npm package. Starting with Bun 1.2 (released January 2025), it is a SQL client built directly into the runtime — you can connect to PostgreSQL with a single line: import { sql } from "bun". Internally, it implements the PostgreSQL wire protocol directly in Zig, deeply integrated with Bun's event loop and uSockets networking layer. Handshakes, SSL/TLS upgrades, and MD5/SCRAM-SHA-256 authentication are all handled in Zig.
pg, by contrast, implements the wire protocol in pure JavaScript. It doesn't use a C library like libpq — all parsing and serialization runs inside the V8 engine. I understand this difference through three axes:
First, binary vs. text wire protocol. Bun.sql supports PostgreSQL's binary format. By reducing the need to serialize numbers, timestamps, and other types to text and then parse them back, parsing overhead decreases when handling large volumes of numeric or time-based data. However, the binary format makes visual debugging difficult in tcpdump/Wireshark captures, and can cause unexpected behavior with PgBouncer transaction mode or some proxies and query loggers — so it's not an unconditional advantage.
Second, automatic prepared statement caching. Bun.sql automatically reuses prepared statements when the same query string is executed repeatedly. To achieve this effect with pg, you must explicitly specify the name parameter on the query object.
Third, query pipelining. This is the most notable difference. Bun.sql can send multiple queries in succession without waiting for responses. pg sends the next query only after receiving the response to the current one. By reducing the number of network round trips, throughput differences widen in scenarios where a large volume of small queries arrive simultaneously.
Comparing in Actual Code
Connection Setup
// Bun.sql - conceptual example (confirm actual option names in official docs for your version)
import { SQL } from "bun";
// Automatically references DATABASE_URL environment variable if present
// (in the form postgresql://user:pass@localhost:5432/mydb)
const sql = new SQL({
url: process.env.DATABASE_URL,
max: 10,
});
const result = await sql`SELECT 1`;// node-postgres
import { Pool } from "pg";
const pool = new Pool({
host: "localhost",
port: 5432,
database: "mydb",
user: "user",
password: "pass",
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});pg controls connection lifetime through max, idleTimeoutMillis, and connectionTimeoutMillis as specified in the official Pool documentation (options like acquireTimeoutMillis or min are not on the official API surface, so even if you've seen them in community examples, it's safer to verify the docs before using them). Bun.sql doesn't yet have options this refined, and feature requests for fine-grained pool control are under discussion in GitHub issues.
Query Execution
// Bun.sql - tagged template literal
const userId = 42;
const result = await sql`
SELECT id, name, email
FROM users
WHERE id = ${userId}
`;
console.log(result[0].name);// node-postgres
const userId = 42;
// Basic approach (no prepared statement caching)
const { rows } = await pool.query(
"SELECT id, name, email FROM users WHERE id = $1",
[userId]
);
// name parameter required for prepared statement caching
const { rows: cachedRows } = await pool.query({
name: "get-user-by-id",
text: "SELECT id, name, email FROM users WHERE id = $1",
values: [userId],
});Bun.sql's tagged template literal API automatically treats string interpolation as parameter binding, making SQL injection mistakes harder to introduce accidentally. pg is equally safe when using $1 placeholders, but between the two styles, the tagged template approach has a lower chance of mistakes.
Using with Drizzle ORM
More teams are adopting the Bun.sql + Drizzle combination for Bun-only runtime projects, and the drizzle-orm/bun-sql adapter is officially documented in the Drizzle documentation.
import { drizzle } from 'drizzle-orm/bun-sql';
import { sql } from 'bun';
import { eq } from 'drizzle-orm';
import { usersTable } from './schema';
const db = drizzle(sql);
const users = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, 42));One important point to note here: adding an ORM layer significantly dilutes the performance gap between drivers. The JS cycles Drizzle spends on query building, type casting, and result mapping mean that in many workloads, Bun.sql's pipelining and binary parsing advantages get buried beneath ORM overhead. Expecting driver benchmark results to carry over directly to an ORM layer is likely to disappoint. It's better to re-measure with your own service's representative queries.
The Critical Difference in Connection Pool Behavior
This is something I find more concerning than performance, from an operational stability standpoint.
The Result of Different Initialization Strategies: Startup Load Spikes
Bun.sql's connection pool initialization strategy isn't yet detailed in official documentation, but the pattern repeatedly highlighted in user observations and the feature request issue #30631 is that "the pool grows noticeably large and fast in response to request load." pg creates connections incrementally as requests arrive, and cleans up idle connections via idleTimeoutMillis.
Why does this matter in production? When running with max=200 or more and restarting multiple instances simultaneously, TCP/TLS handshakes can flood in over a short period, causing a connection spike on the PostgreSQL server. The --sql-preconnect flag (introduced in Bun v1.2.21) lets the application establish connections before receiving traffic, but this is less a tool for preventing the spike itself and more a tool for shifting that moment earlier and managing it explicitly. To actually reduce the spike, you ultimately need to set max conservatively or control the instance rollout order in your deployment pipeline.
pg controls this behavior with the combination of max, idleTimeoutMillis, and connectionTimeoutMillis, and has a long track record with external poolers like PgBouncer.
The Reality in Numbers
Most cited benchmarks don't publicly disclose sufficient reproduction conditions. Here is a summary of the representative references, with trust levels noted:
- Pure API server throughput comparison: Evert Heylen's independent analysis reports that throughput differences between Bun and Node converge to around 3%. The environment and measurement methodology are documented publicly, making it relatively worth referencing.
- Pipelining advantage under high-concurrency small queries: A single user report in Bun issue #20294. Hardware, network, pipelining depth, and other conditions are not disclosed in detail — take only the directional observation (pipelining is advantageous) and treat the absolute multipliers as reference only.
- Improvements in bulk row reads: A single migration case cited in an InfoQ article. Query types and data sizes are only partially disclosed.
- Millisecond-level figures in node-postgres issue #3391: User-submitted benchmarks with unspecified hardware and network conditions — unsuitable for citing absolute values.
Therefore, rather than absolute figures like "X times faster," this article summarizes directional trends only.
| Workload Type | Observed Direction | Primary Cause |
|---|---|---|
| General CRUD API (network latency dominant) | Effectively no difference | Driver parsing cost is buried in network round trips |
| High-volume small repeated queries (internal services, batch) | Bun.sql tends to be meaningfully faster | Query Pipelining |
| Bulk row reads (aggregation, reports) | Bun.sql tends to be somewhat faster | Binary parsing + automatic prepared statement caching |
Always ask yourself when looking at benchmark numbers: "Do the network conditions in this test match our service?" If the DB is in the same VPC, network latency is low, making the driver parsing cost relatively larger. If the DB is across the public internet, driver differences are generally in the noise range.
Trade-off Summary
| Item | Bun.sql | pg (node-postgres) |
|---|---|---|
| Implementation language | Zig native | Pure JavaScript |
| Prepared statements | Automatic caching (default) | Requires manual name parameter |
| Query pipelining | Enabled by default | Not available |
| Wire protocol | Binary support (note: debugging and proxy compatibility concerns) | Text-based (broad tooling compatibility) |
| Pool initialization/scaling behavior | Aggressive (startup spike management required) | Gradual (controlled via idleTimeoutMillis, etc.) |
| Fine-grained pool control options | Still limited | max, idleTimeoutMillis, connectionTimeoutMillis, etc. |
| PgBouncer transaction mode | Compatibility not sufficiently verified | Long-validated combination |
| Runtime support | Bun only | Works on Node.js and Bun |
| Ecosystem maturity | New (2025–) | 10+ years in production use |
| External dependencies | None (built into runtime) | Separate npm install required |
Common Pitfalls
Early versions of Bun.sql had reported issues with concurrent statement execution and Date instance handling. Before adopting it, check the changelog for the Bun version you're using, and if your service handles many timestamps, be sure to validate with representative queries.
If you're using PgBouncer in transaction mode, compatibility with Bun.sql has not yet been sufficiently verified. In that case, it's safer to stay with pg or postgres.js.
For teams that need to maintain a cross-runtime codebase, the postgres.js + Drizzle combination is also a realistic alternative. Since Bun.sql's API is modeled after it, the tagged template literal syntax is nearly identical, and the same code works on both Node.js and Bun.
What to Choose and When
Closing Thoughts
There are three conclusions I want to emphasize repeatedly in this article.
First, the scenarios where Bun.sql creates a perceptible difference over pg are narrow. Internal services and batch workloads that execute large volumes of small queries over low-latency networks are the only situations where the pipelining advantage is clearly felt. For most other API servers, EXPLAIN ANALYZE and index design have far more impact than driver choice.
Second, the difference in connection pool initialization and scaling behavior matters more from an operational standpoint than a performance one. If your deployment pipeline restarts multiple instances simultaneously with a large max value, you must verify that you can handle the startup connection spike before adopting Bun.sql. The --sql-preconnect flag is not a tool that eliminates this spike — it's a tool that moves that moment earlier so you can manage it explicitly.
Third, if you're using PgBouncer in transaction mode, staying with pg or postgres.js is the safer choice for now. Conversely, if you've already committed to Bun as your sole runtime and neither of the above conditions applies, the API-side benefits — zero dependencies, tagged template literals, and automatic prepared statement caching — are reason enough to choose Bun.sql.
Don't judge by numbers alone. First confirm which axis your service sits on. "Results measured on our own workload" is ultimately the only evidence you can trust.
References
- Bun 1.2 Official Release Notes
- Bun SQL Official Documentation
- Bun v1.2.21 Release Notes (--sql-preconnect, etc.)
- Bun 1.2 Improves Node Compatibility and Adds Postgres Client — InfoQ
- Performance: pg VS postgres.js VS Bun.SQL — GitHub Issue #3391
- Huge performance gap between bun sql and pg npm test? — Bun GitHub Issue #20294
- Feature Request: Customizable Connection Pool Strategy — Bun GitHub Issue #30631
- Node vs Bun: no backend performance difference — Evert Heylen
- Drizzle ORM - PostgreSQL with Bun.sql Official Documentation
- node-postgres Pool API Official Documentation