effidevFlutter · Cloudflare edge · Cloud cost optimization
English

Drizzle ORM + Cloudflare D1: 99% Smaller Bundle than Prisma

Drizzle ORM and Cloudflare D1 edge SQLite architecture diagram

Why ORMs Are a Problem on the Edge

Have you ever used Prisma on Cloudflare Workers only to find your cold start times ridiculously slow? The reason is simple: Prisma’s bundle size is 1.6 MB. Although Workers has a 10 MB script size limit, bundling a 1.6 MB ORM means hundreds of milliseconds evaporate during every cold start.

Drizzle ORM was designed with the exact opposite philosophy. Its bundle size is just 12.2 KB (min+gzip)—99.2% smaller than Prisma. There is no code generation step and no binary or Rust engine. It wraps SQL using pure TypeScript, making it run exceptionally well in edge runtimes.

In this guide, we’ll walk step-by-step through building a fully type-safe edge database architecture by combining Drizzle ORM and Cloudflare D1. You’ll build a REST API with Hono, manage migrations with drizzle-kit, and set up your local development environment.

Drizzle ORM vs. Prisma: Edge Environment Comparison

Comparing both ORMs in a real production environment reveals stark differences:

Metric Drizzle ORM Prisma 7+
Bundle Size (min+gzip) 12.2 KB 1.6 MB
Cold Start Impact Minimal (~50-100ms) Low (~80-150ms)
Edge Runtime Support First-class Supported from v7
Code Generation Step None Yes (prisma generate)
Type Inference Direct TypeScript Inference Generated Client
SQL Control Level High (SQL-first) Medium (Abstraction)
Official D1 Driver drizzle-orm/d1 None (Community)
Learning Curve Medium Low

While Prisma 7 did remove the Rust engine in favor of a pure TypeScript/WASM approach, a 130x difference in bundle size remains. In an environment like Cloudflare Workers where cold starts can trigger on any request, this difference is crucial.

What Is Cloudflare D1?

D1 is Cloudflare’s serverless SQLite database. Because you access it directly via Workers bindings, there is zero TCP connection overhead. It supports global read replication and offers 5 million query reads per day even on the free plan.

Cloudflare Workers Request


  D1 Binding (env.DB)
        │ (IPC within same datacenter)

  SQLite Database
  (D1 Serverless Instance)

The key point is that you access D1 through bindings without HTTP request overhead. Latency is roughly one-tenth that of an external database connection.

Initial Project Setup

1. Hono + Drizzle + D1 Scaffolding

# Create Hono Worker template
npm create cloudflare@latest my-d1-app -- --template hono

cd my-d1-app

# Install Drizzle
npm install drizzle-orm
npm install -D drizzle-kit

2. Creating a D1 Database

# Create D1 database
npx wrangler d1 create my-app-db

# Example output:
# [[d1_databases]]
# binding = "DB"
# database_name = "my-app-db"
# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

3. Configuring wrangler.jsonc

// wrangler.jsonc
{
  "name": "my-d1-app",
  "main": "src/index.ts",
  "compatibility_date": "2025-01-01",
  "compatibility_flags": ["nodejs_compat"],
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "my-app-db",
      "database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
      "migrations_dir": "drizzle/migrations"
    }
  ]
}

4. Configuring drizzle.config.ts

// drizzle.config.ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  dialect: "sqlite",
  schema: "./src/db/schema.ts",
  out: "./drizzle/migrations",
  driver: "d1-http",
  dbCredentials: {
    accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
    databaseId: process.env.CLOUDFLARE_DATABASE_ID!,
    token: process.env.CLOUDFLARE_D1_TOKEN!,
  },
});

Local Development Tip: In local development, you use wrangler d1 migrations apply, so driver and dbCredentials in drizzle.config.ts are strictly for production remote migrations.

Schema Definition: TypeScript-First

Drizzle’s primary strength is combining schema definitions and type inference into a single step. You don’t need a separate code generation step like prisma generate.

// src/db/schema.ts
import {
  sqliteTable,
  text,
  integer,
  real,
  blob,
} from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";

// Users table
export const users = sqliteTable("users", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  email: text("email").notNull().unique(),
  name: text("name").notNull(),
  role: text("role", { enum: ["admin", "user", "guest"] })
    .notNull()
    .default("user"),
  createdAt: text("created_at")
    .notNull()
    .default(sql`(datetime('now'))`),
  updatedAt: text("updated_at")
    .notNull()
    .default(sql`(datetime('now'))`),
});

// Posts table
export const posts = sqliteTable("posts", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  title: text("title").notNull(),
  slug: text("slug").notNull().unique(),
  content: text("content").notNull(),
  authorId: integer("author_id")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  published: integer("published", { mode: "boolean" }).default(false),
  viewCount: integer("view_count").default(0),
  publishedAt: text("published_at"),
  createdAt: text("created_at")
    .notNull()
    .default(sql`(datetime('now'))`),
});

// Tags table (M:N relationship)
export const tags = sqliteTable("tags", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  name: text("name").notNull().unique(),
  slug: text("slug").notNull().unique(),
});

export const postTags = sqliteTable("post_tags", {
  postId: integer("post_id")
    .notNull()
    .references(() => posts.id, { onDelete: "cascade" }),
  tagId: integer("tag_id")
    .notNull()
    .references(() => tags.id, { onDelete: "cascade" }),
});

// Type inference — usable immediately without code generation
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
export type Tag = typeof tags.$inferSelect;

The key difference from Prisma: there’s no need to run prisma generate. The schema file itself is the single source of truth for TypeScript types. IDE autocompletion works out of the box.

Migration Workflow

Generating SQL Migration Files

npx drizzle-kit generate

This command generates SQL files in the drizzle/migrations/ folder:

-- drizzle/migrations/0000_initial.sql
CREATE TABLE `users` (
  `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
  `email` text NOT NULL,
  `name` text NOT NULL,
  `role` text DEFAULT 'user' NOT NULL,
  `created_at` text DEFAULT (datetime('now')) NOT NULL,
  `updated_at` text DEFAULT (datetime('now')) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);
--> statement-breakpoint
CREATE TABLE `posts` (
  `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
  `title` text NOT NULL,
  `slug` text NOT NULL,
  `content` text NOT NULL,
  `author_id` integer NOT NULL,
  `published` integer DEFAULT false,
  `view_count` integer DEFAULT 0,
  `published_at` text,
  `created_at` text DEFAULT (datetime('now')) NOT NULL,
  FOREIGN KEY (`author_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);

Applying Migrations

# Apply to local D1 (Development)
npx wrangler d1 migrations apply DB --local

# Apply to production D1
npx wrangler d1 migrations apply DB --remote

Wrangler tracks migration history using a d1_migrations table inside D1, preventing accidental duplicate applications.

Schema Change Example

# Generate a new migration after modifying schema
npx drizzle-kit generate

# Inspect generated migration and apply locally
npx wrangler d1 migrations apply DB --local

Workers + Hono Integration

Initializing the DB Client

// src/db/client.ts
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";

export type Env = {
  DB: D1Database;
};

export function createDb(d1: D1Database) {
  return drizzle(d1, { schema });
}

export type DrizzleDb = ReturnType<typeof createDb>;

Setting Up the Hono App

// src/index.ts
import { Hono } from "hono";
import { createDb, type Env } from "./db/client";
import { usersRouter } from "./routes/users";
import { postsRouter } from "./routes/posts";

const app = new Hono<{ Bindings: Env }>();

// DB Middleware
app.use("*", async (c, next) => {
  c.set("db", createDb(c.env.DB));
  await next();
});

app.route("/api/users", usersRouter);
app.route("/api/posts", postsRouter);

app.get("/health", (c) => c.json({ status: "ok" }));

export default app;

Users Router — Full CRUD

// src/routes/users.ts
import { Hono } from "hono";
import { eq, like, desc, count } from "drizzle-orm";
import { users, posts, type NewUser } from "../db/schema";
import type { DrizzleDb, Env } from "../db/client";

const usersRouter = new Hono<{
  Bindings: Env;
  Variables: { db: DrizzleDb };
}>();

// GET /api/users — Pagination + Search
usersRouter.get("/", async (c) => {
  const db = c.get("db");
  const { page = "1", limit = "20", q } = c.req.query();

  const pageNum = Math.max(1, parseInt(page));
  const limitNum = Math.min(100, parseInt(limit));
  const offset = (pageNum - 1) * limitNum;

  // Dynamically construct WHERE clause
  const whereClause = q ? like(users.name, `%${q}%`) : undefined;

  const [userList, totalResult] = await Promise.all([
    db
      .select()
      .from(users)
      .where(whereClause)
      .orderBy(desc(users.createdAt))
      .limit(limitNum)
      .offset(offset)
      .all(),
    db
      .select({ count: count() })
      .from(users)
      .where(whereClause)
      .get(),
  ]);

  return c.json({
    data: userList,
    pagination: {
      page: pageNum,
      limit: limitNum,
      total: totalResult?.count ?? 0,
      hasNext: offset + limitNum < (totalResult?.count ?? 0),
    },
  });
});

// GET /api/users/:id — Get user + post count
usersRouter.get("/:id", async (c) => {
  const db = c.get("db");
  const id = parseInt(c.req.param("id"));

  const user = await db
    .select({
      id: users.id,
      email: users.email,
      name: users.name,
      role: users.role,
      createdAt: users.createdAt,
      postCount: count(posts.id),
    })
    .from(users)
    .leftJoin(posts, eq(posts.authorId, users.id))
    .where(eq(users.id, id))
    .groupBy(users.id)
    .get();

  if (!user) {
    return c.json({ error: "User not found" }, 404);
  }

  return c.json(user);
});

// POST /api/users — Create user
usersRouter.post("/", async (c) => {
  const db = c.get("db");
  const body = await c.req.json<NewUser>();

  // Check for duplicate email
  const existing = await db
    .select({ id: users.id })
    .from(users)
    .where(eq(users.email, body.email))
    .get();

  if (existing) {
    return c.json({ error: "Email already exists" }, 409);
  }

  const [newUser] = await db
    .insert(users)
    .values({
      email: body.email,
      name: body.name,
      role: body.role ?? "user",
    })
    .returning();

  return c.json(newUser, 201);
});

// PATCH /api/users/:id — Partial update
usersRouter.patch("/:id", async (c) => {
  const db = c.get("db");
  const id = parseInt(c.req.param("id"));
  const body = await c.req.json<Partial<NewUser>>();

  const [updated] = await db
    .update(users)
    .set({
      ...body,
      updatedAt: new Date().toISOString(),
    })
    .where(eq(users.id, id))
    .returning();

  if (!updated) {
    return c.json({ error: "User not found" }, 404);
  }

  return c.json(updated);
});

// DELETE /api/users/:id
usersRouter.delete("/:id", async (c) => {
  const db = c.get("db");
  const id = parseInt(c.req.param("id"));

  const [deleted] = await db
    .delete(users)
    .where(eq(users.id, id))
    .returning({ id: users.id });

  if (!deleted) {
    return c.json({ error: "User not found" }, 404);
  }

  return c.json({ message: "Deleted", id: deleted.id });
});

export { usersRouter };

Advanced Querying: Fetching Relations

// src/routes/posts.ts — JOIN relational data
import { Hono } from "hono";
import { eq, and, desc, inArray } from "drizzle-orm";
import { posts, users, tags, postTags } from "../db/schema";
import type { DrizzleDb, Env } from "../db/client";

const postsRouter = new Hono<{
  Bindings: Env;
  Variables: { db: DrizzleDb };
}>();

// GET /api/posts/:slug — Post details (includes user + tags)
postsRouter.get("/:slug", async (c) => {
  const db = c.get("db");
  const slug = c.req.param("slug");

  // 1. Fetch post + author
  const post = await db
    .select({
      id: posts.id,
      title: posts.title,
      slug: posts.slug,
      content: posts.content,
      published: posts.published,
      viewCount: posts.viewCount,
      publishedAt: posts.publishedAt,
      author: {
        id: users.id,
        name: users.name,
        email: users.email,
      },
    })
    .from(posts)
    .innerJoin(users, eq(posts.authorId, users.id))
    .where(and(eq(posts.slug, slug), eq(posts.published, true)))
    .get();

  if (!post) {
    return c.json({ error: "Post not found" }, 404);
  }

  // 2. Fetch tags (separate query — two simple queries are faster than complex subqueries on D1)
  const postTagList = await db
    .select({ name: tags.name, slug: tags.slug })
    .from(tags)
    .innerJoin(postTags, eq(postTags.tagId, tags.id))
    .where(eq(postTags.postId, post.id))
    .all();

  // 3. Increment view count (fire-and-forget)
  c.executionCtx.waitUntil(
    db
      .update(posts)
      .set({ viewCount: post.viewCount + 1 })
      .where(eq(posts.id, post.id))
      .run()
  );

  return c.json({ ...post, tags: postTagList });
});

export { postsRouter };

Incrementing view counts with c.executionCtx.waitUntil() is a signature Cloudflare Workers pattern. It executes background DB updates even after returning the HTTP response.

Local Development: Integrating D1 Local SQLite

# Start local development server
npx wrangler dev

# Apply migrations in a separate terminal
npx wrangler d1 migrations apply DB --local

Wrangler stores local D1 state as SQLite files in .wrangler/state/v3/d1/. Your data persists across dev server restarts.

Querying local DB directly:

npx wrangler d1 execute DB --local --command "SELECT * FROM users LIMIT 5;"

Seeding local data:

npx wrangler d1 execute DB --local --file ./drizzle/seed.sql

Transactions and Batch Processing

Because D1 is serverless SQLite, it supports transactions, and Drizzle makes them type-safe and intuitive to use.

// Transaction: Create user + post simultaneously
async function createUserWithPost(
  db: DrizzleDb,
  userData: NewUser,
  postData: Omit<NewPost, "authorId">
) {
  return await db.transaction(async (tx) => {
    const [user] = await tx
      .insert(users)
      .values(userData)
      .returning();

    const [post] = await tx
      .insert(posts)
      .values({ ...postData, authorId: user.id })
      .returning();

    return { user, post };
  });
}

Multi-query optimization leveraging D1’s Batch API:

// D1 Batch API — Execute multiple queries in a single network round-trip
async function batchInsertPosts(db: DrizzleDb, postsData: NewPost[]) {
  // Drizzle's batch helper (D1-specific)
  const statements = postsData.map((post) =>
    db.insert(posts).values(post)
  );

  // Process entire batch with a single HTTP request
  await db.batch(statements);
}

db.batch() executes multiple statements while invoking D1’s HTTP API only once. Running 1,000 separate INSERT queries requires 1,000 network round-trips, whereas a batch query requires just 1.

Cost Optimization: Staying Within D1 Free Tier Limits

Here are D1’s free tier limits and how to optimize your queries using Drizzle:

Metric Free Tier Limit Paid (Workers Paid)
Read Operations 5 Million / day Unlimited
Write Operations 100,000 / day Unlimited
Storage Capacity 5 GB 25 GB
Number of DBs 10 Unlimited

Drizzle patterns to reduce query volume:

// ❌ N+1 Query Problem
const postList = await db.select().from(posts).all();
for (const post of postList) {
  const author = await db
    .select()
    .from(users)
    .where(eq(users.id, post.authorId))
    .get(); // Triggers a query for every post
}

// ✅ Solved in 1 query using JOIN
const postListWithAuthor = await db
  .select({
    postId: posts.id,
    title: posts.title,
    authorName: users.name,
  })
  .from(posts)
  .innerJoin(users, eq(posts.authorId, users.id))
  .all();
// ✅ Frequently read data → Reduce D1 read requests via KV Caching
export type CacheEnv = {
  DB: D1Database;
  KV: KVNamespace;
};

async function getPostWithCache(
  db: DrizzleDb,
  kv: KVNamespace,
  slug: string
) {
  const cacheKey = `post:${slug}`;

  // Cache hit
  const cached = await kv.get(cacheKey, "json");
  if (cached) return cached;

  // Cache miss → Query D1 and store in cache
  const post = await db
    .select()
    .from(posts)
    .where(eq(posts.slug, slug))
    .get();

  if (post) {
    // Cache with a 5-minute TTL
    await kv.put(cacheKey, JSON.stringify(post), { expirationTtl: 300 });
  }

  return post;
}

D1 read operations cost $0.001 per 1 million requests. Caching frequently accessed data in Workers KV can reduce your D1 costs by over 90%.

Type-Safe API Client: Combining with Hono RPC

Propagating Drizzle’s types to your frontend via Hono RPC delivers true end-to-end type safety.

// src/index.ts — Exporting Hono RPC types
import { Hono } from "hono";
import type { User, Post } from "./db/schema";

const app = new Hono<{ Bindings: Env }>();

const routes = app
  .get("/api/users", async (c) => {
    // ... implementation
    return c.json({ data: [] as User[] });
  })
  .post("/api/users", async (c) => {
    // ... implementation
    return c.json({} as User, 201);
  });

export type AppType = typeof routes;
export default app;
// Frontend (React / Next.js)
import { hc } from "hono/client";
import type { AppType } from "../worker/src/index";

const client = hc<AppType>("https://my-app.workers.dev");

// Full type inference — flowing directly from D1 schema to frontend
const response = await client.api.users.$get();
const { data } = await response.json();
// data is typed as User[] — 100% IDE autocompletion support

Types flow automatically: Schema definition → Drizzle types → Hono responses → RPC client. You achieve end-to-end type safety using pure TypeScript, without prisma generate, tRPC setup, or GraphQL schemas.

CI/CD: GitHub Actions Deployment Pipeline

# .github/workflows/deploy.yml
name: Deploy to Cloudflare Workers

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Run TypeScript type check
        run: npx tsc --noEmit

      - name: Apply D1 migrations (Production)
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
        run: npx wrangler d1 migrations apply DB --remote

      - name: Deploy Workers
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
        run: npx wrangler deploy

The sequence (Migrations → Deploy) is vital. Migrations must apply first so that the newly deployed Workers code can query the updated schema.

Drizzle Studio: Visualizing Your DB Without a Desktop GUI

# Launch Drizzle Studio connected to local D1
npx drizzle-kit studio

Opening https://local.drizzle.studio in your browser allows you to view and edit D1 data in a clean web GUI. If you’re accustomed to Prisma Studio, you’ll feel right at home.

Performance Benchmarks: Real-World D1 Latency

Actual measured metrics when using D1 in Workers (based on official Cloudflare documentation):

Query Type Average Latency
Simple SELECT (Indexed) 1-5ms
JOIN Query 5-15ms
INSERT 5-10ms
Complex Aggregation Query 10-50ms
Batch INSERT (100 rows) 10-20ms

Compared to external PostgreSQL instances (such as Neon or Supabase), D1 binding calls within Workers execute without TCP handshake overhead, making latency 3-5x lower. If your goal is a TTFB under 100ms, D1 is your best option.

D1 vs. Other Edge DBs: Selection Criteria

Scenario Recommendation
Cloudflare All-in-One Stack D1 + Drizzle
Multi-Cloud (Vercel + CF Mixed) Turso + Drizzle
Need PostgreSQL Features (JSONB, Full-text) Neon + Drizzle
Need Real-Time Sync Supabase + Drizzle
Complex Analytical Queries External PostgreSQL

If you are committed to the Cloudflare ecosystem, D1 is hands-down the best option. If you need multi-cloud portability, libSQL-based Turso lets you use the exact same Drizzle API with portable database hosting.

Migration Checklist

Key items to check when migrating from Prisma to Drizzle:

While migrating requires initial effort, once you get used to Drizzle, directly controlling SQL feels significantly more intuitive. The core philosophy is that an ORM should express SQL in TypeScript rather than hide it behind abstractions.

Conclusion

The Drizzle ORM + Cloudflare D1 stack is one of the most efficient choices for edge full-stack development in 2026.

If Prisma’s 1.6 MB bundle posed challenges in your Cloudflare Workers environment, switching to Drizzle is about more than just trimming file sizes. It’s a shift in philosophy—expressing SQL through TypeScript rather than treating the ORM as a black box.

Related Post: Learn how to combine Drizzle API with Hono RPC in a complete full-stack example in Hono RPC and Cloudflare Workers: Full-Stack Type Safety with TanStack Query v5.