A practitioner's look at Cloudflare D1, the serverless SQLite database built for Workers, covering setup, read replication, limits, and fit.
If you already run your app on Cloudflare Workers, reaching for a traditional database means adding a network hop to some region far from your users. D1 is Cloudflare's answer: a serverless SQLite database that lives inside the same platform as your Workers, with read replicas placed near where your traffic actually is. This guide walks through what D1 is, how it works, and where it earns a spot in your stack.
For the broader landscape, see our serverless and edge databases guide.
D1 is a managed SQLite database exposed to Cloudflare Workers through a binding. You don't provision servers, manage connection pools, or pick a region for compute. You create a database, bind it to a Worker, and query it with prepared statements. Under the hood each database is a SQLite instance, so you get real SQL, transactions, indexes, and the SQLite dialect you already know.
The part that makes it an edge database rather than just hosted SQLite is read replication. D1 keeps a primary copy plus read replicas distributed across Cloudflare's network. Reads can be served from a replica close to the user, while writes route to the primary. That shape is the whole pitch: cheap, fast reads near the reader, with a single authoritative writer.
The mental model has three pieces.
Primary plus replicas: Every write goes to the primary. Replicas asynchronously catch up. A read hitting a nearby replica might be slightly behind the primary, which matters for consistency (more on that below).
The Workers binding model: Your Worker never opens a raw connection. Instead, wrangler.toml declares a binding, and at runtime that binding shows up on env as an object with a query API. Cloudflare handles routing your query to the right instance.
Prepared statements: You build a statement, bind parameters, and execute. The API is small: prepare(), bind(), then all(), first(), or run().
Create a database from the CLI:
npx wrangler d1 create my-app-db
# Copy the printed database_id into wrangler.toml
npx wrangler d1 migrations create my-app-db create_users
Add the binding and point at a migrations directory. Verify field names against current Cloudflare docs, since the config format shifts between wrangler versions:
# wrangler.toml
name = "my-app"
main = "src/index.ts"
compatibility_date = "2026-07-01"
[[d1_databases]]
binding = "DB"
database_name = "my-app-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
migrations_dir = "migrations"
Write a migration as plain SQL, then apply it:
-- migrations/0001_create_users.sql
CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_users_email ON users(email);
npx wrangler d1 migrations apply my-app-db --local # local dev
npx wrangler d1 migrations apply my-app-db --remote # production
Now query from the Worker. The binding name in wrangler.toml becomes the property on env:
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const email = url.searchParams.get("email") ?? "";
const { results } = await env.DB
.prepare("SELECT id, email, created_at FROM users WHERE email = ?")
.bind(email)
.all();
return Response.json(results);
},
};
Always bind parameters rather than interpolating strings into SQL. It's the same injection story as any SQL database.
Async replication buys you speed at the cost of a consistency wrinkle: right after a write, a read from a lagging replica might not see it. For a user who just updated their profile and reloads the page, that reads as a bug.
D1's Sessions API addresses this with read-your-writes semantics. You start a session, and D1 tracks a bookmark representing how current your reads need to be. Follow-up reads in that session are guaranteed to see at least the data your session has already written, routing to a replica that's caught up (or to the primary) as needed. The rough shape:
const session = env.DB.withSession("first-primary");
await session.prepare("INSERT INTO users (email) VALUES (?)").bind(email).run();
const { results } = await session
.prepare("SELECT * FROM users WHERE email = ?")
.bind(email)
.all(); // sees the insert above
Check the current method names and session modes against Cloudflare docs before shipping, as this API has evolved. The key idea: use sessions when a request does a write and then reads its own result. Pure read traffic can skip sessions and take the fastest nearby replica.
D1 has real ceilings. There's a maximum database size (in the low tens of gigabytes per database, verify against current Cloudflare docs), plus per-query limits on rows returned and statement complexity. If you outgrow one database, the intended pattern is many databases (for example, one per tenant) rather than one giant instance.
Pricing follows a usage model: rows read and rows written, plus storage, with a free tier and paid tiers on the Workers plan. Because billing counts rows touched, an unindexed query that scans a big table costs you twice, in latency and in dollars. Treat indexes as a cost control, not just a performance tuning step. Confirm the exact numbers against current Cloudflare pricing, since they change.
Local development is genuinely good. Wrangler runs D1 on a local SQLite file via miniflare, so wrangler dev gives you a working database with no network round trip. Apply migrations with --local, iterate, and only touch --remote when you're ready. The same binding code runs in both places.
D1 is a strong fit when:
D1 is the wrong tool when:
If you're weighing alternatives, our Turso vs Cloudflare D1 comparison digs into the other major edge-SQLite option.
For a Workers app that's read-dominated and happy with SQLite, D1 is close to a default. The binding model is clean, local dev is painless, and read replication plus the Sessions API cover the common consistency trap without a lot of ceremony. The moment your workload turns write-heavy, needs Postgres features, or pushes past the size limits, stop and reach for a different database. D1 is a sharp tool for a specific shape of app, and it's most of the way to great when that shape matches yours. Verify the specific limits, pricing, and API names against current Cloudflare docs before you commit, because this product moves fast.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
Explore more articles in this category
A hands-on walkthrough of Turso and libSQL, from CLI setup to embedded replicas that put SQLite reads next to your users.
A practical look at when SQLite is a genuinely good production database, where it breaks, and the tooling that makes it viable.
A practitioner's guide to choosing between serverless and provisioned databases based on cost, latency, connections, and load shape.
Evergreen posts worth revisiting.