After removing `pg` and `aws-sdk` — Connecting to PostgreSQL and object storage without external drivers using Bun.sql·Bun.s3
When you run a Node.js project for a long time, package.json quietly grows thick. Using PostgreSQL brings in pg, managing the connection pool drags in pg-pool, and @types/pg gets added for type safety. It's also common to start a task uploading a single file to S3 and end up going through @aws-sdk/client-s3 and @aws-sdk/lib-storage. Each dependency was a choice, but at some point it starts to feel like shackles.
This was the part that caught my eye in the Bun v1.2 release in January 2025. The runtime itself began providing built-in clients for querying PostgreSQL and uploading files to S3 — no npm install required. Bun.sql and Bun.s3 — when I first saw these two APIs, I wondered "but is it actually usable?", and after migrating an entire side project to Bun recently, the answer became quite clear.
This post retraces that experience step by step. From the moment of setting environment variables and firing the first query, to the API naming issue I hit when wrapping a transaction, to building an S3 upload endpoint, to the pitfalls of connecting to R2/MinIO, and finally to adding Drizzle on top. The goal of this post is to talk through the traps encountered at each step, right where they happened.
A diagram before we start
Looking at the dependency diagram makes it clear what Bun has absorbed.
It's a structure where the runtime absorbs the role previously played by external packages. A single line import { sql } from 'bun' gets you a PostgreSQL client including a connection pool. Let's move on to actual code.
Step 1 — Fire the first query with a single environment variable
If DATABASE_URL is set, you can query immediately without any separate connection configuration.
import { sql } from "bun";
// SELECT
const users = await sql`SELECT * FROM users WHERE active = ${true} LIMIT ${10}`;
// INSERT
const [newUser] = await sql`
INSERT INTO users (name, email)
VALUES (${name}, ${email})
RETURNING *
`;
// UPDATE
await sql`UPDATE posts SET views = views + 1 WHERE id = ${postId}`;In the Tagged Template Literal syntax, values inside ${} are automatically treated as parameter bindings. This is a structural way to prevent SQL injection — the key point is that there is "no way to forget" to escape. It looks like ordinary string interpolation at first glance, which made me suspicious, but in practice the values are completely separated at the driver level.
When you need an explicit client instance, pass a connection string to the SQL class. We'll use this approach again later when integrating with Drizzle.
import { SQL } from "bun";
const client = new SQL(process.env.DATABASE_URL!);
const posts = await client`SELECT * FROM posts WHERE author_id = ${userId}`;Detailed options like pool size and timeout can have different key names depending on the version, so it's safer to check the Bun SQL official docs.
Step 2 — The API naming issue encountered in transactions
This is where my first pitfall was. I tried wrapping with sql.transaction() and got an error that the method doesn't exist. Bun.sql follows the API design of postgres.js, where the transaction entry point is named begin.
await sql.begin(async (tx) => {
await tx`UPDATE accounts SET balance = balance - ${amount} WHERE id = ${fromId}`;
await tx`UPDATE accounts SET balance = balance + ${amount} WHERE id = ${toId}`;
});The tx received inside the callback shares the same connection and transaction context. If an exception occurs, it automatically rolls back. tx.savepoint() is also available if you need savepoints — check the Transactions section of the Bun SQL docs for the exact signature.
Pitfall summary: postgres.js/Bun.sql uses .begin(), while pg uses the client.query('BEGIN') style. When in doubt, open the docs before you start.
Step 3 — Uploading the first file with Bun.s3
Bun.s3 also reads environment variables automatically. Just having AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and S3_BUCKET is enough to create a default instance.
// Auto-reads environment variables
const file = Bun.s3.file("images/photo.jpg");
const buffer = await file.arrayBuffer();When you need an explicit instance, create Bun.S3Client directly.
const s3 = new Bun.S3Client({
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
bucket: process.env.S3_BUCKET,
region: "ap-northeast-2",
});
// Upload
await s3.write("uploads/avatar.png", imageBuffer);
// Download
const data = await s3.file("uploads/avatar.png").arrayBuffer();Because S3File objects behave like the Web API's Blob, you can pass them directly to Response or Request. This is where streaming response handling becomes natural.
Presigned URL
You can generate both PUT for direct client uploads and GET for downloads. Option key names including content type specification can vary by version, so it's recommended to check the presign entry in the S3Client reference alongside.
// Download
const downloadUrl = s3.presign("uploads/doc.pdf", {
method: "GET",
expiresIn: 600,
});
// Upload (see reference for content type and other option keys)
const uploadUrl = s3.presign("uploads/doc.pdf", {
method: "PUT",
expiresIn: 3600,
});Step 4 — Assembling an upload endpoint in an HTTP server
Combining Bun.serve and Bun.s3 gives you a complete multipart upload endpoint with no external middleware.
const s3 = new Bun.S3Client({
bucket: process.env.S3_BUCKET,
region: process.env.AWS_REGION,
});
Bun.serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url);
if (req.method === "POST" && url.pathname === "/upload") {
const formData = await req.formData();
const file = formData.get("file") as File;
if (!file) {
return Response.json({ error: "No file provided" }, { status: 400 });
}
const key = `uploads/${crypto.randomUUID()}-${file.name}`;
await s3.write(key, file);
const downloadUrl = s3.presign(key, { method: "GET", expiresIn: 3600 });
return Response.json({ key, url: downloadUrl });
}
return new Response("Not Found", { status: 404 });
},
});What felt most convenient in practice was req.formData() working as-is, without middleware like multer.
Step 5 — Handling orphan records when combining SQL and S3
There's a pattern that comes up frequently in production. "Upload a file to S3, then store its metadata in the DB." Written naively, it looks like this:
async function uploadFileNaive(file: File, userId: number) {
const key = `files/${userId}/${crypto.randomUUID()}-${file.name}`;
await s3.write(key, file);
const [record] = await sql`
INSERT INTO file_uploads (key, name, size, user_id, uploaded_at)
VALUES (${key}, ${file.name}, ${file.size}, ${userId}, NOW())
RETURNING id
`;
return { key, id: record.id };
}The problem is when the S3 upload succeeds but the DB INSERT fails. An orphan object is left in S3. Putting the S3 upload inside sql.begin() ties up the connection for a long time due to network I/O inside the transaction, but leaving it unattended means your storage bill quietly grows.
The pattern I actually use is to first write a pending-status record to the DB, then promote it to committed on upload success.
async function uploadFileWithPending(file: File, userId: number) {
const key = `files/${userId}/${crypto.randomUUID()}-${file.name}`;
const [record] = await sql`
INSERT INTO file_uploads (key, name, size, user_id, status, created_at)
VALUES (${key}, ${file.name}, ${file.size}, ${userId}, 'pending', NOW())
RETURNING id
`;
try {
await s3.write(key, file);
await sql`UPDATE file_uploads SET status = 'committed', uploaded_at = NOW() WHERE id = ${record.id}`;
return { key, id: record.id };
} catch (err) {
await sql`UPDATE file_uploads SET status = 'failed' WHERE id = ${record.id}`;
throw err;
}
}With this structure, a cleanup job helps in two directions:
- Records where
status = 'pending'has persisted beyond a certain time → state transition was cut off by an app crash. Either delete the corresponding S3 object or, if it actually exists, recover it to committed. - Records with
status = 'failed'→ delete the S3 object and archive the record.
If periodic batch jobs feel burdensome, you can also combine S3 lifecycle rules to auto-expire objects under a specific prefix.
Step 6 — Pitfalls with R2 and MinIO
Bun.s3 can connect to any S3-compatible storage by simply changing the endpoint. This is where I spent some time on URL style issues.
- MinIO local server: By default, it only accepts path-style URLs (the bucket is not appended to the host header). Requests made in virtual-hosted style end up with errors like "SignatureDoesNotMatch".
- Cloudflare R2: Supports both virtual-hosted style and path-style, but depending on custom domains or region issues, path-style can be a better fit. It's not "required," but it's closer to "a safe switch to leave on."
Check the S3Client reference for the option key name that controls this behavior in Bun.S3Client. The name may differ from AWS SDK's forcePathStyle, so it's worth opening the docs before copying code.
The connection code itself follows this skeleton (verify option keys at the link above):
// Cloudflare R2 (conceptual example)
const r2 = new Bun.S3Client({
endpoint: `https://${process.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`,
accessKeyId: process.env.R2_ACCESS_KEY,
secretAccessKey: process.env.R2_SECRET_KEY,
bucket: process.env.R2_BUCKET,
});
// MinIO local (path-style required)
const minio = new Bun.S3Client({
endpoint: "http://localhost:9000",
accessKeyId: "minioadmin",
secretAccessKey: "minioadmin",
bucket: "dev-bucket",
});Other S3-compatible storages like Backblaze B2 and DigitalOcean Spaces connect with the same structure.
Step 7 — Adding Drizzle ORM
If you need a type-safe schema and migrations, you can layer Drizzle on top of Bun.sql. The Drizzle official docs have a Bun SQL connection guide with the required adapter versions and import paths laid out, so keep it open when you start.
bun add drizzle-orm
bun add -d drizzle-kit// schema.ts
import { pgTable, serial, text, boolean, timestamp } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: serial("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
active: boolean("active").default(true),
createdAt: timestamp("created_at").defaultNow(),
});// db.ts
import { SQL } from "bun";
import { drizzle } from "drizzle-orm/bun-sql";
import * as schema from "./schema";
const client = new SQL(process.env.DATABASE_URL!);
export const db = drizzle({ client, schema });// Usage example
import { db } from "./db";
import { users } from "./schema";
import { eq } from "drizzle-orm";
const activeUsers = await db.select().from(users).where(eq(users.active, true));Because you pass the new SQL() instance directly to Drizzle, it works without a pg adapter. Prisma's Bun support is in progress on their roadmap and community adapters exist, but since specific packages can go unmaintained, it's safer to check Prisma's official Bun support status as you go. As of July 2026, Drizzle is the most stable option from a practical standpoint.
Revisiting the tradeoffs
Summarizing the experience across each step, the pros and cons of Bun.sql and Bun.s3 break down like this:
What you gain
- Zero dependencies: Works without
pg,postgres.js, oraws-sdk.package.jsongets lighter, and the supply chain attack surface shrinks. - Unified API: Handles PostgreSQL, MySQL, and SQLite with the same Tagged Template syntax. The MySQL/MariaDB integration can be traced to its introduction in the Bun v1.2.21 release notes.
- Structural SQL injection prevention: Parameters are separated at the driver level, making escaping mistakes structurally impossible.
- Performance: Benchmarks are published in the Bun v1.2 release notes. The comparison target matters, though. Official numbers are mostly against Node.js +
pg. If you're already running postgres.js on Bun, the benchmark gap shrinks considerably, and in environments where actual network latency dominates, the real-world difference may be negligible. Not distinguishing between the two baselines will lead to mismatched expectations. - Single binary deployment: You can create an executable including drivers with
bun build --compile.
What you accept
- Bun runtime requirement: Cannot run on Node.js. The entire team switches runtimes.
- Last few percent of Node.js compatibility: Most things run fine, but packages with
node-gyp-based native addons can be an actual blocker. - Observability ecosystem: APM/tracing tool support for Bun is not as broad as for Node.js. You need to check the Bun support status of your tools before adopting in production.
- Limited ORM choices: Drizzle is most stable, Prisma is in progress, TypeORM is not fully compatible.
- MySQL maturity: Integration arrived later than PostgreSQL, and production validation cases are relatively fewer.
- Some newer API coverage: New S3-related APIs may not be reflected in Bun.s3 immediately. Search oven-sh/bun GitHub issues to reliably check whether a needed API is supported.
The branching logic needed to decide on migration looks like this:
So, what should you check right now?
Instead of a safe conclusion like "start gradually with a new project," I'll leave one specific question to close with.
Does your production deployment pipeline have a node-gyp build step?
bcrypt, sharp, sqlite3 (meaning the old bindings, not better-sqlite3), in-house native addons — if any of these apply, the first gate of a Bun migration is replacing the addon. If none of them apply, you can set a single DATABASE_URL and fire your first query locally in under 30 minutes. Those 30 minutes are the fastest way to judge whether this post is actually useful.
Seeing the diff where pg and @aws-sdk/* disappear from package.json is reason enough to run that experiment.