Cloudflare D1: The Edge SQLite Database Guide (2026)
A practitioner's look at Cloudflare D1, the serverless SQLite database built for Workers, covering setup, read replication, limits, and fit.
Key takeaways
- Cloudflare D1 is a managed SQLite database that Workers query through a binding.
- Each database is capped at 10 GB on Workers Paid and processes one query at a time, so it suits read-heavy apps and per-tenant databases.
- Read replicas only serve queries made through the Sessions API, and billing counts rows scanned, not returned.
On this page
Cloudflare D1 is serverless SQLite for Workers: create it with npx wrangler d1 create, bind it in your Wrangler config, and query it with prepared statements on env.DB. Use it for read-heavy apps that fit in 10 GB per database. Replicas only help when you query through withSession(), and writes always go to one single-threaded primary.
Verified against
Wrangler 4.156.0 (npm registry), against the Cloudflare D1 docs for getting started, migrations, limits, pricing, read replication and debugging. Checked October 2026.
What D1 actually is
Each D1 database is a SQLite instance that Cloudflare runs for you inside a Durable Object. Your Worker never opens a connection: Wrangler declares a binding, and at runtime that binding appears on env with a small API (prepare(), bind(), then run(), all(), first(), plus batch()). You get real SQL, transactions, indexes and the SQLite dialect.
The shape that matters most is in the limits page: each database is single-threaded and processes one query at a time. Cloudflare's own numbers are about 1,000 queries per second at a 1 ms average query and about 10 per second at 100 ms. Slow queries do not just slow themselves down, they queue everyone behind them.
Set it up, end to end
Create the database. Wrangler prints the binding block for you:
$ npx wrangler@latest d1 create my-app-db
✅ Successfully created DB 'my-app-db' in region WEUR
Created your new D1 database.
Add the binding to your Wrangler config. Cloudflare's docs show both formats; this is the JSONC one:
// wrangler.jsonc
{
"name": "my-app",
"main": "src/index.ts",
"compatibility_date": "2026-10-01",
"d1_databases": [
{
"binding": "DB",
"database_name": "my-app-db",
"database_id": "<id printed by d1 create>",
"migrations_dir": "migrations"
}
]
}
Create and apply a migration. Wrangler records applied migrations in a d1_migrations table:
$ npx wrangler d1 migrations create my-app-db create_users
$ cat > migrations/0001_create_users.sql <<'SQL'
CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
SQL
$ npx wrangler d1 migrations apply my-app-db --local
$ npx wrangler d1 migrations apply my-app-db --remote
$ npx wrangler d1 execute my-app-db --remote --command="SELECT count(*) FROM users"
The UNIQUE constraint already creates an index on email, so no separate CREATE INDEX is needed. Query from the Worker with bound parameters, never string interpolation:
// src/index.ts
export interface Env { DB: D1Database }
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const email = new URL(request.url).searchParams.get("email");
if (!email) return new Response("missing email", { status: 400 });
const row = await env.DB
.prepare("SELECT id, email, created_at FROM users WHERE email = ?")
.bind(email)
.first();
return row ? Response.json(row) : new Response("not found", { status: 404 });
},
};
npx wrangler dev runs the same code against a local SQLite file, so --local migrations and the dev server need no network at all.
Read replication only works through sessions
This is the part most guides get wrong. You enable replication per database (dashboard Settings, or the REST API with read_replication.mode set to auto), and D1 places replicas in ENAM, WNAM, WEUR, EEUR, APAC and OC. But Cloudflare's docs state that replication only takes effect when you query through a session. Plain env.DB.prepare() calls keep going to the primary.
// Read-your-writes across requests: carry the bookmark in a header
const bookmark = request.headers.get("x-d1-bookmark") ?? "first-unconstrained";
const session = env.DB.withSession(bookmark);
await session.prepare("UPDATE users SET email = ? WHERE id = ?").bind(newEmail, id).run();
const { results, meta } = await session
.prepare("SELECT * FROM users WHERE id = ?").bind(id).all(); // sees the update
console.log(meta.served_by_region, meta.served_by_primary);
const res = Response.json(results);
res.headers.set("x-d1-bookmark", session.getBookmark() ?? "");
return res;
The three starting points: "first-unconstrained" (the default, any replica), "first-primary" (first query sees the latest data), or a bookmark string (at least as fresh as that point). The header name is a convention from Cloudflare's example, not a requirement. Replicas cost nothing extra, and the Sessions API is available only through the Worker binding, not the REST API.
Limits and pricing, as of October 2026
| Item | Workers Free | Workers Paid |
|---|---|---|
| Max database size | 500 MB | 10 GB |
| Storage per account | 5 GB | 1 TB (raisable) |
| Databases | 10 | 100,000 included, then $0.10/month each |
| Rows read | 5 million/day | 25 billion/month included, then $0.001 per million |
| Rows written | 100,000/day | 50 million/month included, then $1.00 per million |
| Queries per Worker invocation | 50 | 1,000 |
Other hard limits on both plans: 100 bound parameters per query, 100 columns per table, 2 MB per row, string or BLOB, 100 KB per SQL statement, and 30 seconds per query. Storage beyond the included 5 GB is $0.75 per GB-month, and there are no egress charges.
The billing detail that bites: rows read counts rows scanned, not rows returned. A filter on an unindexed column over a 5,000-row table bills 5,000 reads even if it returns one row. Indexes are a cost control first. Run EXPLAIN QUERY PLAN on anything hot and look for SCAN without USING INDEX.
Errors you will hit, and the fix
D1_ERROR: no such table: users: migrations ran--localbut not--remote, or the reverse. Runwrangler d1 migrations list my-app-db --remoteto see what is pending.D1_TYPE_ERROR: you boundundefined. Passnullfor missing values.D1_ERROR: Exceeded maximum DB size.: the database hit 10 GB (500 MB on Free). Delete data or shard into more databases; this limit cannot be raised.D1 DB is overloaded. Too many requests queued.orRequests queued for too long.: the single-threaded primary is saturated. Add indexes, shorten queries, usebatch(), or split tenants across databases.D1 DB exceeded its CPU time limit and was reset.: one query is too heavy. Paginate or shard.Network connection lost.: retryable. D1 retries read-only queries up to twice; writes are never retried for you, so make them idempotent before adding your own retry.D1 query result exceeded the maximum response size limit of 50 MB: enforced from November 4, 2026. Select fewer columns or paginate.
Where this breaks
D1 is the wrong tool for write-heavy workloads: one primary processes one query at a time, and replicas do nothing for writes. It is wrong for a single dataset past 10 GB with no natural shard key, and for anything that needs Postgres features such as extensions, rich types or concurrent writers. The many-small-databases pattern works for per-tenant SaaS, but cross-tenant queries then become application code. For the general trade-offs of running SQLite under real traffic, see SQLite in production.
Questions people ask
Is Cloudflare D1 free?
D1 has a free tier on Workers Free: 5 million rows read and 100,000 rows written per day, 5 GB of total storage, and up to 10 databases of 500 MB each. On Workers Paid the monthly allowance is 25 billion rows read, 50 million rows written and 5 GB of storage, with overage at $0.001 per million reads, $1.00 per million writes and $0.75 per GB-month.
What is the maximum size of a Cloudflare D1 database?
As of October 2026, 10 GB per database on Workers Paid and 500 MB on Workers Free, per Cloudflare's limits page. The cap cannot be raised. An account can hold up to 1 TB on Paid, raisable on request, so the intended pattern for larger datasets is many databases, such as one per tenant, rather than one large one.
Does D1 read replication need code changes?
Yes. Enabling replication on the database is not enough: queries only reach replicas when you go through the Sessions API with env.DB.withSession(). To keep read-your-writes consistency across requests, return session.getBookmark() to the client and pass it back on the next request. Without a session, every query still goes to the primary.
The call we'd make
For a Workers app that is read-dominated and fits in SQLite, D1 is close to a default: the binding model is clean, local dev is painless, and there is no egress bill. Wire in withSession() from day one so turning on replication is a setting change, index every filter column because you pay per row scanned, and plan the shard key before you get near 10 GB. The moment writes dominate or you need Postgres, move on; our Turso vs Cloudflare D1 comparison covers the nearest alternative.
Sources
Cite this article
Kiril Urbonas (2026). Cloudflare D1: The Edge SQLite Database Guide (2026). DevOpsNess. Updated 2026-10-31. https://www.devopsness.com/blog/cloudflare-d1-guide
[Cloudflare D1: The Edge SQLite Database Guide (2026)](https://www.devopsness.com/blog/cloudflare-d1-guide) (Kiril Urbonas, DevOpsNess, updated 2026-10-31)
More from Cloud
Explore more articles in this category
.NET 8 Support Ends November 10: The Lambda and Azure Functions Checklist
.NET 8, .NET 9, the Lambda dotnet8 runtime and the Azure Functions in-process model all lose support on November 10, 2026. Inventory commands, the isolated-worker migration and a CI gate.
Kubernetes 1.34 Hit End of Life Oct 27: What EKS, GKE and AKS Do Next
Upstream stopped patching 1.34 on October 27. EKS starts billing 6x on December 2, AKS drops it at the end of November, and GKE gives you until January. The dates, the commands to find your 1.34 clusters, and the upgrade order.
Lambda Python 3.10 Is Deprecated Today: Find Every Function and Move to 3.13
AWS deprecated the Lambda python3.10 runtime on October 31, 2026. Functions keep running, patches stop. The inventory commands, IaC diffs and AL2023 gotchas for moving to python3.13.
You might have missed
Evergreen posts worth revisiting.