Skip to main content
Cloudflare D1: The Edge SQLite Database Guide (2026)

Cloudflare D1: The Edge SQLite Database Guide (2026)

KU
• 8 min read•Updated •71 views

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.

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:

Bash
$ 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:

jsonc
// 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:

Bash
$ 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:

TypeScript
// 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.

TypeScript
// 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

ItemWorkers FreeWorkers Paid
Max database size500 MB10 GB
Storage per account5 GB1 TB (raisable)
Databases10100,000 included, then $0.10/month each
Rows read5 million/day25 billion/month included, then $0.001 per million
Rows written100,000/day50 million/month included, then $1.00 per million
Queries per Worker invocation501,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 --local but not --remote, or the reverse. Run wrangler d1 migrations list my-app-db --remote to see what is pending.
  • D1_TYPE_ERROR: you bound undefined. Pass null for 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. or Requests queued for too long.: the single-threaded primary is saturated. Add indexes, shorten queries, use batch(), 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

Explore topics:Cloud
React
Share this post
Cite this article
Plain text

Kiril Urbonas (2026). Cloudflare D1: The Edge SQLite Database Guide (2026). DevOpsNess. Updated 2026-10-31. https://www.devopsness.com/blog/cloudflare-d1-guide

Markdown

[Cloudflare D1: The Edge SQLite Database Guide (2026)](https://www.devopsness.com/blog/cloudflare-d1-guide) (Kiril Urbonas, DevOpsNess, updated 2026-10-31)

KU

Kiril Urbonas

AI Engineer

585 articles
View all articles by Kiril Urbonas

You might have missed

Evergreen posts worth revisiting.