---
title: "06 — D1: Serverless SQLite at the Edge"
uid: d1-database
tags: ["sqlite", "database", "sql", "cloudflare", "roadmap:cloudflare", "prisma", "drizzle", "d1", "migrations"]
excerpt: "D1 is serverless SQLite living on Cloudflare's network, reached from a Worker through a binding — not a connection string. Same SQL; different address."
date: 2026-08-13T03:28:18+0000
source: https://www.aveshina.my.id/en/blog/d1-database
---

"A database for Workers, somehow" was my D1 summary, and the "somehow" hid the interesting part. The model that pinned it: **D1 is serverless SQLite running on Cloudflare's network, and a Worker reaches it through a binding — not a connection string — which is the part that makes it actually feel edge-native.** [1] The query language and schema are plain SQLite; the shift is in where it lives and how you talk to it.

The framing that landed is the comparison with what came before. A traditional database is a server in a region. My Worker, running next to the user, has to open a TCP connection across the world to reach it, pay that latency on every query, and manage connection pooling because each Worker invocation is short-lived. D1 collapses that: the database is on Cloudflare's network, the binding is a direct in-network channel, and there's no connection to pool [1][2]. The Worker reads and writes SQL with single-digit-millisecond latency to the nearest D1 replica.

```figure
<svg viewBox="0 0 740 280" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="Traditional database versus D1. Left: a Worker at the edge opens a TCP connection across the world to a single DB server in one region, long latency. Right: the Worker reaches D1 via a binding, in-network, no connection string, low latency.">
  <defs>
    <marker id="darrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
      <path d="M0,0 L10,5 L0,10 z" fill="#64748b"/>
    </marker>
  </defs>
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">

    <text x="185" y="24" font-size="12" font-weight="700" fill="#7f1d1d" text-anchor="middle">Traditional DB — remote server</text>
    <text x="555" y="24" font-size="12" font-weight="700" fill="#052e16" text-anchor="middle">D1 — edge, via binding</text>

    <!-- LEFT -->
    <rect x="40" y="60" width="100" height="40" rx="6" fill="#e0e7ff" stroke="#6366f1"/>
    <text x="90" y="84" font-size="10" font-weight="700" fill="#1e1b4b" text-anchor="middle">Worker · edge</text>

    <rect x="230" y="60" width="120" height="40" rx="6" fill="#fee2e2" stroke="#dc2626"/>
    <text x="290" y="84" font-size="10" font-weight="700" fill="#7f1d1d" text-anchor="middle">DB · one region</text>

    <path d="M140,80 L228,80" fill="none" stroke="#dc2626" stroke-width="2" stroke-dasharray="6,4" marker-end="url(#darrow)"/>
    <text x="184" y="72" font-size="9" fill="#7f1d1d" text-anchor="middle">TCP · pooled · ~100ms</text>

    <text x="185" y="130" font-size="10" font-style="italic" fill="#7f1d1d" text-anchor="middle">connection crosses the world per query</text>

    <!-- RIGHT -->
    <rect x="410" y="60" width="100" height="40" rx="6" fill="#e0e7ff" stroke="#6366f1"/>
    <text x="460" y="84" font-size="10" font-weight="700" fill="#1e1b4b" text-anchor="middle">Worker · edge</text>

    <rect x="600" y="60" width="120" height="40" rx="6" fill="#dcfce7" stroke="#16a34a"/>
    <text x="660" y="84" font-size="10" font-weight="700" fill="#052e16" text-anchor="middle">D1 · on-network</text>

    <path d="M510,80 L598,80" fill="none" stroke="#16a34a" stroke-width="2" marker-end="url(#darrow)"/>
    <text x="554" y="72" font-size="9" fill="#052e16" text-anchor="middle">binding · in-network · ~ms</text>

    <text x="555" y="130" font-size="10" font-style="italic" fill="#052e16" text-anchor="middle">no connection string, nothing to pool</text>

    <!-- bottom: SQL -->
    <rect x="180" y="170" width="380" height="80" rx="8" fill="#fef9c3" stroke="#ca8a04" stroke-width="1.5"/>
    <text x="370" y="194" font-size="11" font-weight="700" fill="#422006" text-anchor="middle">plain SQLite — schema, queries, syntax all standard</text>
    <text x="370" y="216" font-size="10" font-family="ui-monospace, monospace" fill="#422006" text-anchor="middle">await env.DB.prepare("SELECT * FROM users WHERE id=?")</text>
    <text x="370" y="232" font-size="10" font-family="ui-monospace, monospace" fill="#422006" text-anchor="middle">  .bind(42).first()</text>
    <text x="370" y="250" font-size="9" font-style="italic" fill="#475569" text-anchor="middle">ORM-friendly: Drizzle and Prisma both target D1</text>
  </g>
</svg>
```

## Schema management

D1 uses SQLite-compatible DDL, so anything I know about SQLite schema transfers directly [3]. Tables, columns, data types, primary and foreign keys, indexes — all standard SQL. I design the schema from the application's data needs, write the CREATE TABLE statements, and apply them via wrangler:

```
wrangler d1 execute my-db --file=./schema.sql
```

The decisions worth slowing down for are the usual SQL ones: data types for storage efficiency, indexes on columns used in WHERE/JOIN/ORDER BY, and foreign-key relationships. Because D1 is SQLite, the type system is flexible (SQLite uses dynamic typing), but I treat it as if it were strict — predictable types make for predictable queries.

## Migrations

Schema evolves, and migrations are the structured way to apply changes repeatably [4]. The pattern:

1. Write a numbered SQL file (0001_add_email_index.sql) containing ALTER TABLE or CREATE INDEX statements.
2. Apply it with wrangler d1 execute --file=./migrations/0001_*.sql.
3. Track which migrations have been applied (a _migrations table, or a tool that tracks it for you).
4. Apply them in order, against every environment.

Two disciplines that pay off: version every migration script and apply them in order, and never edit a migration that's already been applied in production — write a new one to reverse it. Migrations are append-only history, and treating them that way prevents the data-inconsistency bugs that bite when schemas drift between environments [4].

## Query optimization

D1 inherits SQLite's query optimizer, but I can't pretend it's magic — bad queries are still bad queries [5]. The practices worth internalizing:

- **Index for your access patterns.** Every column that shows up in a WHERE, JOIN, or ORDER BY deserves an index. Without one, the query falls back to a full table scan, which is linear in table size.
- **Be explicit about columns.** SELECT name, email instead of SELECT *. Fewer bytes transferred, and the query survives schema additions without surprising me.
- **Filter early.** Push WHERE clauses as deep as possible so joins operate on the smallest intermediate result sets.
- **Read the plan.** SQLite's EXPLAIN QUERY PLAN shows whether a query uses an index or scans. If a slow query shows SCAN TABLE, that's the smoking gun — add an index or rewrite the predicate.
- **Consider denormalization deliberately.** Sometimes duplicating a column to avoid a join is the right trade. It costs storage and consistency effort, so it's a choice, not a default.

## Drizzle: the TypeScript ORM I reach for

Drizzle is a TypeScript-first ORM — an "object-relational mapper," which is a layer that turns my database tables into typed objects I query from code instead of raw SQL strings — and it targets D1 directly. It's become my default [6]. The pitch:

- **Schema in TypeScript.** I define tables and columns as typed objects, and Drizzle generates the SQL DDL for migrations.
- **Type-safe query builder.** Queries are constructed in TS with full type inference — the result's shape is known at compile time, and refactors that break a query show up as type errors.
- **Migration tooling.** Drizzle generates and applies migrations, integrating cleanly with the D1 workflow.

A Drizzle query reads almost like SQL but with end-to-end types:

```
const result = await db
  .select({ id: users.id, name: users.name })
  .from(users)
  .where(eq(users.id, 42));
```

The result is { id: number; name: string }[], and any column rename propagates as a type error. For a TypeScript project on D1, this is the path of least friction.

## Prisma: when you want its ecosystem

Prisma is the other major ORM, and it works with D1 through a dedicated adapter [7]. Its strengths are the things Drizzle trades away: a richer schema language, a generated type-safe client with a more opinionated API, mature migrations, and an ecosystem of tooling (Prisma Studio, the data platform).

The trade-off is that Prisma historically assumed a long-lived connection to a traditional Postgres/MySQL database, and the D1 adapter is the bridge that makes it work in the binding-based, serverless world of Workers. For a team already deep in Prisma, the adapter lets them bring their existing schemas and workflows. For a greenfield D1 project, Drizzle's lighter footprint and tighter D1 integration usually win.

## How I use this

D1 is my default when a Worker needs structured, queryable, relational data — user accounts, application records, anything where SQL is the natural shape. The pattern I keep: schema designed upfront, migrations versioned and append-only, Drizzle for type-safe queries, and EXPLAIN QUERY PLAN consulted the moment a query feels slow. When I need single-object strong consistency across concurrent writers (real-time coordination, counters that must be exact), that's the signal to move that specific piece to Durable Objects rather than fighting D1's read/write model.

## References

[1] Cloudflare, "Cloudflare D1 documentation," Cloudflare Docs, 2024. [Online]. Available: [https://developers.cloudflare.com/d1/](https://developers.cloudflare.com/d1/)

[2] Cloudflare, "Getting started with D1," Cloudflare Docs, 2024. [Online]. Available: [https://developers.cloudflare.com/d1/get-started/](https://developers.cloudflare.com/d1/get-started/)

[3] Cloudflare, "Schema validation · Cloudflare API Shield," Cloudflare Docs. [Online]. Available: [https://developers.cloudflare.com/api-shield/security/schema-validation/](https://developers.cloudflare.com/api-shield/security/schema-validation/)

[4] Prisma, "Database migrations: what are the types of DB migrations?," Prisma Data Guide. [Online]. Available: [https://www.prisma.io/dataguide/types/relational/what-are-database-migrations](https://www.prisma.io/dataguide/types/relational/what-are-database-migrations)

[5] Cloudflare, "Query parameters and cached responses," Cloudflare Docs. [Online]. Available: [https://developers.cloudflare.com/automatic-platform-optimization/reference/query-parameters/](https://developers.cloudflare.com/automatic-platform-optimization/reference/query-parameters/)

[6] Drizzle, "Drizzle ORM," 2024. [Online]. Available: [https://orm.drizzle.team/](https://orm.drizzle.team/)

[7] Prisma, "Prisma documentation," 2024. [Online]. Available: [https://www.prisma.io/docs](https://www.prisma.io/docs)

```quiz
Q: How does a Worker connect to a D1 database?
- Via a TCP connection string, like a traditional database
- Via a binding declared in wrangler.toml — an in-network channel, no connection to pool
- Via a public REST API with an API key
correct: 1
explain: D1 is accessed through a binding, not a connection string. The Worker calls methods on `env.DB`, the call stays inside Cloudflare's network, and there's no connection lifecycle to manage.

Q: Which SQL dialect does D1 use?
- A Cloudflare-specific SQL variant
- SQLite-compatible SQL — schema, types, and queries are standard SQLite
- PostgreSQL-flavored SQL
correct: 1
explain: D1 is built on SQLite. Anything you know about SQLite schema, types, and queries applies directly.

Q: What's the safest way to change a D1 schema that's already in production?
- Edit the original CREATE TABLE statement and re-run it
- Write a new, append-only migration script with ALTER TABLE — never edit applied migrations
- Drop and recreate the table from a backup
correct: 1
explain: Migrations are append-only history. Editing an applied migration causes schemas to drift between environments and can corrupt data. Always write a new migration to evolve or reverse changes.

Q: You see `SCAN TABLE` in an `EXPLAIN QUERY PLAN` output for a slow query. What's the most likely fix?
- Rewrite the query in Drizzle instead of raw SQL
- Add an index on the columns used in the WHERE/JOIN/ORDER BY predicates
- Increase the D1 database's storage allocation
correct: 1
explain: SCAN TABLE means the query is doing a full table scan — linear in table size, slow on large tables. An index on the filtered columns lets SQLite seek directly to matching rows.

Q: Why might you choose Drizzle over Prisma for a greenfield D1 + Workers project?
- Prisma doesn't work with D1 at all
- Drizzle is TypeScript-first with a lighter footprint and tighter D1 integration; Prisma's strengths come with more overhead via an adapter
- Drizzle doesn't support migrations
correct: 1
explain: Prisma works with D1 through an adapter, but it was originally designed for long-lived database connections. Drizzle was built for the binding-based serverless model, which usually means less friction on a fresh Workers project.
```
