A Medicaid pricing lookup site with a 765,000-row table racked up a $134.14 Cloudflare bill in April 2026. Not from compute, not from bandwidth — $127.60 of it, 95% of the invoice, was a single line item: 127,599,130,859 D1 row reads. The table had no index beyond its primary key, so four page-load queries were scanning the entire thing, over and over, on every visitor. That's not a hypothetical. It's a published post-mortem, and it's the cleanest illustration available of what it actually costs to let a platform meter your database by the row.
The Meter That Turns a Missing Index Into an Invoice
Cloudflare D1 is Workers' bundled SQLite database, and its pricing has three dimensions: storage at $0.75/GB-month (25GB included on the $5/mo Workers Paid plan), rows written at $1.00 per million (50 million included), and rows read at $0.001 per million — with 25 billion included before that meter starts charging. Twenty-five billion sounds like a number no small app should ever hit. The Medicaid site hit it five times over in a month, and the reason had nothing to do with traffic volume.
The site's getLatestYear() helper ran on every page load and scanned the full table looking for the most recent year present — 172.86 billion row reads by itself over the billing period. A "top procedures" aggregation running in a shared layout component added another 122.21 billion. A procedure-code lookup added 68.68 billion. State-level statistics queries added roughly 20 billion more. None of these were expensive queries in the traditional sense — SQLite chewed through them fine, latency-wise. They were expensive because D1 counts every row a query touches, including rows scanned to evaluate a WHERE clause and then thrown away, and bills for the count.
The fix, once diagnosed, was unglamorous: four composite indexes on the columns actually being filtered and grouped on, cutting row reads by up to 765x on the worst offenders; an ANALYZE pass so SQLite's query planner could pick the new indexes over a full scan when more than one path existed; and a KV cache layer in front of the layout and route functions, since the underlying data only changed once a year but was being re-queried on every single pageview. Projected result: roughly 400 billion rows read a month down to about 19 billion — under the free allowance, turning a $134 bill back into the $5 base plan fee.
That's a genuinely good outcome, and it says something true about D1: a well-indexed app with a sane caching layer can run comfortably inside the included allowance, and 25 billion reads a month is a lot of headroom for most workloads. The catch is what "well-indexed" is doing in that sentence. On a database you own, a missing index is a latency problem — a query that used to take 2ms now takes 40ms, a developer notices, or doesn't, and either way nothing happens to the bill. On D1, a missing index is a billing problem. The failure mode isn't "the app is slow," it's "the invoice has a line item you have to explain," and the two only look identical from a monitoring dashboard until the specific week a table crosses the row count where a full scan stops being free.
The 10GB Ceiling Is a Sharding Decision, Not a Storage Limit
The row meter isn't D1's only structural constraint. Every individual database is capped at 10GB on the Workers Paid plan (500MB on Free), and Cloudflare is explicit that this is deliberate: the platform wants an application sharded across many small databases, not consolidated into one large one. The corresponding ceiling on the other axis is generous — up to 50,000 databases per account on Paid (10 on Free) — which only makes sense if the expected pattern is one database per tenant, per shard key, or per some other natural partition, not one database for the whole app.
For a specific and common shape of application — multi-tenant SaaS where each customer's data is already logically separate — that's not a bad trade. "One SQLite database per tenant, capped at 10GB, replicated and billed per row" is a coherent design, and D1's Sessions API (built-in sequential consistency across a primary and its read replicas, no extra charge for the replicas themselves) makes the multi-database model less painful to work with than it sounds. But it's a decision the platform is making on the application's behalf. An app that doesn't shard naturally along tenant lines — a single growing dataset, like the Medicaid pricing table — has to introduce sharding as an operational concern specifically to stay under a cap that has nothing to do with how much data the app actually needs to store, and everything to do with how D1 wants to distribute load across its own infrastructure.
What Owning the Box Instead Buys — and What It Doesn't
The self-hosted answer to "SQLite that survives a disk failure" is Litestream: an open-source process that tails SQLite's write-ahead log and streams incremental changes to S3-compatible object storage — R2, B2, or a self-hosted bucket like MinIO or Garage sitting on the same Hetzner fleet a Cluster API–managed PaaS already provisions. It runs as a sidecar next to the app, takes periodic snapshots, and replays the WAL between them. There's no row counter anywhere in that path. A query that scans a badly-indexed 765,000-row table costs CPU time and wall-clock latency on the box it runs on — the same failure mode a missing index has always had — and nothing else. The bill for the month is whatever the Hetzner instance and its backup bucket already cost, unchanged by how many rows any query happened to touch.
That's also the honest limit of the comparison. Litestream backs up a single-writer SQLite database on a single node; it doesn't give you D1's multi-region read replicas or its automatic per-tenant sharding, and scaling past one write-heavy node means reaching for something else — LiteFS for multi-node read replicas, or Turso's libSQL fork, which is MIT-licensed and free to self-host if a team wants Turso's replication model without Turso's own usage-based hosted pricing (itself metered on rows read, the same dimension that produced the $134 bill in the first place). None of that is a features-parity claim. It's the same trade every self-hosted answer to a managed primitive makes: less built-in distribution, no meter running underneath it.
That's the shape of the trade a self-hosted PaaS is actually offering when it bundles nothing. Managing tenant databases is a stated non-goal for bex, not a missing feature on a roadmap — the theory being that reimplementing what Litestream, Patroni, or pgBackRest already do well is a worse use of a platform team's time than the git-push-to-HTTPS path it actually owns. A tenant running SQLite-plus-Litestream, or Postgres, on a bex-managed Hetzner fleet gets exactly the ownership tradeoff described above: no row-read meter, no 10GB-per-database ceiling forcing a sharding decision the app doesn't otherwise need, and no vendor-side infrastructure absorbing the operational work of failover and backup verification — because that work stays the tenant's, on hardware they can see the bill for in a single line: the box, not the query pattern that ran on it.
The Real Question Isn't "Which Database Is Cheaper"
D1's per-row pricing isn't a trap, and the Medicaid site's bill isn't evidence that Cloudflare overcharges — 25 billion included reads a month is a genuinely large allowance, and the fix that brought the bill back to $5 was good engineering hygiene any database benefits from, D1 or not. What the incident actually demonstrates is narrower and more useful: a bundled managed database changes what a missing index is. On owned hardware, it's a performance bug that shows up in a trace. On a per-row-metered platform, it's a billing bug that shows up in an invoice a month later, after the query pattern that caused it has already run tens of billions of times. Neither is inherently better engineering — but only one of them requires reading a pricing page to understand what a bad query costs you, and that's the distinction a team should actually be weighing before "which database is cheaper" — not whether D1's 25 billion reads is a big number, but whether they want their own indexing mistakes billed by the row at all.
Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, with your database staying exactly that: yours, not a managed line item on someone else's meter. Star the repo on GitHub or deploy your first app today.
Sources:
- D1 pricing — Cloudflare Developers docs
- D1 limits — Cloudflare Developers docs
- I got a $134 Cloudflare D1 bill. Here's how I cut it 95% — Full Stack SvelteKit
- Global read replication — Cloudflare D1 docs
- Sequential consistency without borders: How D1 implements global read replication — The Cloudflare Blog
- Building D1: a Global Database — The Cloudflare Blog
- Turso Database Pricing



