Cloudflare D1: The Edge SQLite Database Guide (2026)

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.

What D1 is #

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.

How it works #

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().

Getting started with wrangler #

Create a database from the CLI:

bash.bash

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:

toml.toml

# 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:

sql.sql

-- 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);

bash.bash

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:

ts.ts

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.

Read replication and the Sessions API #

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:

ts.ts

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.

Limits, pricing, and local dev #

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.

When to use D1 #

D1 is a strong fit when:

Your app already lives on Cloudflare Workers and you want to keep everything on one platform.

Traffic is read-heavy and globally distributed, so replicas near users pay off.

Your data model is comfortable in SQLite and fits within the size limits.

You want low operational overhead and cheap-to-zero idle cost.

D1 is the wrong tool when:

You have write-heavy workloads. A single primary caps write throughput, and async replication doesn't help writes.

Your dataset is large, well past the per-database size limit, with no clean way to shard.

You need Postgres-specific features: rich types, extensions like PostGIS, stored procedures, or advanced concurrency.

You need strong global consistency on every read without threading sessions through your code.

If you're weighing alternatives, our Turso vs Cloudflare D1 comparison digs into the other major edge-SQLite option.

The call we'd make #

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.