Bun 2.0 Postgres: Build a REST API with Zero Dependencies

Jul 5, 2026

Bun 2.0 shipped a native Postgres client, so let's build a complete CRUD API with routing, parameterized queries, and transactions without installing a single package.

Bun 2.0 dropped on July 2nd and the headline feature for me isn't Windows support or the faster installs. It's Bun.sql: a native Postgres client baked into the runtime. That means the classic small-service stack of express, pg, and dotenv is now zero packages. No node_modules, no supply chain surface, no driver version drift. The HN thread hit 900+ comments arguing about whether a runtime should ship a database driver. My take: for the thousands of tiny internal services that are just HTTP in front of Postgres, absolutely yes. Let's prove it by building a complete notes API, CRUD plus a transactional bulk import, in one file with an empty package.json. You need Bun 2.0 installed and a DATABASE_URL pointing at any Postgres instance.

server.ts
const server = Bun.serve({
  port: 3000,
  routes: {
    "/health": () => Response.json({ ok: true }),
  },

  fetch() {
    return new Response("Not found", { status: 404 });
  },
});

console.log(`Notes API on http://localhost:${server.port}`);
server.ts

Boot the server with built-in routing

Bun.serve has grown a proper routes object, so we don't need Express or Hono for path matching. Each key is a route pattern, and the value is either a handler or an object of per-method handlers. The fetch function at the bottom is the fallback for anything the router doesn't match. Save this as server.ts and run bun run server.ts, then hit /health. Notice what we did not do: no npm install, no framework, no dotenv.

Connect Postgres and create the schema

Here's the Bun 2.0 headline. Import sql from "bun" and you have a pooled Postgres client that reads DATABASE_URL from the environment automatically. No client instantiation, no pool config, no .env loader, Bun reads .env files natively too. Top-level await means we can run the schema bootstrap before the server starts taking traffic. In a real project you'd use migrations, but CREATE TABLE IF NOT EXISTS is honest for a demo.

Read notes with tagged-template queries

This is the part that sold me. That ${req.params.id} interpolation is not string concatenation, the tagged template compiles to a parameterized query with the value bound separately. SQL injection is impossible through this syntax, and you get prepared-statement performance for free. Route params come off req.params with no middleware. Query results are plain arrays of objects, so destructuring the first row for the single-note case reads naturally.

Create notes with POST and real validation

POST slots in next to GET on the same route object, which keeps related handlers together instead of scattered across router.get and router.post calls. The .catch(() => null) on req.json() is deliberate: malformed JSON should be a 400, not an unhandled rejection that 500s. RETURNING gives us the inserted row back in one round trip, so the client sees the generated id and timestamp immediately. Test it: curl -X POST localhost:3000/api/notes -d '{"title":"ship it"}'.

Update and delete with COALESCE partial updates

PATCH is where hand-written SQL usually gets ugly, dynamically building SET clauses based on which fields arrived. The COALESCE trick sidesteps all of it: bind each field or null, and COALESCE(null, title) keeps the existing value. One static query handles every combination of partial updates, and it stays a prepared statement. DELETE uses RETURNING as an existence check, so we get the 404 case without a separate SELECT.

Bulk import with a real transaction

The test of any Postgres client is transactions, and Bun.sql passes cleanly. sql.begin opens a transaction, hands you a scoped tx client, commits when the callback resolves, and rolls back automatically if it throws. So a bulk import is all-or-nothing: one bad item in the array and every prior insert is rolled back, no half-imported garbage. Note the static route "/api/notes/import" wins over the ":id" parameter route because Bun's router prefers exact matches, so no ordering hacks needed.

That's a production-shaped API, parameterized queries, partial updates, atomic bulk imports, and it installs in zero seconds because there is nothing to install. Run it with bun run server.ts. The tagged-template syntax deserves the last word: because interpolation always becomes a bound parameter, the easy path and the safe path are the same path, which is exactly how injection-resistant APIs should be designed. The honest caveats: Bun.sql is Postgres-only, and if you need a query builder or migrations you'll still reach for Drizzle, which already supports Bun.sql as a driver. But for the long tail of small services where pg plus express plus dotenv was pure ceremony, Bun 2.0 just deleted three dependencies and an entire class of lockfile audits from my life. I'm not going back.

#code#tutorials