
Bun.js + Elysia + Drizzle: a type-safe REST API with PostgreSQL
A practical Bun.js API using Elysia for routing, Drizzle ORM for typed SQL, PostgreSQL for persistence, and Zod for validation.
Elysia handles HTTP routes, Drizzle describes PostgreSQL tables with TypeScript, and Bun runs the whole service without a separate transpile step.
Why this stack
Each library has one clear responsibility, so the service stays easy to test and evolve.
Elysia
A Bun-native HTTP framework with routes, middleware, and schemas.
Drizzle
A SQL-first ORM: tables are TypeScript, while queries remain close to SQL.
PostgreSQL
A reliable production store for indexes, transactions, and future growth.
Zod
Validates input at the API boundary before it reaches the service layer.
The request is validated before SQL runs, and the response returns through a typed contract.
Section stack screenshotStep 1: create the project
Bun installs dependencies and executes TypeScript without a separate bundler.
Initialize and install
mkdir tasks-api && cd tasks-api
bun init
bun add elysia drizzle-orm postgres zod
bun add -d drizzle-kitConfigure the environment
DATABASE_URL=postgres://app:app@localhost:5432/tasks
PORT=3000
DB_POOL_SIZE=10Bun loads .env automatically. Keep secrets outside source control.
Configure migrations
// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({ schema: "./src/db/schema.ts", out: "./drizzle", dialect: "postgresql", dbCredentials: { url: process.env.DATABASE_URL! } });Run bun run db:generate and bun run db:migrate; commit the generated migration.
Step 2: define the table and migration
The table becomes the source of truth for insert and select types. Do not maintain a second hand-written Task type.
// src/db/schema.ts
import { boolean, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
export const tasks = pgTable("tasks", { id: uuid("id").defaultRandom().primaryKey(), title: text("title").notNull(), done: boolean("done").notNull().default(false), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull() });
export type Task = typeof tasks.$inferSelect;
export type NewTask = typeof tasks.$inferInsert;Generate and apply the migration with bun run db:generate and bun run db:migrate.
Step 3: add typed Elysia routes
Elysia validates the request at the edge. The handler can then focus on the database operation.
import { Elysia, t } from "elysia";
import { desc, eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "./db/client";
import { tasks } from "./db/schema";
const createTask = z.object({ title: z.string().trim().min(1).max(120) });
export const app = new Elysia()
.get("/health", () => ({ status: "ok", runtime: "bun" }))
.get("/tasks", () => db.select().from(tasks).orderBy(desc(tasks.createdAt)))
.post("/tasks", async ({ body, set }) => { const input = createTask.parse(body); const [task] = await db.insert(tasks).values(input).returning(); set.status = 201; return task; }, { body: t.Object({ title: t.String({ minLength: 1, maxLength: 120 }) }) })
.patch("/tasks/:id", async ({ params, body }) => { const [task] = await db.update(tasks).set({ done: body.done }).where(eq(tasks.id, params.id)).returning(); return task ?? new Response("Not found", { status: 404 }); }, { params: t.Object({ id: t.String() }), body: t.Object({ done: t.Boolean() }) })
.listen(Number(Bun.env.PORT ?? 3000));Database client and Docker Compose
Create one connection pool per long-lived Bun process. For local development, Compose gives the team the same PostgreSQL version.
// src/db/client.ts
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
const sql = postgres(Bun.env.DATABASE_URL!, { max: Number(Bun.env.DB_POOL_SIZE ?? 10), prepare: false });
export const db = drizzle(sql);services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: tasks
ports: ["5432:5432"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d tasks"]Transactions, errors, and production rules
A fast runtime does not replace explicit data boundaries.
Use transactions for related writes
Wrap multi-table changes in db.transaction(async (tx) => { ... }).
Return 404 for missing resources
Check returning() rather than returning undefined with status 200.
Run migrations separately
Apply migrations as a deploy step; do not let every server instance mutate the schema.
Keep the pool bounded
Use a pooler or a smaller limit in serverless environments.
Test handlers without opening a port
Elysia exposes handle, so a smoke test can call the app directly.
import { expect, test } from "bun:test";
import { app } from "./index";
test("rejects an empty title", async () => {
const response = await app.handle(new Request("http://localhost/tasks", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ title: " " }) }));
expect(response.status).toBe(422);
});Run bun test against a dedicated test database.
Run and smoke-test locally
Start PostgreSQL
docker compose up -d
bun run db:migrateStart Bun
bun run src/index.ts
curl http://localhost:3000/healthUse the frozen lockfile
bun install --frozen-lockfile
bun run db:migrate
bun testFAQ
Yes. Drizzle works with Bun and PostgreSQL through the postgres driver or another supported driver. Pin and test the versions used by your deployment.
No. Bun provides the runtime, package manager, and TypeScript execution. Node.js can still be installed for other projects.
Conclusion
Describe the task — first 15 minutes of consultation are free.
Related Articles

AI Assistant Development Cost in 2026: RAG Chatbots, CRM Integrations, Guardrails, and Support
A practical buyer guide to AI assistant development cost in 2026: prototypes, RAG chatbots, knowledge-base assistants, CRM and website integrations, guardrails, evaluations, monitoring, and support.

AI for landing page development: where it speeds up launches and where it hurts conversion
A practical research piece on using AI for landing page development: v0, Webflow AI, Builder.io, Framer-like builders, UX generation, copy, SEO, personalization, A/B testing, template risk, accessibility, security and technical debt.

AI SEO / GEO in 2026: Your Next Customers Aren’t Humans — They’re Agents
Search is shifting from clicks to answers. Bots and AI agents crawl, cite, recommend, and increasingly buy. Learn what AI SEO / GEO means, why classic SEO is no longer enough, and how PAS7 Studio helps brands win visibility in the agentic web.

The most powerful Apple chip yet? M5 Pro and M5 Max are breaking records
A data-backed March 2026 analysis of Apple M5 Pro and M5 Max. We break down why these chips can credibly be called Apple's most powerful pro laptop silicon, how they compare with M4 Pro, M4 Max, M1 Pro, M1 Max, and how they stack up against Intel and AMD laptop rivals.
Professional development for your business
We create modern web solutions and bots for businesses. Learn how we can help you achieve your goals.