Working with PostgreSQL Without `pg` — Building a Prepared Statement, Transaction, and Type-Safe Query Layer with Bun.sql
When you run a Node.js backend for a long time, you start to feel package.json getting heavier bit by bit. You install pg, pg-pool, and @types/pg just to connect PostgreSQL, then add an ORM on top for type safety, and before you know it your dependency tree has ballooned to hundreds of packages. I often found myself thinking "when will I ever be able to cut this down?" — and then Bun 1.2 came along and changed that calculus considerably.
Bun 1.2 bundled a native PostgreSQL client called Bun.sql directly into the runtime. No npm install pg required, and no separate connection pool library to configure. Even more interesting is that automatic prepared statements, a transaction callback API, and TypeScript generic return types are all built right in. In this post, we'll look at how Bun.sql actually works, how it handles prepared statements and transactions, and patterns for layering a type-safe query layer on top.
What it means to use PostgreSQL without pg
Implementing the wire protocol directly in Zig
Bun.sql is not simply a wrapper on top of pg. It reimplements the PostgreSQL Wire Protocol in the Zig language and embeds it directly into the Bun runtime. To be precise about what "zero dependencies" means here — it means there is no external driver package that needs to be installed via npm. The Bun runtime itself is of course still required.
The API design references postgres.js, so if you're already using postgres.js, changing just the import path will keep most of your code working as-is.
// Existing postgres.js
import postgres from "postgres";
const sql = postgres(process.env.DATABASE_URL);
// Switching to Bun.sql
import { sql } from "bun";
// Automatically reads the DATABASE_URL or POSTGRES_URL environment variableThe real reason tagged template literals prevent SQL injection
sql`SELECT * FROM users WHERE id = ${userId}` looks like the value is being interpolated directly into the string, but that's not what actually happens. An earlier draft explained this as "safe because TemplateStringsArray is fixed as a constant array," but that gets the causality backwards. The fact that the array is fixed doesn't itself prevent injection — rather, the Bun.sql implementation uses this structure to send the SQL text and parameter values over separate channels via the PostgreSQL Extended Query Protocol.
In other words, the server receives the "query text" and "binding parameters" as separate messages, and the parameter values never pass through the parser. Injection is prevented because there is no path for user input to reach the parser through string concatenation in the first place — the tagged template syntax itself isn't magic. If you misunderstand this and call it like sql(`SELECT * FROM users WHERE id = ${userId}`) with ordinary string concatenation, the protection breaks.
import { sql } from "bun";
// Whatever string userId is, it's delivered only through the parameter channel
// Even a value like '1; DROP TABLE users' never reaches the parser
const users = await sql<{ id: number; name: string }>`
SELECT id, name FROM users WHERE id = ${userId}
`;Automatic Prepared Statements — Performance gains with zero configuration
Honestly, when I first read the docs, I was skeptical that "automatically becomes a prepared statement" was real. It sounded like marketing copy, but when I dug into the mechanics, there was a concrete mechanism behind it.
How it works
When a query is executed for the first time, Bun sends a Parse message to the PostgreSQL server. The server performs parsing and planning at this point and caches the execution plan. When the same query structure is called again with different parameters, only a Bind + Execute is sent to the stored statement, so the parsing and planning steps are not repeated.
import { sql } from "bun";
async function getUserByEmail(email: string) {
// On the first call, the execution plan is cached on the server
// Subsequent calls reuse it with only the parameter changed
const [user] = await sql<{ id: number; name: string; email: string }>`
SELECT id, name, email FROM users WHERE email = ${email} LIMIT 1
`;
return user ?? null;
}This effect is most pronounced for repeated queries where the query structure stays the same and only the values change, like looking up by email. Since concrete performance numbers vary significantly by workload and schema, it's safer to run benchmarks against your own query patterns before adopting it in production.
Things to watch out for when using PGBouncer
In environments using PGBouncer transaction mode, these automatic prepared statements can cause conflicts. Because PGBouncer redistributes connections from the pool rather than pinning them per client, the server-side state of a prepared statement created on one connection doesn't exist on another.
Bun provides ways to disable prepared statements per query as a workaround (e.g., the .simple() method or a prepare: false connection option). However, since the exact API names and their introduction timing vary between releases, it's recommended to check the official SQL docs for your current version of Bun before applying this in a real project.
There's also an infrastructure-level solution. According to Crunchy Data's writeup, PGBouncer 1.21+ supports prepared statements in transaction mode, so upgrading PGBouncer is often cleaner than manually toggling the option in application code.
Transaction handling — How sql.begin() actually works
Atomic transactions
sql.begin() internally reserves a dedicated connection from the connection pool and automatically sends BEGIN. If an exception is thrown from the callback, it automatically sends ROLLBACK; if the callback completes normally, it sends COMMIT.
async function transferFunds(fromId: number, toId: number, amount: number) {
return sql.begin(async (tx) => {
const [from] = await tx`
UPDATE accounts SET balance = balance - ${amount}
WHERE id = ${fromId} AND balance >= ${amount}
RETURNING *
`;
if (!from) throw new Error("Insufficient balance");
const [to] = await tx`
UPDATE accounts SET balance = balance + ${amount}
WHERE id = ${toId}
RETURNING *
`;
return { from, to };
// If an exception is thrown here, both UPDATEs are ROLLBACKed
});
}Specifying transaction access mode
The first argument to sql.begin() accepts a string to specify transaction options. One concept worth clarifying here: READ WRITE is a PostgreSQL access mode, not an isolation level. Isolation levels are SERIALIZABLE, REPEATABLE READ, READ COMMITTED, and READ UNCOMMITTED. Mixing these two concepts up makes tuning confusing, so it's worth keeping them separate.
// Specifying access mode
const [user, account] = await sql.begin("read write", async (tx) => {
const [user] = await tx`
INSERT INTO users (name) VALUES (${"Alice"}) RETURNING *
`;
const [account] = await tx`
INSERT INTO accounts (user_id) VALUES (${user.id}) RETURNING *
`;
return [user, account];
});
// Specifying isolation level along with access mode
await sql.begin("isolation level serializable, read write", async (tx) => {
// ...
});Transaction flow diagram
If you need distributed transactions
Sometimes you need two-phase commit (2PC) across multiple PostgreSQL instances. Whether Bun.sql exposes a dedicated helper for this (e.g., an API analogous to beginDistributed in the postgres.js family) depends on your Bun version and release notes. Even if no such helper is available, you can implement 2PC directly using PostgreSQL's own PREPARE TRANSACTION / COMMIT PREPARED / ROLLBACK PREPARED commands, so regardless of API availability, the protocol itself has you covered.
Layering a type-safe query layer
Simple return type annotation with just generics
By using Bun.sql's generic parameter, you can annotate the return row type without a separate ORM.
import { sql } from "bun";
interface User {
id: number;
name: string;
email: string;
created_at: Date;
}
// Basic usage — specify return row type with generics
const users = await sql<User[]>`
SELECT * FROM users WHERE created_at > ${new Date("2025-01-01")}
`;
// Using destructuring when you need a single row
const [user] = await sql<User[]>`
SELECT * FROM users WHERE id = ${userId} LIMIT 1
`;
// user: User | undefinedThe limitation of this approach is clear: it cannot automatically infer query result columns at compile time. Even if you query only a subset of columns like SELECT id, name FROM users, it will be treated as the developer-specified User type, so the type is present but may not match the actual result. If you need column-level safety, you'll need a builder like Drizzle or Kysely.
Things to consider before wrapping
An earlier draft showed a thin typedQuery wrapper example for improved reusability, but on reflection its practicality is questionable. The object returned by Bun.sql is a special form that is both a Promise and exposes chaining methods like .execute(), .simple(), and .values(). Force-casting it to Promise<T[]> loses the chaining API, and code that added options before await can silently break.
So I prefer specifying the return type at the repository function level and using chaining inside when needed, rather than a thin cast wrapper.
async function findUsersSince(date: Date): Promise<User[]> {
return sql<User[]>`
SELECT id, name, email, created_at
FROM users
WHERE created_at > ${date}
ORDER BY created_at DESC
`;
}Combining with Drizzle ORM — when you need column-level type safety
If you want type annotations down to the column level, one option is to combine Drizzle ORM's Bun SQL adapter. The structure has Drizzle handling schema definition, migrations, and type-safe query building, while Bun.sql acts as the actual execution driver. Since exact export paths and function signatures for the Drizzle adapter vary by version, check the official docs when actually adopting it.
// Conceptual example — refer to the latest Drizzle docs for the actual API
import { drizzle } from "drizzle-orm/bun-sql";
import { sql } from "bun";
import { eq } from "drizzle-orm";
import { users } from "./schema";
const db = drizzle(sql);
const activeUsers = await db
.select()
.from(users)
.where(eq(users.active, true));There's no clear objective data on how widely the Bun.sql + Drizzle combination is used in practice, but given that Drizzle officially supports the Bun SQL adapter, it's certainly positioning itself as one of the leading candidates in a Bun-first stack.
Trade-offs — An honest assessment
Advantages
| Item | Details |
|---|---|
| Reduced dependencies | No external npm drivers like pg, pg-pool, @types/pg. PostgreSQL connectivity with just the Bun runtime installed |
| Performance | Native Zig implementation optimizes pipelining and protocol handling. Since quantitative numbers vary by workload, check the official release notes alongside your own benchmarks |
| SQL injection protection | Parameter-separated transmission via Extended Query Protocol blocks injection paths when using tagged templates |
| Connection pool | Provided out of the box without a separate pooling library |
| Cold start | No external driver loading — advantageous for serverless and edge environments |
Disadvantages and considerations
| Item | Details |
|---|---|
| Bun-only | Does not work on Node.js. Locks you into Bun as your runtime |
| PGBouncer compatibility | Prepared statement conflicts possible in transaction mode. Requires disabling via application options or resolving at the infrastructure level with PGBouncer 1.21+ |
| Type inference limitations | Column-level automatic inference not available. Full compile-time type safety requires a separate builder like Drizzle or Kysely |
| Distributed transaction API | Whether a 2PC helper is separately exposed needs to be verified per version. Even without one, PREPARE TRANSACTION can be executed directly |
| Ecosystem maturity | Plugin and middleware ecosystem is relatively thin compared to the pg driver |
Common pitfalls in practice
1. Using sql directly inside a transaction callback
Inside the callback, use tx instead of sql. Using sql executes outside the transaction.
// Wrong — using sql instead of tx executes outside the transaction
await sql.begin(async (tx) => {
await sql`UPDATE accounts SET balance = 0 WHERE id = ${id}`; // Dangerous!
});
// Correct
await sql.begin(async (tx) => {
await tx`UPDATE accounts SET balance = 0 WHERE id = ${id}`;
});2. Inserting dynamic table or column names
Tagged template literals only parameterize values, so table names and column names cannot be inserted that way. Bun.sql provides a separate escape helper for identifiers, but the exact function name (e.g., the form of a sql() call) may vary by release, so check the current signature in the official SQL API docs before using it. In any case, avoid concatenating raw user input strings directly as identifiers.
3. The cost of globally disabling prepare: false
Disabling prepared statements for every query for PGBouncer compatibility means paying parsing and planning costs on every repeated query. It's better to apply it selectively only to queries that truly need it, or upgrade to PGBouncer 1.21+ to resolve it at the infrastructure level.
A decision framework for adoption
Beyond the technical conditions covered in the trade-offs table, there are a few additional axes worth considering in the context of your team and project.
In summary, there are four main axes to evaluate:
- Runtime strategy: If you need to run both Bun and Node.js side by side,
Bun.sqlwill hurt code portability, making it premature at this point. - Team's Bun operations experience: If your team has never run Bun in production, rather than putting it directly into a critical service, it's better to start with a batch job or internal tool first.
- Legacy migration cost: If your existing
pg+ ORM combination is running stably and the dependency burden isn't significant, there isn't a strong incentive to change. On the other hand, places like serverless functions where cold start is a bottleneck are prime candidates. - Required level of type safety: If it's mostly simple CRUD, generics alone may be sufficient, but in domains with frequent joins and partial column queries, it's better to plan from the start on pairing with a builder like Drizzle.
You might feel uneasy at the thought of no pg driver, but having used it in practice, the API is nearly identical to postgres.js, so the adaptation cost is lower than expected. If you have existing code, simply changing the import path and running it in a test environment is more than enough as a first step.
References
- Bun Official SQL Docs
- Bun 1.2 Official Blog Release Notes
- Drizzle ORM — Getting Started with Bun SQL Adapter
- Crunchy Data: Prepared Statements in Transaction Mode for PGBouncer
- PostgreSQL Docs: Extended Query Protocol
- PostgreSQL Docs: SET TRANSACTION (Access Mode and Isolation Level)
- PostgreSQL Docs: PREPARE TRANSACTION (Two-Phase Commit)