F-Keys\Research\d1bench _◻✕
← Back Forward → ↑ Up Home Find Status Log
Address 📁 F-Keys\Research\d1bench

d1bench

Cloudflare documents D1's limits one at a time. It does not document how they interact, and they interact in a way that changes which bulk-insert strategy is correct. This harness deploys inside a Worker, because two of the limits under test only exist inside a Worker invocation, and sweeps three strategies against a live database.

Measured 2026-10-10 · live D1, region ENAM · five findings · v0.1.0 · raw data published · MIT

The question

Three ways to write N rows to D1, and each one is stopped by a different documented limit. Which limit binds first, for a given shape of data, is the only part a developer actually has to decide — and it is the part that is not written down.

strategyone statement holdscapped by
paramsmany rows, values bound 100 bound parameters per query
literalmany rows, values inlined 100,000-byte statement length
onebyoneone row queries per invocation

A single batch() call took 150,000 statements

The documented cap is 1000 queries per Worker invocation, and the advice that follows it is to chunk a bulk insert so the statement count stays under it. No rung refused:

statements in one batch()D1 timeper statement
1,000280 ms0.280 ms
8,0001,701 ms0.213 ms
32,0006,563 ms0.205 ms
128,00030,208 ms0.236 ms
150,00025,802 ms0.172 ms
175,000refused

One batch() behaves as one query against the count limit, so chunking statements to stay under 1000 solves a problem that is not there. The limit that does bind is duration: thirty seconds, applied to the entire batch call, exactly as the documentation’s own footnote 4 states.

Which makes the ceiling a time budget rather than a statement count — thirty seconds divided by your per-statement cost. It moves with row width, so a number copied out of this table into code handling wider rows will be wrong.

The ceiling is exact, and it reaches one

Climbed until D1 refused, at every column count. The arithmetic holds exactly: 50 rows at two columns, 12 at eight, 3 at thirty-two, and 1 at sixty-four. Past fifty columns, “chunked bound parameters” is one statement per row wearing a different name.

Which is why literal SQL pulls away

1,000 rows of 16-byte values, median of five passes, tables truncated before every measured run:

columnsparamsliteralparams rows/stmt literal rows/stmt
454 ms49 ms251000
888 ms59 ms12500
32195 ms123 ms3143
64503 ms187 ms177

At four columns the gap is inside the noise and no winner is named. At sixty-four it is 2.7×.

Then the row-size axis reverses it, and ends in a wall

Literal SQL's chunk collapses as rows fatten — 500, 200, 53, 13, 3 — and once one literal tuple passes the statement limit, there is no chunk size that works. At roughly 131 KB rows it is refused outright while bound parameters carry on unaffected, because bound values travel beside the SQL rather than inside it.

The result

The two strategies are capped by orthogonal limits. Bound parameters bind on column count and are blind to row size; literal binds on row size and is blind to column count. Everything above follows from that.

It reports what it did not establish

One region, one database, one account, one day, and no indexes anywhere. Escaping cost was not swept, and that is the one untested axis that could narrow literal SQL's advantage. The findings document also carries a correction to an earlier draft of this work, which claimed the 30-second duration cap went unenforced by confusing a per-statement limit with a per-call total.

How it works

  1. Deploy the harness where the limits existTwo of the limits under test only exist inside a Worker invocation. Measured from a laptop they do not exist at all, and the number you get is mostly your own internet connection.
  2. Sweep both axes, repeats on the outsideColumn count against row size, three strategies. The repeat loop is the outer one, so drift over the sweep spreads across every condition instead of pooling in whichever strategy happened to run last.
  3. Reconcile the rows before believing the clock260,487 statements sent, 260,487 rows present, exact. A benchmark that reports a time for writes which never happened is worthless.

What it found

<code>batch()</code> is not charged per statementA single call took 150,000 statements without refusal, 150× the documented 1000-query per-invocation cap.
The real ceiling is a time budget, not a countThe 30-second cap applies to the whole batch, as the documentation's own footnote says. 150,000 statements succeeded in 25.8 s and 175,000 were refused, so the maximum is thirty seconds divided by your per-statement cost — and it moves with row width.
The parameter ceiling is exactly <code>floor(100/C)</code>Confirmed by climbing until D1 answers too many SQL variables, at every column count. Not quoted from the documentation — hit on purpose.
Chunked bound parameters stops being batchingAt 51 or more columns the ceiling is one row per statement, which is the naive strategy under another name, still described as batching by the code that uses it.
Literal SQL is 2.7× faster at 64 columns187 ms against 503 ms. The mechanism is not subtle: literal carries 77 rows per statement there, bound parameters carry one.
And then literal SQL becomes unavailablePast roughly 131 KB of row, one literal tuple exceeds the 100,000-byte statement limit and no chunk size works. Not slower. Refused.
The two are capped by orthogonal limitsBound parameters bind on column count and ignore row size. Literal binds on row size and ignores column count. Every finding above is a consequence of that one sentence.
Timed from both sides, because one side is blindIn Workers Date.now() advances only on I/O, so a clock inside cannot see the CPU that builds a statement — and literal SQL is the strategy doing that work.

Specifications

Measured2026-10-10, region ENAM
Versionv0.1.0
Strategiesbound parameters, literal SQL, one statement per row
Axescolumns 2 to 64, values 16 B to 64 KB
LicenseMIT
Sourcevince-gonzalez/d1bench

Questions

Does this say the documentation is wrong?
No, and the weaker claim is the true one. The row reads Queries per Worker invocation (read subrequest limits), and the parenthetical is routinely dropped when the number is quoted. The page says per-query limits apply to each statement inside a batch, giving statement length as its example, but never says how the query count is charged. The documentation leaves the question open while inviting the stricter reading. The measurement settles it.
Why does it matter which clock you use?
Because timed only from inside the Worker, literal SQL looks better than it is, and the headline becomes an artifact of the measuring instrument rather than a property of D1. Outside overhead was measured at 84 to 98 ms and came out the same for both strategies at every column count, so the CPU the inner clock cannot see is not what produced the gap. That was the measurement most likely to overturn the result, which is why it was run.
So which should I use?
Wide rows of short values: literal SQL, by a margin that grows to 2.7× at 64 columns. Any value pushing a row past about 100 KB of literal text: bound parameters, because literal SQL cannot express the statement at all. The broad middle, eight columns or fewer and rows under 32 KB: the difference sits inside run-to-run spread, so use bound parameters and have no escaping to get wrong. And never chunk to stay under 1000 statements.
What is not claimed?
One region, one database, one account, one day, and no indexes on any table. No sweep of escaping cost, which is the one untested axis that could narrow literal SQL's advantage. Nothing here tests the 30-second duration cap, which applies per statement and was never approached. The ratios and the orthogonality are the claims; the absolute milliseconds are evidence for them, not the result.
1 item Log  ·  Status F-Keys