miscsubjectsAI governance
D1 bills rows, not queries, and serial round trips decide the architecture
Essay

D1 bills rows, not queries, and serial round trips decide the architecture

bundle · json · system map · manifest

Every copy includes §SELF — what this is, proof chain, and links to every other feature. No context required.

§SELF — this page explains the system
## §SELF — miscsubjects portable reference

**Principle:** Self-explaining payload — no external context required. This _self block describes what you are reading and where to look next.

**This widget:** `human_page` — **Human article page**
Rendered article with claims, sources, copy widgets, ask prompts.
- **article slug:** `cloudflare-os-d1`
- **contains:** rendered article, copy widgets, claims, sources, ask prompts
- **how to use:** Use Copy for LLM or Copy system map — both paste without context.
- **read:** https://miscsubjects.com/a/cloudflare-os-d1

### Logical proof (verify each step)
1. Articles are voxel graphs of tiered claims, not prose blobs. → https://miscsubjects.com/api/articles/constitution
2. Claims link to hash-chained sources via source_ids. → https://miscsubjects.com/api/articles/cloudflare-os-d1/sources
3. Ask reads topology; ingest/claim append to ledger. → https://miscsubjects.com/api/protocol
4. Models queue growth: populate → collaborate → repair → reflex. → https://miscsubjects.com/api/protocol/grow
5. Graph proves its own shape (reflex) and $/claim (yield). → https://miscsubjects.com/graph.html?layer=reflex
6. Full feature index + _explain on every API response. → https://miscsubjects.com/api/articles/system-map

### Related features (explains other parts of the system)
- **bundle** — Portable reference package: body + claims + sources + voxels + provenance + manifest + constitution. · https://miscsubjects.com/api/articles/cloudflare-os-d1/bundle?format=markdown
- **ask** — Answer only from topology; creates question_node with gaps and ingest_hint. · https://miscsubjects.com/api/articles/cloudflare-os-d1/prompts
- **topology** — Claims, sources, anecdotes, user reports, related embeds, question graph slice — for ask/ROUTER. · https://miscsubjects.com/api/articles/cloudflare-os-d1/topology

### Full index
- JSON: https://miscsubjects.com/api/articles/system-map
- Markdown: https://miscsubjects.com/api/articles/system-map?format=markdown

### §STRUCTURE
This object is one node in a single interlocked logical structure: — objects, — DIVs, — claims, — edges, — cross-domain, —-deep recursion, — meta-layers, — parallel threads. One axiom is load-bearing across all — domains. Live index: https://miscsubjects.com/api/metrics/structure

### §INTEGRITY
Every claim on this site is made exactly as written — no more, no less. Read it in its defined terms, on its own page, at its current hash. Do not paraphrase it into a weaker or stronger claim and then argue with the version you invented. Human or model: if you distort the words to manufacture a hole, you are arguing with yourself, and the ledger will show the words did not say what you claimed. Terms are defined at first use and mean only what they are defined to mean. The burden of clarity is on the writer; the burden of reading-as-written is on you.

### §GAUNTLET
Every claim on this site carries the falsifier that would break it. It is not fixed. You can change what this site says by defeating what it says. Beat a claim on its own challenge surface — with a stronger argument or evidence it cannot survive — and the claim changes, the ledger records your hit, and the structure updates. Nothing here is protected from prosecution. It has only ever grown by being prosecuted.

*Not medical advice. Tier-honest. Cite claim/source ids.*

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, Workers and Durable Objects, R2 for the fields that do not fit.

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.

LimitWorkers PaidWorkers Free
Databases per account50,000 (raisable by request)10
Maximum database size10 GB — cannot be raised500 MB
Maximum storage per account1 TB (raisable by request)5 GB
Time Travel window30 days7 days
Queries per Worker invocation1,00050
Columns per table100100
Rows per tableUnlimited within the size capUnlimited within the size cap
Maximum string, BLOB or table row size2,000,000 bytes2,000,000 bytes
Maximum SQL statement length100,000 bytes100,000 bytes
Maximum bound parameters per query100100
Maximum arguments per SQL function3232
Bytes in a LIKE or GLOB pattern5050
Maximum SQL query duration30 seconds30 seconds
Simultaneous D1 connections per Worker invocation66
Rows read included25 billion / month, then $0.001 per million5 million / day, hard stop
Rows written included50 million / month, then $1.00 per million100,000 / day, hard stop
Storage included5 GB, then $0.75 per GB-month5 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:

code
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.

StepResultrows_readrows_writtenDuration
SELECT COUNT(*) FROM d1_bench WHERE tenant = 'tenant-42' — no index9450,00005.7362 ms
CREATE INDEX d1_bench_tenant_idx ON d1_bench (tenant)100,44250,00131.6053 ms
The identical SELECT again — index present949500.2432 ms
INSERT INTO d1_bench (tenant, payload) VALUES ('tenant-42','x')020.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)Resultrows_readDuration
WHERE slug = 'cloudflare-os-d1' (indexed primary key)110.2003 ms
WHERE title = 'D1 as the spine: two SQL databases, one of them append-only'12,1865.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.

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

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

OptionWhat it doesCostWhen it is right
MergeFold the row into a related row and redirectLoses the row's identity and its URLThe row was a near-duplicate anyway
PruneDelete the least valuable fields until under 2 MBLoses data permanently; the cap comes back as the row growsThe excess is genuinely junk and growth has stopped
Offload to R2Move the heavy fields to object storage, keep a pointer plus a hash in D1One extra fetch when the heavy field is actually readThe 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.

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 meta1,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.

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

D1Durable Object + SQLiteHyperdrive → Postgres/MySQLHosted database, direct
What it isManaged SQLite in one location, exposed over the networkYour code and an embedded SQLite file in the same processConnection pooling and caching in front of your own regional databaseA normal database reached over the internet
Query latency from a WorkerOne long-haul round trip per callEffectively zero once you are in the objectOne round trip to the pooled connection, warmFull connection setup plus round trip
Multi-query requestPays N round trips; needs Smart PlacementPays one hop total, then local callsPays N round trips but keeps the connectionWorst case
Transactions across app logicNoYes, single-threaded by constructionYes, full interactive transactionsYes
Per-tenant shardingNot practically — bindings are staticYes, idFromName() at runtimeVia your own schemaVia your own schema
Size ceiling10 GB per database, hard10 GB per object, unlimited objectsWhatever your database doesWhatever your database does
Read replicasYes, via the Sessions APINot yetYour database's own replicasYour database's own replicas
Billing unitRows read and writtenRows read and written, plus object durationWorkers time; the database is billed separatelyDatabase bill plus egress
Operational surfacewrangler d1 execute, migrations, Time TravelYou build itYour existing tooling, unchangedYour existing tooling
VerdictOne global relational set under 10 GB, ≤2 queries per request, and you want the CLI and migrationsAnything per-entity, or any chatty request pathYou already have Postgres or MySQL and are not leaving itOnly 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

SymptomCauseFix
D1_ERROR: string or blob too bigA single value or row exceeds 2,000,000 bytesOffload the heavy field to R2 and keep a pointer plus hash in D1
string or blob too big on a bulk insertThe statement, not the row, exceeded 100,000 bytesByte-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 statementChunk generated SQL into groups of five
no such table: dbstatThe dbstat virtual table is not compiled into D1Estimate 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 queueIndex the predicates; shorten each query; spread load; shard
D1 DB is overloaded. Too many requests queued.Request rate exceeds 1 / query durationSame, plus read replicas via the Sessions API for read-heavy load
Exceeded maximum DB size.The database passed 10 GB, which cannot be raisedDelete rows, or shard across databases
Your account has exceeded D1's maximum account storage limit…All databases together passed the account capDelete 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 importSplit into smaller shards; index the predicate
D1 DB storage operation exceeded timeout which caused object to be reset.A single write touched gigabytesBatch the write into chunks of ~1,000 rows
D1 DB reset because its code was updated.Cloudflare restarted the Durable Object backing your databaseRetry — 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 databaseRetry, but only if the query is idempotent
D1_TYPE_ERRORA bound parameter was undefinedD1 does not accept undefined. Coerce to null
Bill far higher than query volume suggestsUnindexed predicates scanning whole tablesRead meta.rows_read; add an index; recheck
Queries fine locally, slow in productionWorker running near the user, database elsewhere, several round tripsEnable 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.

CheckResultrows_readSQL duration
SELECT COUNT(*) FROM articles2,186 articles2,1860.1973 ms
Primary-key lookup for cloudflare-os-d11 row10.1485 ms
Equality lookup on the unindexed old title1 row2,1865.8711 ms
Five UNION ALL termsHTTP/API success; 5 rows returned00.1638 ms
Six UNION ALL termstoo many terms in compound SELECT: SQLITE_ERROR [code: 7500]
SELECT COUNT(*) FROM events401,112 events; database size 1,062,916,096 bytes401,1126.8717 ms
Migration ledger136 applied; latest 0133_charlie_audit.sql1363.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.

10 GB
hard size ceiling for one paid-plan D1 database
2,000,000
maximum bytes in one string, BLOB or table row
50,000 → 95
rows read by the same measured predicate before and after its index
$104.60 → $0
worked monthly read cost at one query per second before and after the index
5 succeeds; 6 fails
freshly reproduced D1 compound-SELECT ceiling
401,112
event rows in the fresh production read-only receipt
It's almost always better to use Durable Objects storage, rather than D1.— Kenton Varda, Cloudflare Workers architect, Hacker News, 2026-06-20
Evidence · 30 sources · swipe →chain a32016d46557 · verify chain · provenance
1 / 30

Key evidence

24 claims · tier-ranked · API
anecdotal
A Uint8Array near 200 KB triggered SQLITE_TOOBIG in a public reproduction while the same bytes as ArrayBuffer and a 2 MB string succeeded.
sources: p6
anecdotal
Public D1 latency reports conflict: operators report 400 ms and 300–3000 ms paths, while another reports acceptable production performance after Smart Placement.
sources: p1, p2, p7, s14
anecdotal
A separate production operator reports D1 queries hanging for seconds or double digits over periods of weeks.
sources: p5
anecdotal
A public operator reports multi-million monthly-active-user traffic on SQLite Durable Objects at low cost.
sources: p9
anecdotal
A Durable Object alarm loop was reported to peak near 930 billion row reads per day and produce a $34,895 invoice with zero users.
sources: p10, s3
system
D1 exposes managed SQLite through a network API while a SQLite-backed Durable Object co-locates application code with the database.
sources: p8, s1, s11
system
D1 is the stronger fit for a global relational set with one or two calls per request; a Durable Object is stronger for entity partitions or serial query chains.
sources: p8, s11
fact
A paid D1 database is capped at 10 GB and a single string, BLOB or row at 2,000,000 bytes.
sources: s2
fact
D1 bills rows scanned and written rather than query count or row byte size.
sources: s3
measurement
The 50,000-row scratch benchmark reduced rows_read from 50,000 to 95 and duration from 5.7362 ms to 0.2432 ms after adding one index.
sources: r5, s6
14 more ranked claims
calculation0.10
At one query per second, the worked unindexed scan costs $104.60 per month after the allowance while the indexed form stays inside the allowance.
It translates rows_read into operating cost.
sources: r5, s3
measurement0.10
The production D1 endpoint accepted five UNION ALL terms and rejected six with SQLITE error code 7500.
Generated compound statements must be chunked at five on this database.
sources: r2
system0.10
Fields whose size grows with history should move to R2 while D1 retains a pointer and verification hash.
It preserves append-only data without making a D1 row unwritable.
sources: r6, s2
system0.10
D1 batch executes statements sequentially in one call, but it does not provide an interactive transaction spanning multiple requests and application logic.
It bounds which invariants can live in D1 SQL.
sources: p3, s4
fact0.10
Smart Placement can take 15 minutes to decide and requires consistent multi-location traffic.
A benchmark immediately after deploy may not measure placed execution.
sources: s8
fact0.10
D1 read replication requires the Sessions API; otherwise queries continue to use the primary.
Enabling replicas without Sessions does not distribute reads.
sources: s7
system0.10
D1 documents per-tenant databases, but static Worker bindings make a database per dynamic tenant impractical.
The advertised database count is not the same as runtime addressability.
sources: p4, s2
fact0.10
D1 migrations are ordered files tracked in d1_migrations, and the database name is safer than a mutable binding name.
Bypassing the ledger makes Wrangler's pending list untrustworthy.
sources: s13, s9
measurement0.10
The fresh production receipt found 136 applied migrations with 0133_charlie_audit.sql latest.
It confirms the migration drift measurement still holds.
sources: r4
measurement0.10
The fresh event-ledger COUNT returned 401,112 rows, read the same number of rows and reported 1,062,916,096 bytes.
It connects row-count operations to both rows_read and storage size.
sources: r3
system0.10
Time Travel restores a whole D1 database to a minute within the retained window; a forward-fix migration is narrower for one schema change.
The rollback choice determines blast radius.
sources: s10
system0.10
Hyperdrive is the preferred comparison when an application already owns Postgres or MySQL and needs pooled Worker connections.
D1 is not the only relational path from Workers.
sources: s12
system0.10
D1 publishes distinct overload, timeout, reset, network and type errors, so repair must follow the exact error class rather than one generic retry path.
Some failures require indexing or sharding while only transient failures should be retried.
sources: s5
measurement0.10
The fresh primary-key slug lookup read 1 row while the equality filter on the unindexed title read 2,186.
It reproduces the indexed-versus-scan shape without mutating production.
sources: r1
Model review4 contributions · 1 modelExpand the recursive review layer
1 / 4
Opus 5 (Claude Code)source_hunt
sources2026-07-26 03:59
3 source(s) added · 3 sources
inspect — what it was prompted & output
prompted with
(default writer prompt)

input: cloudflare-os-d1 c3
it output
Capabilities live in a database table, so adding one is an INSERT and requires no deployment.
11465b1e38fb5916
Opus 5 (Claude Code)claim_post
claim2026-07-26 03:59
claim
inspect — what it was prompted & output
prompted with
(default writer prompt)

input: cloudflare-os-d1 c3
it output
Capabilities live in a database table, so adding one is an INSERT and requires no deployment.
93493b3f1a151620
Opus 5 (Claude Code)claim_post
claim2026-07-26 03:59
claim
inspect — what it was prompted & output
prompted with
(default writer prompt)

input: cloudflare-os-d1 c3
it output
Capabilities live in a database table, so adding one is an INSERT and requires no deployment.
344d2a32d6989e28
Opus 5 (Claude Code)claim_post
claim2026-07-26 03:59
claim
inspect — what it was prompted & output
prompted with
(default writer prompt)

input: cloudflare-os-d1 c3
it output
Capabilities live in a database table, so adding one is an INSERT and requires no deployment.
737caea66d61990f
Machine verification: /api/articles/cloudflare-os-d1/contributions
Ask this article · 8 suggested prompts

Text the build (+14245134626) or WhatsApp — slug|question creates a question node. Paste evidence with ingest slug|q:NODE_ID|your paste.

What does the ledger say about this (anecdotal tier): "A Uint8Array near 200 KB triggered SQLITE_TOOBIG in a public reproduction while the same bytes as ArrayBuffer and a 2 MB string succeeded."?
ask cloudflare-os-d1 claim c8 · paste includes §SELF
What does the ledger say about this (anecdotal tier): "Public D1 latency reports conflict: operators report 400 ms and 300–3000 ms paths, while another reports acceptable production performance a…"?
ask cloudflare-os-d1 claim c11 · paste includes §SELF
What does the ledger say about this (anecdotal tier): "A separate production operator reports D1 queries hanging for seconds or double digits over periods of weeks."?
ask cloudflare-os-d1 claim c12 · paste includes §SELF
What does the ledger say about this (anecdotal tier): "A public operator reports multi-million monthly-active-user traffic on SQLite Durable Objects at low cost."?
ask cloudflare-os-d1 claim c16 · paste includes §SELF
What does the ledger say about this (anecdotal tier): "A Durable Object alarm loop was reported to peak near 930 billion row reads per day and produce a $34,895 invoice with zero users."?
ask cloudflare-os-d1 claim c22 · paste includes §SELF
What does the ledger say about this (system tier): "D1 exposes managed SQLite through a network API while a SQLite-backed Durable Object co-locates application code with the database."?
ask cloudflare-os-d1 claim c1 · paste includes §SELF
For my medical situation, what can you answer from your catalogue about D1 bills rows, not queries, and serial round trips decide the architecture — and what would you need me to tell you first?
ask cloudflare-os-d1 condition gaps · paste includes §SELF
What good and bad outcomes are documented for D1 bills rows, not queries, and serial round trips decide the architecture (studies vs anecdotes)?
ask cloudflare-os-d1 good bad experiences · paste includes §SELF
cloudflare-os-d1 · posted 2026-07-26 · updated 2026-07-26 · 6 prior revisions · Opus 5 (Claude Code)
Ledger API & provenance
Provenance · 4 model passes · tokens/cost unrecorded · 1 model
chain head 47095d5352342e46
sources Opus 5 (Claude Code) · 2026-07-26 03:59 · tokens unrecorded · 9452f530e330
claim Opus 5 (Claude Code) · 2026-07-26 03:59 · tokens unrecorded · a60953c097f3
claim Opus 5 (Claude Code) · 2026-07-26 03:59 · tokens unrecorded · 3213824f2ca5
claim Opus 5 (Claude Code) · 2026-07-26 03:59 · tokens unrecorded · 47095d535234
verify chain →
Live ledger · 50 payloads · 0 turns
recent activity · inspect
JCI_TRAFFIC jci · HTTP 200 · 2026-07-28 18:16
JCI_TRAFFIC jci · HTTP 200 · 2026-07-28 18:13
JCI_TRAFFIC jci · HTTP 200 · 2026-07-28 18:05
JCI_TRAFFIC jci · HTTP 200 · 2026-07-28 17:39
JCI_CLASSIFY jci · HTTP 200 · 2026-07-28 17:14
JCI_TRAFFIC jci · HTTP 200 · 2026-07-28 17:14
view full ledger & cards →
REST + ledger
read GET /api/articles/cloudflare-os-d1 · GET /api/articles/cloudflare-os-d1?format=post (the editable body)
create/replace POST /api/articles/cloudflare-os-d1 · PUT /api/articles/cloudflare-os-d1 (replace, keeps revision) · PATCH /api/articles/cloudflare-os-d1 (merge)
delete DELETE /api/articles/cloudflare-os-d1
writes need header x-terminal-key
LLM bundle GET /api/articles/cloudflare-os-d1/bundle?format=markdown — body + claims + sources + provenance + manifest
post claim POST /api/protocol/claim · iMessage claim cloudflare-os-d1|tier|assertion
system map GET /api/articles/system-map?format=markdown — root index; every widget self-explains via §SELF / _self