Working with PostgreSQL Using Bun 1.2's Built-in SQL Driver — Prepared Statements, Transactions, and Where Streaming Stands Today
When you decide to move a Node.js backend to Bun, one of the first friction points is the DB driver. Since node-postgres and postgres.js are long-validated libraries in the Node.js ecosystem, it's tempting to think "can't I just use those with Bun?" And yes, that actually works. But starting with Bun 1.2, a PostgreSQL client was bundled into the runtime itself, adding one more option to the mix.
Bun.sql is a built-in SQL client that requires no external package installation. It handles PostgreSQL by default, and from later patches in Bun 1.2 onward, it supports MySQL/MariaDB and SQLite through the same API (check the Bun release notes for exact support timelines). The driver itself is implemented in Zig.
This article walks through automatic prepared statement caching, transactions and savepoints, and the current state of streaming queries, with a focus on code. It also covers parts that aren't fully stable yet, so if you're evaluating a migration right now, this should give you something concrete to base that decision on.
The examples that follow assume you're reusing the
sqlinstance created in the first example throughout.new SQL(...)is not called again in every example.
Why the built-in driver — what it means to drop the external package
Why does removing one dependency matter
One npm install postgres is not a huge burden. But from a CI pipeline perspective, the story changes a bit. Some external drivers require a native build, and most of us have experienced a broken build from a version conflict at least once. From a supply chain security standpoint, one fewer external dependency means one fewer target for auditing.
Of course, turning this argument around, you have to acknowledge that the Bun runtime itself demands a much larger trust boundary. Instead of trusting a thin library like postgres.js, you're trusting the entire runtime — so "fewer dependencies" only holds true from the application package.json perspective.
The practical value of Bun.sql is that the driver is already inside the runtime, so no separate installation or build step is needed.
import { SQL } from "bun";
const sql = new SQL("postgres://user:pass@localhost:5432/mydb");
const users = await sql`SELECT * FROM users WHERE active = ${true}`;Passing parameters through tagged template literals means binding is handled internally, which blocks SQL injection at the API level. The structure itself makes it difficult to accidentally build queries through string concatenation.
Query execution flow at a glance
The client (Bun) manages prepared statement names, and the server (PostgreSQL) holds the corresponding parsed results within the session. Keeping these two layers separate in mind will make the server log discussion that comes later feel natural.
Prepared statements — caching without any extra configuration
Automatic server-side prepared statement caching
When using pg, boosting performance for repeated queries required explicitly naming them, like client.query({ name: 'fetch-user', text: '...' }). With Bun.sql, this is automated by default.
const ids = [1, 2, 3, 4, 5];
for (const id of ids) {
const [row] = await sql`SELECT * FROM orders WHERE id = ${id}`;
console.log(row);
}Even inside a loop, the SELECT * FROM orders WHERE id = $1 query is only parsed once. On subsequent iterations, only the parameters change. For services with a high volume of individual-row lookups, this makes a noticeable difference.
If you leave the PostgreSQL server logs open, you can see with your own eyes that from the second execution onward, the server-side Parse step is skipped and only Bind → Execute appears. This is because the client reuses the statement name, and the server reuses the parsed result attached to that name.
Transactions — automatic rollback when an exception is thrown inside the callback
Basic transactions
const [user, account] = await sql.begin(async (tx) => {
const [u] = await tx`
INSERT INTO users (name) VALUES ('Alice') RETURNING *
`;
const [a] = await tx`
INSERT INTO accounts (user_id) VALUES (${u.id}) RETURNING *
`;
return [u, a];
});When sql.begin() is called, a dedicated connection is reserved internally and held for the duration of the callback. If an exception is thrown inside the callback, it automatically ROLLBACKs; if it completes normally, it COMMITs. There is no need to manually call rollback inside a try/catch.
The return value of the callback becomes the result of sql.begin() (verifiable in the official documentation and examples). You can also pass an isolation level string as the first argument ("serializable", "repeatable read", etc.), which is distinct from the PostgreSQL access mode (READ WRITE, etc.). Check the docs for supported strings when needed.
Partial rollback with savepoints
Savepoints are useful when you want to undo only a specific block rather than the entire transaction. There is one important caveat here: if an exception inside a savepoint block propagates outward uncaught, it will abort the outer transaction as well. If you want only the savepoint to roll back, you must absorb the exception with a try/catch.
await sql.begin(async (tx) => {
await tx`INSERT INTO users (name) VALUES ('Bob')`;
try {
await tx.savepoint(async (sp) => {
await sp`UPDATE users SET role = 'admin' WHERE name = 'Bob'`;
throw new Error('Permission policy violation');
});
} catch (e) {
console.warn('Role change failed, continuing', e);
}
await tx`INSERT INTO audit_log (action) VALUES ('user_created')`;
});Written this way, the exception thrown inside the savepoint is caught and handled outside, so the user creation for Bob and the audit log are preserved while only the role change is rolled back. Remove the try/catch and the entire transaction rolls back.
Transaction execution flow
Connection pool reservation and release are internal implementation details — nothing to worry about at the API contract level. The flow an application developer needs to understand is the one above: BEGIN → queries → either COMMIT or ROLLBACK.
Reserving a connection — when you need session-scoped temporary tables
Temporary tables exist per connection. If a connection pool hands you a different connection each time, the temporary table you just created will be gone. Use sql.reserve() to pull out a dedicated connection for this case.
The
usingkeyword in the example below requires TypeScript 5.2 or later, withtarget/libset toES2022or higher (oresnext) intsconfig.json. In environments that don't meet this requirement, usetry/finallywith an explicitreserved.release()call instead.
{
using reserved = await sql.reserve();
await reserved`CREATE TEMP TABLE staging (id INT, val TEXT)`;
await reserved`INSERT INTO staging VALUES (1, 'test')`;
const rows = await reserved`SELECT * FROM staging`;
console.log(rows);
} // Automatically returned to the pool when the scope exitsusing is the ECMAScript Explicit Resource Management spec, and Bun supports it. Once execution leaves the scope, the connection is returned automatically with no risk of leaks.
Streaming queries — a wait-and-see situation for now
Streaming is needed when you want to process a large result set row by row without loading everything into memory at once. An API in this direction is being discussed for Bun.sql, but as of August 2026, it has not stabilized.
GitHub Issue #25307 is tracking a request for native iterator/stream SELECT support, and the proposed shape looks roughly like this.
// Conceptual form proposed in GitHub Issue #25307 — not a stable API
const stream = sql`SELECT * FROM large_table`.stream();
for await (const row of stream) {
process(row);
}If you need to process a result set of millions of rows right now, the realistic approach is to break it up with offset pagination or cursor-based pagination. At least until the streaming API stabilizes.
Trade-offs — where it works and where it hits a wall
| Item | Assessment |
|---|---|
| Removes external dependency | Simplifies application package.json (runtime trust boundary grows) |
| SQL injection prevention | Blocked at the API level via tagged template literals |
| Prepared statement caching | Enabled by default, no extra configuration needed |
| Unified API for PostgreSQL/MySQL/SQLite | Easy to switch between test (SQLite) and production (PostgreSQL) |
| Bun runtime lock-in | Same code cannot be reused on Node.js, Deno, or edge runtimes |
| Streaming API | Not yet stable; constraints on processing large result sets |
| ORM support | Drizzle ORM has an official adapter; Prisma is not supported |
| Production validation period | Fewer long-running production cases compared to pg and postgres.js |
Common mistakes in practice
Using sql directly inside a transaction. Inside a transaction callback, you must use the tx parameter received by the callback. Using the outer sql directly runs the query on a different connection, outside the transaction scope.
// Wrong
await sql.begin(async (tx) => {
await sql`INSERT INTO users (name) VALUES ('Wrong')`; // Not tx!
});
// Correct
await sql.begin(async (tx) => {
await tx`INSERT INTO users (name) VALUES ('Correct')`;
});Expecting runtime change alone to fix performance problems. If your application profile is bottlenecked on PostgreSQL I/O rather than being CPU-bound, switching runtimes will not eliminate the bottleneck. Building a roadmap on the assumption that "moving to Bun will make it faster" without measurement is risky. Always do A/B measurements on your own workload.
ORM support status
| Tool | Bun.sql support method | Status |
|---|---|---|
| Drizzle ORM | drizzle-orm/bun-sql adapter |
Officially supported |
| Kysely | kysely-bun-sql |
Community adapter |
| Prisma | None | Not supported (as of August 2026) |
Raw Bun.sql |
Tagged template literals | Built into runtime |
If you're already using Drizzle, swapping the adapter is all it takes to move over naturally. If your codebase has heavy Prisma dependency, check Prisma's Bun support roadmap before worrying about Bun.sql itself.
So when do you use it, and when do you hold off
In summary, the decision comes down to two axes: have you already moved to the Bun runtime, and is the ORM or driver you depend on compatible with Bun.sql.
If you're starting a new project on top of Bun with no Prisma dependency, using Bun.sql as your default is natural. Prepared statement caching is on by default so you get repeated query performance without separate tuning, and the callback-based transaction API structurally reduces the chance of forgetting rollback handling.
On the other hand, if you're maintaining a legacy Node.js service and migrating only some services to Bun, keeping postgres.js for cross-service code reusability may result in lower maintenance costs at the organizational level. It's not too late to judge by the standard of "a structure worth touching six months from now" rather than "optimal right this instant."
References
Official documentation and API
- Bun official SQL documentation
- Bun SQL API Reference — TransactionSQL
- Bun SQL API Reference — SQL.reserve()
- Bun SQL API Reference — SQL.transaction()
GitHub issues (ongoing discussion)
Articles and adapters