# Cloudflare D1: The Edge SQLite Database Guide (2026)

URL: https://www.devopsness.com/blog/cloudflare-d1-guide
Author: Kiril Urbonas
Published: 2026-08-06
Updated: 2026-10-31
Categories: Cloud
Tags: Cloudflare D1, Sqlite, Edge, Workers, Database

Summary: A practitioner's look at Cloudflare D1, the serverless SQLite database built for Workers, covering setup, read replication, limits, and fit.

---
# Cloudflare D1: The Edge SQLite Database Guide (2026)

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

```ts
// 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.

```ts
// 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 `--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](/blog/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](/blog/turso-vs-cloudflare-d1-edge-sqlite) comparison covers the nearest alternative.

## Sources

- [Cloudflare D1 docs: Limits](https://developers.cloudflare.com/d1/platform/limits/)
- [Cloudflare D1 docs: Pricing](https://developers.cloudflare.com/d1/platform/pricing/)
- [Cloudflare D1 docs: Global read replication](https://developers.cloudflare.com/d1/best-practices/read-replication/)
- [Cloudflare D1 docs: Debug D1](https://developers.cloudflare.com/d1/observability/debug-d1/)

