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.
| strategy | one statement holds | capped by |
params | many rows, values bound |
100 bound parameters per query |
literal | many rows, values inlined |
100,000-byte statement length |
onebyone | one 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 time | per statement |
| 1,000 | 280 ms | 0.280 ms |
| 8,000 | 1,701 ms | 0.213 ms |
| 32,000 | 6,563 ms | 0.205 ms |
| 128,000 | 30,208 ms | 0.236 ms |
| 150,000 | 25,802 ms | 0.172 ms |
| 175,000 | refused |
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:
| columns | params | literal | params rows/stmt |
literal rows/stmt |
| 4 | 54 ms | 49 ms | 25 | 1000 |
| 8 | 88 ms | 59 ms | 12 | 500 |
| 32 | 195 ms | 123 ms | 3 | 143 |
| 64 | 503 ms | 187 ms | 1 | 77 |
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
- 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.
- 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.
- 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
| Measured | 2026-10-10, region ENAM |
|---|
| Version | v0.1.0 |
|---|
| Strategies | bound parameters, literal SQL, one statement per row |
|---|
| Axes | columns 2 to 64, values 16 B to 64 KB |
|---|
| License | MIT |
|---|
| Source | vince-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.