Every "free serverless DB" comparison ranks capacity, price, and cold-start. Almost none answer the question you actually hit in production: what happens at the free-tier wall? So I filled a Cloudflare D1 database to its limit over the REST API and poked it.
The honest short version: the wall is not a full read-only lockout, a plain DELETE clears it immediately (but that recovery is not quota-free), and — the part I got wrong twice — building an index on a blob column roughly doubles your storage, so it slams into the wall on its own. This post has been revised twice: my own size_after logs, and then a follow-up experiment, corrected me. I'll show both corrections instead of hiding them.
Scope: Workers Free, throwaway DBs, over the D1 REST API /query, 2026-07. The coarse wall was reproduced on a second, freshly-created database; the exact-ceiling pin and the index-doubling are each n=1 (flagged). This is a REST probe, not a Worker Binding probe — don't generalize endpoint-specific behavior (especially VACUUM).
"The free-tier wall" is not one wall
D1 has several walls, with different scopes and — per Cloudflare's own error list — different messages:
| Wall | Scope | Value (Free) | Documented message |
|---|---|---|---|
| per-DB storage | per database | 500 MB (Paid: 10 GB) |
Exceeded maximum DB size — action: "delete rows / shard" |
| account storage | account | 5 GB | Your account has exceeded D1's maximum account storage limit… |
| daily rows read | account | 5,000,000/day | daily-limit-exceeded → "can't run queries" |
| daily rows written | account | 100,000/day | same (INSERT, UPDATE, and DELETE all count as writes) |
(Free daily counters reset at 00:00 UTC; storage is not daily-reset. Max row 2 MB; 100 columns; 10 DBs/account. All confirmed against the D1 docs on 2026-07-09.) I measured the first one.
Result 1 — the wall is 500 MB (decimal); pinned to one row on one DB, reproduced coarsely on a second
Filling with bulk inserts stalls around ~499.9 MB. To pin it, I then looped single-row inserts at the wall:
… 24 single-row inserts succeed (size_after climbs)
25th: HTTP 400 [{"code":7500,"message":"Exceeded maximum DB size"}]
last successful size_after = 499,994,624 bytes (≈ 499.99 MB ≈ 476.83 MiB)
That ceiling — 499,994,624 — is 500,000,000 (decimal 500 MB) minus 5,376 bytes, enforced at SQLite's 4 KB page granularity (499,994,624 = 122,069 × 4096). I reproduced the coarse wall on a second, freshly-created DB: it stopped at the identical 499,884,032 and returned the same 7500. One caveat so you can hold me to my own logs: the redacted results/*.jsonl carry no account IDs, and served_by_colo is Anycast edge routing, not an account signal — so what's verifiable from the evidence is a second database, and the byte-exact 499,994,624 pin is from the single-row probe on one of them (n=1). The wall firing at all means these DBs are on Workers Free (Paid = 10 GB).
Result 2 — not read-only, but small writes aren't "exempt" either (measured)
My first draft claimed small writes slip past the wall while big ones don't. Wrong — and my own numbers show why. Those 24 single-row inserts above consumed only 499,994,624 − 499,884,032 = 110,592 bytes (~110 KB) of headroom before even a single-row insert was rejected. So:
- Reads always pass — near-tautological for a storage cap; reads don't grow the DB.
- Writes pass only while headroom remains. Small writes weren't bypassing the wall; they were filling the last sliver before it. Call it slack, not exemption. (One headroom-exhaustion sequence, but the 25th insert failing at zero headroom is a clean counterexample to "small writes are exempt" — one is enough.)
Result 3 — recovery is a plain DELETE, no upgrade (but it spends write quota)
DELETE WHERE k < 20000 → 19,998 rows; size_after 499.9 MB → 243,884,032 (~244 MB) immediately; rows_written = 19,998
bulk INSERT (after delete) → restored
VACUUM → 7500 "cannot VACUUM from within a transaction: SQLITE_ERROR" (maybe REST-specific)
DELETE drops D1's reported size immediately and writes resume — no VACUUM, no plan upgrade (recovery class R1). Two honest qualifiers: (1) it isn't quota-free — D1 counts DELETE as a write (docs: "Write operations include INSERT, UPDATE, and DELETE"), so this one 19,998-row delete spent ~20% of the daily 100k budget, and the cost scales with row count, not bytes freed (this delete freed ~256 MB ≈ 12.8 KB/row; whether it's linear per chunk I didn't separately measure); (2) size_after is D1's reported size — the meter dropping doesn't prove the physical file shrank (cf. workerd#1618, local).
Result 4 — CREATE INDEX doubles your storage (the thing I got wrong twice)
At the wall, CREATE INDEX fails with out of memory: SQLITE_NOMEM. My first draft lumped that in as "large allocation blocked"; after review I "corrected" it to "that's a memory limit, independent of the storage wall." Both were wrong.
On a control DB at 300 MB — well below the 500 MB wall — CREATE INDEX failed not with SQLITE_NOMEM but with 7500 "Exceeded maximum DB size" (storage!). Below the wall, a storage error? I measured the mechanism directly — deleted the control to 80 MB and indexed:
CREATE INDEX ON bench(v) at 84,029,440 (80.1 MiB) → OK
size_after 84,029,440 → 167,923,712 = ×1.998 growth (measured once, at ~80 MB)
An index on a blob column carries a copy of the column's data as its keys, so it roughly doubles storage — inferred from this one growth point, not proven in general. If that ×2 holds at other sizes:
| storage before index | after (~×2) | result |
|---|---|---|
| 80 MB | 160 MB | ✅ succeeds (under the wall) |
| 300 MB | ~600 MB | ⛔ Exceeded maximum DB size (over the 500 MB wall) |
| 500 MB (at the wall) | — | ⛔ SQLITE_NOMEM (OOMs during the build) |
So the index failure is storage-bound via the index's own footprint, not "memory, unrelated to the wall." A rough rule of thumb — if the ×2 holds — is that on D1 Free you can only index a column holding up to ~250 MB of data (2 × 250 = 500 MB), but that's an extrapolation, not measured near 250 MB. The SQLITE_NOMEM is just what you see when you're already at the wall.
The error code, and what I did not measure
Cloudflare's error list classifies these by message (Exceeded maximum DB size, …account storage limit…, …isolate memory limit…); the number 7500 appears nowhere in the docs. So 7500 is a generic REST wrapper — the message is the discriminator.
Not measured / n=1 / extrapolated (so not claimed): the index ×2 is a single measurement at one size/row-shape; the ~250 MB threshold is extrapolated (not measured near 250 MB); DELETE linearity across chunks is not measured (one 19,998-row delete); the account 5 GB wall, the daily read wall (5M/day — reads stop, account-scoped), and Worker Binding behavior are all untested; size_after behaves like a coherent logical meter (dropped on the one DELETE, ~doubled on the one index build) but the docs don't say physical vs logical. One full cycle (fill 39k + recover 20k + control 24k ≈ 84k rows_written) fits in one UTC day but not two.
Reproduce it
git clone https://github.com/sumitsuke/free-tier-quota-probe
cd free-tier-quota-probe
npm install && npm run selftest
# .env: CF_ACCOUNT_ID / CF_API_TOKEN (D1:Edit) / D1_DATABASE_ID / D1_CONTROL_DATABASE_ID / BILLING_SAFE=confirmed
node --env-file=.env bin/d1-admin.mjs create wallprobe # create the DB via API (no dashboard needed)
npm run ping -- --target d1
npm run fill -- --target d1 --confirm-destructive
npm run probe-slack -- --target d1 # pins the wall + measures the headroom
npm run fill -- --target d1 --control --cap-mb 300 --confirm-destructive
npm run probe-index -- --target d1 # the index-doubling test
Node ≥ 20, ESM, D1 over native fetch (60 s timeout + one retry). Redacted captures are frozen in results/*.jsonl; predictions (and the follow-up amendment) are in PREREGISTRATION.md. Repo: https://github.com/sumitsuke/free-tier-quota-probe
I write about measurement discipline — filling the wall instead of quoting the limit, and correcting my own assumptions on the record. This post's Results 2 and 4 are the live examples: I reasoned wrong twice and only the measurements got it right. Written with AI assistance; every number is from the captured `results/.jsonl`, and the doc facts were re-verified against Cloudflare's D1 docs on 2026-07-09. A Japanese version of this write-up also exists.*
Verification record (environment, verdict, last verified date, evidence) and the canonical write-up: https://sumitsuke.jp/lab/ — code, data and reproduction: https://github.com/sumitsuke/free-tier-quota-probe.
Top comments (2)
The useful part here is testing the failure mode instead of only comparing the advertised quota.
“500 MB” is a pricing-table fact. What operators actually need to know is the behavior at the boundary:
That distinction matters even more when agents or automation touch the database. A tool should be able to surface “quota wall, reads still available, cleanup possible” instead of collapsing everything into a generic database error.
Failure semantics are part of the API.
"Failure semantics are part of the API" is the sentence I wish I'd had when I started — that's the whole reason the piece exists, and your six questions are a better table of contents than mine.
Where the measurements land against your list, with the ones I didn't answer marked as such:
DELETEdrops the reported size straight away and writes resume — noVACUUM, no plan change. The honest qualifier is that recovery is not quota-free: the delete itself spends write quota, so "you can always delete your way out" has a floor I didn't probe.CREATE INDEXfails without of memory: SQLITE_NOMEM, which reads like a memory limit and isn't. On a control DB at 300 MB — well below the wall — the same statement failed withExceeded maximum DB size. So the failure is storage-bound via the index's own footprint: an index on a blob column carries a copy of the keys, roughly doubling storage. Inferred from one growth point, not proven in general, and the article says so.Exceeded maximum DB size, account storage limit, isolate memory limit) are distinguishable by text; the numeric code7500I saw appears nowhere in the published error list. A caller that switches on codes has less to work with than one that reads messages, which is the opposite of what you'd want.Your framing about agents is the one I'd act on. A wrapper that collapses all of this into "database error" throws away the only three facts an automated caller could use: the wall is storage, reads still work, cleanup is possible. That's three bits, and they're all in the message rather than the code.
Probe scripts are in the repo if you want to re-run any cell — I'd be glad to be corrected on the index ×2, which is my weakest number.