Skip to content

Search docs

Jump between documentation pages.

Browse docs

Use Drizzle ORM with DaloyJS

Drizzle ORM is a lightweight, TypeScript-native ORM with a SQL-like API. It runs everywhere DaloyJS does, including Cloudflare Workers, and infers result types directly from your schema.

One request through Drizzle
  1. 01clientHTTP requestGET /users/:id
  2. 02zodValidated inputparams.id is a uuid
  3. 03drizzleTyped queryselect().from(users).where(eq(...))
  4. 04responseTyped body200 UserSchema | 404
Zod validates the request before your handler runs, Drizzle runs a SQL-like query off state.db with result types inferred from your schema, then the response schema checks the body on the way out.

1. Install

bash
pnpm add drizzle-orm postgres
pnpm add -D drizzle-kit

2. Define your schema

src/db/schema.tsts
import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core";

export const users = pgTable("users", {
  id: uuid("id").defaultRandom().primaryKey(),
  email: text("email").notNull().unique(),
  name: text("name"),
  createdAt: timestamp("created_at").defaultNow().notNull(),
});
drizzle.config.tsts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  schema: "./src/db/schema.ts",
  out: "./drizzle",
  dialect: "postgresql",
  dbCredentials: { url: process.env.DATABASE_URL! },
});

Generate migrations in development and apply them as a deploy step, never at app boot (see migrations):

bash
pnpm drizzle-kit generate
pnpm drizzle-kit migrate

3. Create a Drizzle plugin

src/db/drizzle.tsts
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import type { App } from "@daloyjs/core";
import * as schema from "./schema.ts";

// Add `prepare: false` only behind a transaction-mode pooler
// (PgBouncer in transaction mode, Supabase's transaction pooler).
const client = postgres(process.env.DATABASE_URL!, { max: 10 });
export const db = drizzle({ client, schema });

// Call with the root app, not through app.register(): a decoration made
// inside a registered plugin is visible only to that plugin's own routes.
export function drizzlePlugin(app: App) {
  app.decorate("db", db);
  app.onClose(async () => {
    await client.end({ timeout: 5 });
  });
}

onClose() runs on the Node.js, Bun, and Deno adapters only. On Lambda, Vercel, or Azure Functions keep max at 1 or close to it, and on Workers build the client per request; see Serverless and edge lifecycle.

4. Augment app state types

Add the declare module block to the same module that exports db, not to a separate .d.ts file. Declaration files are exempt from type-checking when skipLibCheck is on (the scaffolded default), so a broken import inside a .d.ts fails silently and state.db degrades to any.

src/db/drizzle.tsts
// same module as the plugin above
declare module "@daloyjs/core" {
  interface AppState {
    db: typeof db;
  }
}

5. Use it in routes

src/server.tsts
import { z } from "zod";
import { eq } from "drizzle-orm";
import { App, NotFoundError } from "@daloyjs/core";
import { serve } from "@daloyjs/core/node";
import { drizzlePlugin } from "./db/drizzle.ts";
import { users } from "./db/schema.ts";

const app = new App();
drizzlePlugin(app);

// Dates go over the wire as ISO strings: z.coerce.date() has no
// representation in the generated OpenAPI document.
const UserSchema = z.object({
  id: z.uuid(),
  email: z.email(),
  name: z.string().nullable(),
  createdAt: z.iso.datetime(),
});

type UserRow = typeof users.$inferSelect;
const toUser = (row: UserRow) => ({ ...row, createdAt: row.createdAt.toISOString() });

app.get(
  "/users/:id",
  {
    operationId: "getUser",
    request: { params: z.object({ id: z.uuid() }) },
    responses: {
      200: { description: "Found", body: UserSchema },
      404: { description: "Not found" },
    },
  },
  async ({ params, state }) => {
    const [user] = await state.db.select().from(users).where(eq(users.id, params.id)).limit(1);
    if (!user) throw new NotFoundError("User not found");
    return { status: 200, body: toUser(user) };
  },
);

app.post(
  "/users",
  {
    operationId: "createUser",
    request: { body: z.object({ email: z.email(), name: z.string().optional() }) },
    responses: { 201: { description: "Created", body: UserSchema } },
  },
  async ({ body, state }) => {
    // Safe only because z.object() strips unknown keys (no mass assignment).
    const [created] = await state.db.insert(users).values(body).returning();
    return { status: 201, body: toUser(created) };
  },
);

await app.ready();
serve(app, { port: 3000 });

Transactions

This example assumes orders and inventory tables in schema.ts and a route that declares 201. Throwing inside the callback rolls back.

ts
import { eq, sql } from "drizzle-orm";
import { inventory, orders } from "./db/schema.ts";

handler: async ({ body, state }) => {
  const order = await state.db.transaction(async (tx) => {
    const [created] = await tx.insert(orders).values({ sku: body.sku, qty: body.qty }).returning();
    await tx
      .update(inventory)
      .set({ stock: sql`${inventory.stock} - ${body.qty}` })
      .where(eq(inventory.sku, body.sku));
    return created;
  });
  return { status: 201, body: order };
}

Interactive db.transaction() needs a driver that holds a database session open. The Neon HTTP driver (drizzle-orm/neon-http) and Cloudflare D1 do not: use db.batch([...]) there, which runs the statements as one atomic batch, or switch to the WebSocket-based drizzle-orm/neon-serverless driver with a Pool when you need interactive transactions on Neon.

Edge runtimes

Drizzle is the easiest path to running DaloyJS against a real database on the edge. Pick a driver:

  • Cloudflare Workers + D1: drizzle-orm/d1
  • Cloudflare Workers + Postgres through Hyperdrive: drizzle-orm/postgres-js (or drizzle-orm/node-postgres) with a client created per request. Keep prepared statements on with Hyperdrive.
  • Neon (Postgres) over HTTP on any edge: drizzle-orm/neon-http
  • PlanetScale (MySQL): drizzle-orm/planetscale-serverless
src/worker.tsts
// Cloudflare Workers + D1
import { env } from "cloudflare:workers";
import { drizzle } from "drizzle-orm/d1";
import { App } from "@daloyjs/core";
import { toFetchHandler } from "@daloyjs/core/cloudflare";
import * as schema from "./db/schema.ts";

// `wrangler types` generates the global `Env` interface from your wrangler
// config, which types `env.DB` as a D1 binding.
const app = new App();

// A D1 binding is not a socket, so one Drizzle instance can serve every
// request. Decorate once, before the routes: calling decorate() inside the
// Worker fetch handler throws on the second request. Drizzle's D1 docs pass
// the binding as the first argument rather than as `{ client }`.
app.decorate("db", drizzle(env.DB, { schema }));

// ...register routes that read state.db

export default toFetchHandler(app);

For Hyperdrive, build the client in a hook instead, as shown in Serverless and edge lifecycle.

Compare with Prisma, TypeORM, MikroORM, Sequelize, Kysely, Supabase, or the ODM overview for document databases.

Drizzle pairs cleanly with every host in the database hosting overview, including Neon, PlanetScale, Turso, and Cloudflare D1.

Edit this page on GitHub

Last updated