---
slug: cloudflare-os-d1
title: D1 bills rows, not queries, and serial round trips decide the architecture
canonical: https://miscsubjects.com/a/cloudflare-os-d1
---

D1 is Cloudflare's managed SQLite. Bind a database to a Worker in `wrangler.toml`, get `env.DB`, write ordinary SQL. No connection string, no pool, no instance to size. That pitch is accurate.

The shape underneath is what decides whether you should build on it: a single SQLite file inside a single Durable Object in a single Cloudflare location, billed by the row rather than by the query, capped at 10 GB per database and 2,000,000 bytes per stored value. Every surprise below follows from one of those four facts.

Siblings: [the platform index](/a/cloudflare-os), [Workers and Durable Objects](/a/cloudflare-os-workers), [R2 for the fields that do not fit](/a/cloudflare-os-r2).

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## Cloudflare's own Workers architect calls D1 a wrapper

Kenton Varda, who built the Workers runtime, wrote this on Hacker News in June 2026:

> I'll let you in on a sort of dirty secret:
>
> It's almost always better to use Durable Objects storage, rather than D1. Even if you only want a single global database, it's better to implement that as a singleton Durable Object, than by using D1. Because that's all D1 itself actually is: a singleton Durable Object that exposes an API to its SQLite database. It's just a wrapper.

His decision rule, in the same comment:

> If your app does no more than one DB query per request, then D1 is fine: the Worker runs near the end user, and talks over the long-haul network to D1 just once. Whereas with Durable Objects, your Worker would talk over the long-haul network to the Durable Object. No difference.
>
> But if your app ever does two or more queries in series for a single request, then Durable Objects becomes vastly better, because you get to move that query-chaining code to happen directly where the database lives, rather than have multiple round trips.

And the reason D1 exists at all: "Really, though, the only reason D1 exists is for comfort. Once you know how to use Durable Objects, there's no reason to use D1." He names one exception — D1's read replication is not yet available to raw Durable Objects.

A **Durable Object** is a single-instance JavaScript class with private storage, addressed by name, that Cloudflare guarantees exists exactly once globally. A SQLite-backed one carries its own embedded SQLite database with the same SQL limits as D1, and your code runs in the same process as it — `sql.exec()` is a local function call, not a network request. That is the entire latency difference.

**Verdict.** Choose D1 when all three hold: the data is one global relational set, the request path makes one or two queries, and you want the operational surface D1 has and raw Durable Objects do not — `wrangler d1 execute` against production, versioned migration files, Time Travel point-in-time restore, and read replicas. Choose a SQLite Durable Object when the data partitions naturally per user, tenant, room or document, or when a single request chains three or more dependent queries. Those two rules cover almost every case; when they conflict, the query-chaining rule wins, because round trips are the thing you cannot optimise away later.

## The limits, fetched today, are the actual specification

Every number below is from `https://developers.cloudflare.com/d1/platform/limits/`, last updated 21 April 2026 per the page itself.

| Limit | Workers Paid | Workers Free |
| --- | --- | --- |
| Databases per account | 50,000 (raisable by request) | 10 |
| Maximum database size | 10 GB — cannot be raised | 500 MB |
| Maximum storage per account | 1 TB (raisable by request) | 5 GB |
| Time Travel window | 30 days | 7 days |
| Queries per Worker invocation | 1,000 | 50 |
| Columns per table | 100 | 100 |
| Rows per table | Unlimited within the size cap | Unlimited within the size cap |
| Maximum string, BLOB or table row size | **2,000,000 bytes** | 2,000,000 bytes |
| Maximum SQL statement length | **100,000 bytes** | 100,000 bytes |
| Maximum bound parameters per query | **100** | 100 |
| Maximum arguments per SQL function | 32 | 32 |
| Bytes in a `LIKE` or `GLOB` pattern | 50 | 50 |
| Maximum SQL query duration | 30 seconds | 30 seconds |
| Simultaneous D1 connections per Worker invocation | 6 | 6 |
| Rows read included | 25 billion / month, then $0.001 per million | 5 million / day, hard stop |
| Rows written included | 50 million / month, then $1.00 per million | 100,000 / day, hard stop |
| Storage included | 5 GB, then $0.75 per GB-month | 5 GB total |

Two of these get their own sections below: the 2,000,000-byte value cap, and rows as the billing unit. Three more matter immediately. **The 10 GB cap cannot be raised** — the docs say so in a caution box. **Each database is single-threaded**, so throughput is `1 / average query duration`: 1 ms queries give roughly 1,000 per second, 100 ms queries give 10. **Batch limits apply per statement**, not per batch, so a `db.batch()` of 40 statements can carry 40 × 100 KB of SQL.

## One undocumented ceiling: five terms in a compound SELECT

Building a table inventory with `SELECT 'x' t, COUNT(*) n FROM x UNION ALL …` across 89 tables failed immediately:

```
too many terms in compound SELECT: SQLITE_ERROR [code: 7500]
```

Bisecting against production found the number. Five `UNION ALL` terms succeed. Six fail.

```bash
# 5 terms — succeeds.  6 terms — SQLITE_ERROR 7500.
npx wrangler d1 execute <DB_NAME> --remote \
  --command "SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1"
```

Upstream SQLite defaults `SQLITE_MAX_COMPOUND_SELECT` to 500. D1 answers at 5, and this is not on the limits page. If anything generates SQL for you — an ORM, a reporting layer, a tool emitting multi-row `VALUES` as unions — chunk at five.

`dbstat`, the virtual table that reports per-table page usage, is also compiled out: `no such table: dbstat: SQLITE_ERROR [code: 7500]`. Per-table size has to be estimated with `SUM(LENGTH(col))`.

## Rows are the billing unit, and an unindexed predicate bills the whole table

You are not billed per query. You are billed for **rows read** — every row the engine had to scan, not the rows it returned. This is the most expensive misunderstanding available on D1.

The pricing page states it without hedging: a full scan of a 5,000-row table counts as 5,000 rows read, and "A query that filters on an unindexed column may return fewer rows to your Worker, but is still required to read (scan) more rows to determine which subset to return." Row size is irrelevant — "A row that is 1 KB and a row that is 100 KB both count as one row."

Rows written are simpler: `INSERT`, `UPDATE` and `DELETE` each cost one written row per row affected, and **an index adds a second written row** whenever the indexed column is part of the write.

### Where to see the number

Every D1 result carries a `meta` object. Read `meta.rows_read` and `meta.rows_written` in your Worker:

```js
const res = await env.DB.prepare("SELECT * FROM articles WHERE title = ?1")
  .bind(title).all();
console.log(res.meta.rows_read, res.meta.rows_written, res.meta.duration);
```

From the CLI, `--json` prints the same object:

```bash
npx wrangler d1 execute loop-content-spine --remote --json \
  --command "SELECT COUNT(*) FROM articles WHERE title = 'D1 as the spine'"
```

Across the account it is in the Cloudflare dashboard at **your D1 database → Metrics → Row Metrics**, and in the GraphQL Analytics API.

### The same query, measured with and without an index

Run against a scratch table of 50,000 rows in this build's preview database, so nothing production was touched. Commands are in the measurement section at the bottom.

| Step | Result | `rows_read` | `rows_written` | Duration |
| --- | --- | --- | --- | --- |
| `SELECT COUNT(*) FROM d1_bench WHERE tenant = 'tenant-42'` — no index | 94 | **50,000** | 0 | 5.7362 ms |
| `CREATE INDEX d1_bench_tenant_idx ON d1_bench (tenant)` | — | 100,442 | 50,001 | 31.6053 ms |
| The identical `SELECT` again — index present | 94 | **95** | 0 | 0.2432 ms |
| `INSERT INTO d1_bench (tenant, payload) VALUES ('tenant-42','x')` | — | 0 | **2** | 0.28 ms |

Same query, same answer, 526 times fewer rows read and 23.6 times faster. The last row is the index's cost made visible: one insert now writes two rows, one to the table and one to the index, exactly as the pricing page says.

Production shows the same shape. `articles` has 2,186 rows and `slug` as its primary key:

| Query on `articles` (2,186 rows) | Result | `rows_read` | Duration |
| --- | --- | --- | --- |
| `WHERE slug = 'cloudflare-os-d1'` (indexed primary key) | 1 | **1** | 0.2003 ms |
| `WHERE title = 'D1 as the spine: two SQL databases, one of them append-only'` | 1 | **2,186** | 5.8578 ms |

On the largest table, `turn_costs` at 135,229 rows and indexed on `ts` only, one equality filter on the unindexed `key` column read **135,247 rows in 140.4919 ms** to return a count of 2,817.

### The arithmetic

Rows read: $0.001 per million after 25 billion included per month. Take the 50,000-row scan at one query per second — a modest API endpoint.

```
86,400 queries/day × 50,000 rows      = 4,320,000,000 rows read/day
4,320,000,000 × 30                    = 129,600,000,000 rows read/month
129,600,000,000 − 25,000,000,000 incl = 104,600,000,000 billable
104,600 millions × $0.001             = $104.60 / month
```

The indexed version of the identical query:

```
86,400 queries/day × 95 rows          = 8,208,000 rows read/day
8,208,000 × 30                        = 246,240,000 rows read/month
246,240,000 < 25,000,000,000 included = $0.00 / month
```

One `CREATE INDEX` is the difference between $104.60 and nothing. Its one-time write cost was 50,001 rows written — five cents at $1.00 per million.

On the free plan the same comparison is not a bill, it is an outage: 5,000,000 rows read per day, so **100 queries per day** at 50,000 rows each before D1 starts returning errors, against 52,631 at 95 rows each.

At the top end this is real money. A solo founder posted a postmortem in April 2026 after a Durable Object alarm loop — same rows-read meter — peaked at roughly 930 billion row reads per day and produced a $34,895 invoice with zero users:

> My DO agent's onStart() handler called this.ctx.storage.setAlarm() on every wake-up without checking whether an alarm was already scheduled.

No platform warning fired. Set a Cloudflare billing alert before you set anything else.

## SQLITE_TOOBIG is the 2,000,000-byte value cap, and serialization gets you there first

The exact string D1 surfaces is `D1_ERROR: string or blob too big`, with the underlying SQLite constant `SQLITE_TOOBIG`. It fires when any single string, BLOB or table row being written exceeds 2,000,000 bytes. It is not a database-size error and not a statement-length error — those have their own messages.

Two things make it arrive earlier than expected. The 100,000-byte statement cap means a large value can blow the statement before it blows the row. And the ceiling can be reached through serialization rather than raw size. A minimal reproduction filed against `cloudflare/workers-sdk` in May 2026:

> Workflows (local `wrangler dev`): a ~200 KB `Uint8Array` step output fails with `string or blob too big: SQLITE_TOOBIG`, but the same bytes as an `ArrayBuffer` (or a 2 MB string) succeed

200 KB of bytes failing while 2 MB of string succeeds is the tell: what is measured is the serialized representation, not your data.

### A worked example, with the numbers

The `articles` table stores each body as `TEXT` and everything non-scalar — claims, sources, widgets, the append-only revision chain — as one JSON `meta` column. One row broke.

`cognitive-stack-intro` carried 94 claims and 90 sources plus 24 inline revision snapshots, each holding a full copy of the body, claims and sources at that point in time. Stored `meta` reached **2,068,258 bytes**, 68,258 over the cap. Every write to that row — repair, claim, fill-slots — returned HTTP 500 with `D1_ERROR: string or blob too big`. Readable, permanently unwritable.

Three options, and only three:

| Option | What it does | Cost | When it is right |
| --- | --- | --- | --- |
| **Merge** | Fold the row into a related row and redirect | Loses the row's identity and its URL | The row was a near-duplicate anyway |
| **Prune** | Delete the least valuable fields until under 2 MB | Loses data permanently; the cap comes back as the row grows | The excess is genuinely junk and growth has stopped |
| **Offload to R2** | Move the heavy fields to object storage, keep a pointer plus a hash in D1 | One extra fetch when the heavy field is actually read | The data must be kept and the row keeps growing — the general answer |

Offload won, because pruning an append-only chain is the one thing the chain exists to prevent. `functions/_lib/revisions_r2.js` writes each full snapshot to R2 at `revisions/<slug>/<n>.json` and leaves a slim index entry in D1 carrying `n`, `ts`, `title`, `bytes`, `prev_hash`, `hash` and `r2_key`. Hash-chain verification still runs from D1 alone; the heavy content is fetched only when a specific revision is requested. `migrateRevisions()` runs at the top of every write, so any write heals a bloated row before adding to it.

`meta` fell from 2,068,258 bytes to 206,362 and the row returned HTTP 200. Measured again today it holds 1,990 bytes of body and 267,379 bytes of `meta` while carrying 80 claims, 112 sources and 43 revisions — more content than the version that could not be saved, in an eighth of the space. The object side is in [R2 as the place large fields go](/a/cloudflare-os-r2).

The same cap caught a bulk import from the other direction. Loading 663,115 iMessage rows hit `SQLITE_TOOBIG` on the **statement** cap rather than the row cap, because the importer packed many rows into one `INSERT`. Fix: byte-aware batching at ≤80,000 bytes per statement, plus a 20,000-character cap on any single message body after one arrived at 123 KB.

**The rule from both cases:** any column whose size is a function of history rather than of the schema belongs in R2 with a pointer in D1 — revision chains, audit payloads, uploaded documents, model transcripts. Keep the hash in D1 so the pointer is verifiable.

The largest row still in the table is 692,724 bytes of body plus 821,837 bytes of `meta` — **1,514,561 bytes, 76% of the cap**. It will need the same treatment.

## A transaction cannot span two requests, and the workaround is a deliberate parse error

D1 runs in auto-commit. The Workers Binding API documentation is plain: `batch()` "Sends multiple SQL statements inside a single call to the database… D1 operates in auto-commit. Our implementation guarantees that each statement in the list will execute and commit, sequentially, non-concurrently." Batched statements are a transaction — "If a statement in the sequence fails, then an error is returned for that specific statement, and it aborts or rolls back the entire sequence."

What is unavailable is holding a transaction open across two round trips: read, decide in JavaScript, write atomically against the state you read. An operator hit exactly this in April 2025:

> Another fun limitation is that a transaction cannot span multiple D1 requests, so you can't select from the database, execute application logic, and then write to the database in an atomic way. At most, you can combine multiple statements into a single batch request that is executed atomically.
>
> When I needed to ensure atomicity in such a multi-part "transaction", I ended up making a batch request, where the first statement in the batch checks a precondition and forces a JSON parsing error if the precondition is not met, aborting the rest of the batch statements.

The statement they used:

```sql
SELECT
  IIF(<precondition>, 1, json_extract("inconsistent", "$")) AS consistent
FROM ...
```

If the precondition holds, the statement returns 1. If not, `json_extract` is handed the invalid JSON literal `inconsistent`, throws, and the batch aborts before any write lands. Their own limit on it: "For anything more complex, one would probably need to create tables to store temporary values, and translate a lot of application logic into SQL statements to achieve atomicity."

The three honest options, ranked:

1. **Push the condition into SQL and use `batch()`.** Works when the precondition fits a `WHERE` or a `CASE`. Prefer `UPDATE … WHERE version = ?` over a poison-pill parse error: optimistic concurrency with a version column is the same guarantee written on purpose, and it reports failure as `changes: 0` rather than by throwing.
2. **Move the entity into a Durable Object.** Single-threaded by construction, so read-decide-write inside one method is atomic with no ceremony. This is where the constraint is pushing you.
3. **Use a database with real interactive transactions**, reached through Hyperdrive. Correct when the logic genuinely cannot be expressed in one round trip.

## Latency: two production reports, both true, measuring different things

The negative reports are specific and repeated. From someone running D1 in production across multiple projects for over a year:

> Using D1 in production for over an year on multiple projects - I can confirm response times to simple queries regularly take 400ms and beyond. On top there's constant network, connection and a plethora of internal errors.

From an evaluation that ended in rejection, with the comparison numbers:

> Using CF Workers + DigitalOcean Postgres, I was seeing query responses in the 50-100ms range.
>
> Using CF Workers + CF D1, I was seeing query responses in the 300-3000ms range.
>
> Both workers had Smart Placement enabled.

From a production user in April 2026, on reliability rather than latency:

> D1 reliability has been bad in our experience. We've had queries hanging on their internal network layer for several seconds, sometimes double digits over extended periods (on the order of weeks).

Against all of that, in the same thread as the 400 ms report:

> I am running 2 production apps on Cloudflare workers, both using D1 for primary storage. I found the performance ok, especially after enabling Smart Placement [1].

Neither side is wrong. They differ on two variables: how many D1 calls a single request makes, and whether the Worker ended up near the database.

**Smart Placement** is the mechanism in the middle. By default a Worker runs in the data centre nearest the user, which is the worst place to be if it then makes several round trips to a database in one fixed location. Smart Placement analyses a Worker's traffic and moves execution close to the backend instead. The documentation is precise about its boundaries: it takes up to 15 minutes to analyse a Worker after deployment; it needs consistent traffic from multiple locations to decide anything; it "only considers locations where the Worker has previously run", so it cannot place a Worker somewhere that never receives traffic; and it reverts itself when it makes things slower, which the docs put at fewer than 1% of Workers. Enable it in `wrangler.toml`:

```toml
[placement]
mode = "smart"
```

Its ceiling, from Varda in the same thread: "even if you have the Worker running in the same colo or even same machine as the D1 database, you're still speaking a network protocol to talk to it, serializing and deserializing data, switch contexts, etc. Directly invoking SQLite locally will still be orders of magnitude faster."

**Verdict.** Budget one long-haul round trip per D1 call, from wherever the Worker runs to wherever the database lives. A request making one query pays one, and Smart Placement will not help it — moving the Worker to the database just moves the same hop to the other end. A request making six sequential queries pays six, and that is where the 400 ms and 3-second numbers come from. Smart Placement collapses those six, which is the difference between the negative reports and the positive one. If the path is inherently chatty and cannot be flattened into one `batch()`, stop tuning D1 and move the entity into a Durable Object, where the queries stop crossing a network at all.

Two mitigations before concluding D1 is too slow. **Read replication** puts read-only copies in other regions, used through the Sessions API — `env.DB.withSession()` — which attaches a bookmark to each query so a session keeps sequential consistency even when different replicas serve it. Replicas cost nothing extra; you pay the same `rows_read`. Without the Sessions API it does nothing: "otherwise all queries will continue to be executed only by the primary database." **Caching** is the other: on this build most article reads never reach D1, because an edge cache or a KV snapshot answers first — [KV as the fast lane](/a/cloudflare-os-kv).

## Per-tenant sharding is documented, and impractical for the reason nobody mentions

Cloudflare's limits FAQ recommends the pattern: "D1 is designed for horizontal scale out across multiple, smaller (10 GB) databases, such as per-user, per-tenant or per-entity databases." 50,000 databases per account on the paid plan, raisable into the millions.

The count is not the problem. A Worker can only talk to a database bound to it at deploy time:

> It's not possible to set up per-user data in D1. Like in theory you probably could, but the DX infrastructure to make it possible is non-existent - you have to explicitly bind each database into your worker. At best you could try to manually shard data but that has a lot of drawbacks. Or maybe have the worker republish itself whenever a new user is registered? That seems super dangerous and unlikely to work in a concurrent fashion […] When I asked on Discord, someone from Cloudflare confirmed that DO is indeed the only way to do tenancy-based sharding

The limits page gives the hard number: bindings are roughly 150 bytes each inside a 1 MB script-metadata budget, so "approximately 5,000" D1 bindings per Worker script. The documented 50,000 databases and the reachable 5,000 are ten times apart, and every new tenant needs a redeploy.

**What to do instead.** Durable Objects address instances by name at runtime — `env.MY_DO.idFromName(tenantId)` — one binding for the class, unlimited instances behind it, each with its own 10 GB SQLite database and no per-class storage cap. Tenancy sharding without a deploy. Someone running it at scale, July 2026:

> We serve multi million MAU on sqlite orchestrated through durable objects. It's not the most complex thing in the world but it goes further than CRUD. It costs us such a small amount of money for what it does.

If you must stay on D1: bind a fixed number of databases up front and hash tenants into them, accepting that rebalancing means a migration. Dynamic database-per-tenant does not exist on D1 today.

## Migrations are ordered files, and `d1 execute` silently desynchronises them

Migrations are `.sql` files in `migrations/`, named with a leading sequence number and applied in filename order. Wrangler records what it applied in a `d1_migrations` table inside the database.

```bash
# 1. Create an empty, correctly-numbered file. Prints the path it created.
npx wrangler d1 migrations create loop-content-spine "add_tenant_index"
#    -> migrations/0331_add_tenant_index.sql

# 2. Write the SQL into that file. Forward-only; write it to be re-runnable.
#    CREATE INDEX IF NOT EXISTS idx_articles_register ON articles(register);

# 3. See exactly what would run, before it runs.
npx wrangler d1 migrations list loop-content-spine --remote

# 4. Apply to the preview database first.
npx wrangler d1 migrations apply loop-content-spine-preview --remote

# 5. Then production.
npx wrangler d1 migrations apply loop-content-spine --remote
```

Use the **database name**, not the binding name. The docs give the reason: "the binding name can change, whereas the database name cannot."

**There is no `down` migration.** The system supports create, list and apply — nothing else. Rolling back means one of two things:

```bash
# Option A — a forward migration that undoes the change. Preferred.
npx wrangler d1 migrations create loop-content-spine "drop_tenant_index"

# Option B — Time Travel, point-in-time restore, 30 days on Workers Paid.
npx wrangler d1 time-travel info loop-content-spine
# ⚠️ The current bookmark is '0000110b-000002cc-000050b4-90fa940d708157e29a40c704f1591c8e'
npx wrangler d1 time-travel restore loop-content-spine --bookmark=<BOOKMARK>
# or:  --timestamp=2026-07-25T00:00:00Z
```

Take the bookmark **before** you apply, not after you break something. Time Travel restores the whole database, so it is a blunt instrument for one bad table.

The failure mode this repository demonstrates is drift. There are 330 `.sql` files in `migrations/`. `d1_migrations` records 136 applied, most recently `0133_charlie_audit.sql`. `wrangler d1 migrations list` therefore reports 202 still to be applied — and nearly all of them already are, because those schema changes were pushed with `wrangler d1 execute --command "CREATE TABLE …"` instead of through the runner. Wrangler cannot know that. Running `apply` now would replay 202 files against a schema that already has them.

**How to avoid it:** never change schema with `d1 execute`. If you already have, insert the missing filenames into `d1_migrations` so the ledger matches reality, then resume using `apply`. Check they agree before every release:

```bash
ls migrations/*.sql | wc -l
npx wrangler d1 execute loop-content-spine --remote \
  --command "SELECT COUNT(*) applied, MAX(name) latest FROM d1_migrations"
```

## Choosing between D1 and the three things it competes with

| | D1 | Durable Object + SQLite | Hyperdrive → Postgres/MySQL | Hosted database, direct |
| --- | --- | --- | --- | --- |
| What it is | Managed SQLite in one location, exposed over the network | Your code and an embedded SQLite file in the same process | Connection pooling and caching in front of your own regional database | A normal database reached over the internet |
| Query latency from a Worker | One long-haul round trip per call | Effectively zero once you are in the object | One round trip to the pooled connection, warm | Full connection setup plus round trip |
| Multi-query request | Pays N round trips; needs Smart Placement | Pays one hop total, then local calls | Pays N round trips but keeps the connection | Worst case |
| Transactions across app logic | No | Yes, single-threaded by construction | Yes, full interactive transactions | Yes |
| Per-tenant sharding | Not practically — bindings are static | Yes, `idFromName()` at runtime | Via your own schema | Via your own schema |
| Size ceiling | 10 GB per database, hard | 10 GB per object, unlimited objects | Whatever your database does | Whatever your database does |
| Read replicas | Yes, via the Sessions API | Not yet | Your database's own replicas | Your database's own replicas |
| Billing unit | Rows read and written | Rows read and written, plus object duration | Workers time; the database is billed separately | Database bill plus egress |
| Operational surface | `wrangler d1 execute`, migrations, Time Travel | You build it | Your existing tooling, unchanged | Your existing tooling |
| **Verdict** | One global relational set under 10 GB, ≤2 queries per request, and you want the CLI and migrations | Anything per-entity, or any chatty request path | You already have Postgres or MySQL and are not leaving it | Only if Hyperdrive cannot reach it |

Two mistakes to avoid: reaching for D1 because it is the dashboard default when the data is obviously per-user, and leaving Cloudflare over D1 latency when the fix was one binding change.

## Symptom, cause, fix

| Symptom | Cause | Fix |
| --- | --- | --- |
| `D1_ERROR: string or blob too big` | A single value or row exceeds 2,000,000 bytes | Offload the heavy field to R2 and keep a pointer plus hash in D1 |
| `string or blob too big` on a bulk insert | The statement, not the row, exceeded 100,000 bytes | Byte-aware batching; cap each statement at ~80,000 bytes |
| `too many terms in compound SELECT: SQLITE_ERROR [code: 7500]` | More than 5 `UNION`/`UNION ALL` terms in one statement | Chunk generated SQL into groups of five |
| `no such table: dbstat` | The `dbstat` virtual table is not compiled into D1 | Estimate table size with `SUM(LENGTH(col))` |
| `D1 DB is overloaded. Requests queued for too long.` | Queries are slow and the single-threaded database has a full queue | Index the predicates; shorten each query; spread load; shard |
| `D1 DB is overloaded. Too many requests queued.` | Request rate exceeds `1 / query duration` | Same, plus read replicas via the Sessions API for read-heavy load |
| `Exceeded maximum DB size.` | The database passed 10 GB, which cannot be raised | Delete rows, or shard across databases |
| `Your account has exceeded D1's maximum account storage limit…` | All databases together passed the account cap | Delete unused databases or raise the account limit by request |
| `D1 DB exceeded its CPU time limit and was reset.` | One query scanned far too much — a huge table scan or a bulk import | Split into smaller shards; index the predicate |
| `D1 DB storage operation exceeded timeout which caused object to be reset.` | A single write touched gigabytes | Batch the write into chunks of ~1,000 rows |
| `D1 DB reset because its code was updated.` | Cloudflare restarted the Durable Object backing your database | Retry — it is transient and expected. Make writes idempotent |
| `Network connection lost.` / `Cannot resolve D1 DB due to transient issue on remote node.` | Transient network fault between Worker and database | Retry, but only if the query is idempotent |
| `D1_TYPE_ERROR` | A bound parameter was `undefined` | D1 does not accept `undefined`. Coerce to `null` |
| Bill far higher than query volume suggests | Unindexed predicates scanning whole tables | Read `meta.rows_read`; add an index; recheck |
| Queries fine locally, slow in production | Worker running near the user, database elsewhere, several round trips | Enable Smart Placement, or flatten into one `batch()`, or move to a Durable Object |

## Every measurement on this page, and the command that produced it

Taken 25 July 2026 against this build's production D1 databases with wrangler 4.103.0. Account id and database ids redacted; substitute your own database name. Reads only, except the scratch table, created and dropped in the **preview** database.

**1. Table inventory — 89 tables, 243,173 rows.** The five-term compound-SELECT ceiling forces chunks of five:

```bash
npx wrangler d1 execute <DB_NAME> --remote --json \
  --command "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"

npx wrangler d1 execute <DB_NAME> --remote --json --command \
"SELECT 'turn_costs' t, COUNT(*) n FROM turn_costs UNION ALL SELECT 'log' t, COUNT(*) n FROM log UNION ALL SELECT 'imessages' t, COUNT(*) n FROM imessages UNION ALL SELECT 'leads' t, COUNT(*) n FROM leads UNION ALL SELECT 'articles' t, COUNT(*) n FROM articles"
```

Largest first: `turn_costs` 135,229 · `log` 59,164 · `imessages` 10,536 · `leads` 10,089 · `agent_turns` 6,844 · `tasks` 6,055 · `cc_turns` 2,296 · `articles` 2,186 · `pipeline` 2,058 · `directory` 892. Six tables are empty.

**2. Database size — 281,993,216 bytes on the content database, 1,062,027,264 bytes on the event log.** `meta.size_after` is returned on every query, so any read gives it:

```bash
npx wrangler d1 execute <DB_NAME> --remote --json --command "SELECT 1"   # read meta.size_after
npx wrangler d1 execute <LEDGER_NAME> --remote --json --command "SELECT COUNT(*) FROM events"
```

The event log holds 400,907 rows at 1.062 GB — 10.6% of the 10 GB per-database ceiling and already past the 500 MB the free plan allows. Both databases together are 1.25 GB, inside the 5 GB included, so storage costs $0.00.

**3. Largest table by stored bytes — `articles`, 78,057,031 bytes.** `dbstat` is unavailable, so size is summed from the columns:

```bash
npx wrangler d1 execute <DB_NAME> --remote --json \
  --command "SELECT SUM(LENGTH(COALESCE(body,''))+LENGTH(COALESCE(meta,''))) AS bytes FROM articles"

npx wrangler d1 execute <DB_NAME> --remote --json \
  --command "SELECT slug, LENGTH(COALESCE(body,'')) body_bytes, LENGTH(COALESCE(meta,'')) meta_bytes FROM articles ORDER BY (LENGTH(COALESCE(body,''))+LENGTH(COALESCE(meta,''))) DESC LIMIT 5"
```

The second query read 4,372 rows to sort 2,186 — an unindexed sort reads the table twice. Largest row: 692,724 + 821,837 = 1,514,561 bytes.

**4. Indexed versus unindexed, identical query.** Against the preview database only:

```bash
DB=<PREVIEW_DB_NAME>
npx wrangler d1 execute $DB --remote --command \
  "CREATE TABLE d1_bench (id INTEGER PRIMARY KEY, tenant TEXT NOT NULL, payload TEXT)"

npx wrangler d1 execute $DB --remote --command \
"INSERT INTO d1_bench (tenant, payload) SELECT 'tenant-' || (abs(random()) % 500), hex(randomblob(32)) FROM (WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM c WHERE x < 50000) SELECT x FROM c)"

npx wrangler d1 execute $DB --remote --json --command \
  "SELECT COUNT(*) AS n FROM d1_bench WHERE tenant = 'tenant-42'"        # rows_read 50000, 5.7362 ms

npx wrangler d1 execute $DB --remote --json --command \
  "CREATE INDEX d1_bench_tenant_idx ON d1_bench (tenant)"                # rows_written 50001

npx wrangler d1 execute $DB --remote --json --command \
  "SELECT COUNT(*) AS n FROM d1_bench WHERE tenant = 'tenant-42'"        # rows_read 95, 0.2432 ms

npx wrangler d1 execute $DB --remote --command "DROP TABLE d1_bench"
```

The recursive CTE is how you generate N rows in one statement without passing the 100,000-byte statement cap.

**5. The compound-SELECT ceiling.** Bisected with `SELECT 1 UNION ALL …` at 2, 5, 6, 8, 10, 15 and 20 terms. 2 and 5 succeed; 6 and above return `SQLITE_ERROR [code: 7500]`.

**6. Migration drift.** `ls migrations/*.sql | wc -l` → 330. `SELECT COUNT(*) applied, MAX(name) latest FROM d1_migrations` → 136, `0133_charlie_audit.sql`. `npx wrangler d1 migrations list <DB_NAME> --remote` → 202 listed as to be applied.

## A fresh read-only receipt reproduces the row meter and the five-term ceiling

Wrangler 4.103.0 ran seven read-only statements against the two production databases at `2026-07-26T05:38:25.854Z`. No table or row changed.

| Check | Result | `rows_read` | SQL duration |
| --- | --- | ---: | ---: |
| `SELECT COUNT(*) FROM articles` | 2,186 articles | 2,186 | 0.1973 ms |
| Primary-key lookup for `cloudflare-os-d1` | 1 row | 1 | 0.1485 ms |
| Equality lookup on the unindexed old title | 1 row | 2,186 | 5.8711 ms |
| Five `UNION ALL` terms | HTTP/API success; 5 rows returned | 0 | 0.1638 ms |
| Six `UNION ALL` terms | `too many terms in compound SELECT: SQLITE_ERROR [code: 7500]` | — | — |
| `SELECT COUNT(*) FROM events` | 401,112 events; database size 1,062,916,096 bytes | 401,112 | 6.8717 ms |
| Migration ledger | 136 applied; latest `0133_charlie_audit.sql` | 136 | 3.2203 ms |

Run the same harmless checks with your database names:

```bash
npx wrangler d1 execute <DB_NAME> --remote --json   --command "SELECT COUNT(*) AS articles FROM articles"

npx wrangler d1 execute <DB_NAME> --remote --json   --command "SELECT slug FROM articles WHERE slug='cloudflare-os-d1'"

npx wrangler d1 execute <DB_NAME> --remote --json   --command "SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1"

npx wrangler d1 execute <DB_NAME> --remote --json   --command "SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1"
```

The fifth query is expected to fail. That failure is the measurement: the same database accepted five compound terms and rejected six with code 7500.
