Skip to content

Your Cloudflare D1 bill was $500? Check rows written first

10 min readCloudflareSQLiteServerlessPricingDatabases

On October 18, a developer posted that Cloudflare had billed them over $500 for D1, for two small personal databases with little traffic. The post has since passed 90,000 views. A few hours later they posted the invoice quantities for September 17 to October 16:

Line itemQuantityCharge
Rows written452,256,462$453
Rows read60,197,421,138$60.20
D1 total, before tax$513.20

They were careful to add that those numbers "confirm the bill, not the cause of the usage." That is the right instinct, and I'm not going to guess at what their code did. The replies filled up with similar stories: a $1,300 bill for D1 writes, and a $1,200 D1 bill whose author's advice was "cache the hell out of reads", so not every one of these is a write problem.

I build Layerbase Cloud, which hosts libSQL with flat pricing, so I have an obvious interest here. I've written before about the read side of D1 billing, where a missing index turns a cheap query into an expensive one. This post is about the other meter, the one that produced most of that invoice.

How D1 bills

From Cloudflare's D1 pricing page, checked October 19, 2026:

Workers FreeWorkers Paid
Rows read5 million per dayFirst 25 billion per month included, then $0.001 per million
Rows written100,000 per dayFirst 50 million per month included, then $1.00 per million
Storage5 GB totalFirst 5 GB included, then $0.75 per GB-month

Three things in that table are worth slowing down for.

A written row costs 1,000 times what a read row costs. $1.00 per million against $0.001 per million. The included allowances lean the same way: 25 billion reads a month against 50 million writes, a 500 to 1 ratio. D1's pricing is built around read-heavy apps, and for those it is very cheap. An app that writes a lot is in a different price bracket than the read rate suggests.

Indexes add written rows. The pricing page says "Indexes will add an additional written row when writes include the indexed column." An insert into a table with two secondary indexes is three written rows, not one. An update that touches an indexed column pays for the index too.

The Free plan has a cap. The paid plan does not. On Workers Free, when you hit the daily limit "you will not be able to run queries against D1" until it resets. That is a hard stop, and it protects you. Workers Paid removes it, and past the included amounts you are "billed for the additional reads, writes and storage." There is no setting that puts the hard stop back.

A worked example

Take the write line from that invoice. $1.00 per million rows means 452,256,462 rows comes to $452.26, which matches the invoice, so that quantity is the billable usage above the included 50 million. The month's total was roughly 502 million rows written.

Spread over a 30-day billing period, the billable part alone is about 15 million rows a day, or about 175 rows written per second, around the clock.

That rate is the whole story. 175 rows a second is nothing for SQLite. Nothing slows down, nothing errors, no log line looks unusual. A loop doing that is invisible unless you are watching the meter.

Now add indexes. Here is an ordinary events table:

sql
CREATE TABLE events (
  id INTEGER PRIMARY KEY,
  user_id INTEGER NOT NULL,
  type TEXT NOT NULL,
  created_at INTEGER NOT NULL
);
CREATE INDEX events_user_id ON events (user_id);
CREATE INDEX events_created_at ON events (created_at);

Each insert writes the row plus one entry in each index, so three written rows. At 175 rows a second, that is only about 58 inserts a second. One background job inserting 58 events a second writes about 450 million rows a month, which bills at about $400 after the included 50 million, on a plan whose base price is $5.

The read line is worth a look too. $60.20 at $0.001 per million is 60.2 billion billable rows, on top of the 25 billion included: around 85 billion rows read in the month. Reads are cheap enough per row that the dollar figure looks small, but 85 billion rows scanned for two small databases is a lot of scanning. If your own invoice has a large rows-read quantity, the missing-index section of our D1 alternatives post is where to look.

What usually causes a write bill

The best public post-mortem I've found is from Little Bear Apps, whose January 2026 D1 usage came to about 4.83 billion rows written and an invoice of $4,586.64. Cloudflare waived it, about 18 days after the first support ticket. Their two bugs are the two most common patterns:

A job that reprocesses what it already did. A cron-scheduled worker processed records in batches with no "already processed" flag or offset, so every run rewrote the same records. About 3.5 billion writes in four days, 1.42 billion of them on a single day. The worker logged no errors. They noticed four days in.

Inserts that re-insert on every run. A seeding job ran inserts with no conflict handling, rewriting existing records each time. About 910 million redundant writes over four days.

The other patterns I'd check for:

Upserts in a loop. INSERT ... ON CONFLICT DO UPDATE is the usual fix for the previous bug, and it still writes the row on every conflict, plus index entries for any indexed columns it sets. A sync job that upserts 10,000 rows every five minutes is 2.9 million upserts a day whether anything changed or not. If most rows usually haven't changed, tell SQLite to skip them:

sql
INSERT INTO products (sku, name, price_cents)
VALUES (?, ?, ?)
ON CONFLICT (sku) DO UPDATE SET
  name = excluded.name,
  price_cents = excluded.price_cents
WHERE products.name IS NOT excluded.name
   OR products.price_cents IS NOT excluded.price_cents;

With the WHERE clause, a row whose values are already current is not updated at all.

Writes on the read path. UPDATE sessions SET last_seen = ? on every request, a view counter incremented per page load, an audit row per API call. Each one is cheap until it is multiplied by traffic and by indexes. Batch them, sample them, or keep them somewhere that doesn't bill per row.

Retries around work that isn't idempotent. A retry wrapper around a multi-step write job will redo the steps that already succeeded. That's a correctness bug anyway; on D1 it is also a billing bug.

Logging into the database. Agent tool calls, request logs, analytics events. Append-only, high volume, often indexed by time and by user. This is the clearest case of a workload D1's pricing is not built for. Workers has Analytics Engine for exactly this.

What alerts do and don't do

Cloudflare has two notification tools, and neither one stops anything.

Budget alerts watch your account-wide usage-based spend for the billing period and send a single email when it crosses a dollar threshold. They are, in the docs' words, "informational only" and "do not pause or cap usage." Since July 2026, Pay-as-you-go accounts without one get a $10 budget alert by default, which starts at the next billing cycle after it is created.

Usage-based billing notifications can be set on D1 Rows Read and Rows Written specifically. Also email.

The billable usage dashboard shows a daily cost breakdown, and keeps only 30 days of history.

So the best case is an email after the spend has happened, while whatever caused it keeps running. At 175 rows a second you lose about $15 a day, which a $10 alert will catch on day one if you read your email. At Little Bear's peak of 1.42 billion rows in a day, that one day cost about $1,400.

If you want a hard stop on Workers Paid, you have to build it. Little Bear's write-up describes theirs: count usage outside D1, warn at 70% of a budget, and disable the feature at 100%.

Audit your own usage

1. Look at the invoice quantities. Billing, then Billable Usage. Rows written and rows read are separate lines. Divide each by the price to see whether you're past the included amounts.

2. Find the database. The D1 metrics come from the GraphQL Analytics API, and you can query rows written per database per day:

graphql
query D1WritesByDay($accountTag: string!, $start: Date, $end: Date) {
  viewer {
    accounts(filter: { accountTag: $accountTag }) {
      d1AnalyticsAdaptiveGroups(
        limit: 10000
        filter: { date_geq: $start, date_leq: $end }
        orderBy: [date_DESC]
      ) {
        sum {
          rowsWritten
          rowsRead
          writeQueries
        }
        dimensions {
          date
          databaseId
        }
      }
    }
  }
}

A steady line points to a loop or a cron. A step on one date points to a deploy.

3. Find the query. Wrangler ranks a database's queries:

bash
npx wrangler d1 insights my-database --sort-by=writes --time-period=7d

4. Measure in code. Every D1 result carries a meta object with rows_written and rows_read for that query. Logging meta.rows_written next to the query name for a day tells you which code path writes what, including the index rows you weren't counting.

5. Compare rows written to rows changed. meta.changes is the number of rows the statement changed. A statement whose rows_written is several times its changes is paying for indexes. A job whose writes stay flat while your data barely changes is rewriting what it already has.

If you'd rather not have a meter at all

D1 is a good fit for read-heavy apps that want reads close to users on every continent, and for most of those the bill is small. If your workload writes a lot, or you just want a database bill that can't change because of a bug, the alternative is a database priced per instance instead of per row.

That's what Layerbase Cloud does for libSQL, the open source SQLite fork. Your schema and queries carry over, and your Worker connects with @libsql/client over HTTPS. Pricing is flat. The Free plan is $0 for two databases that sleep after 15 idle minutes, meant for development and early projects. Solo is $5/month and keeps one database always on. Neither plan meters rows read or written, so a loop writing 500 million rows does not change the bill.

It is still a bug. On a flat plan you find it as a full disk or a busy instance rather than an invoice, and you still have to fix it. The trade going the other way is real too: a Layerbase database lives in one region, so it won't match D1's edge reads for users far from it.

The D1 migration tool needs a Cloudflare account ID and an API token with D1 Read, and it copies your database into a new Layerbase libSQL instance.

FAQ

How much does Cloudflare D1 charge for writes?

On Workers Paid, the first 50 million rows written each month are included, then it's $1.00 per million rows. Reads are $0.001 per million after the first 25 billion. On Workers Free you get 100,000 rows written per day, and queries stop working when you hit the limit.

Do D1 indexes count as rows written?

Yes. Cloudflare's pricing page says "Indexes will add an additional written row when writes include the indexed column." An insert into a table with two secondary indexes is three rows written.

Can I set a spending cap on Cloudflare D1?

Not on Workers Paid. Budget alerts and usage notifications send email but do not pause or cap usage. The only hard limit is the Free plan's daily quota. If you need a hard stop on a paid account, you have to build it in your own code.

How do I find what's writing to my D1 database?

Start with the GraphQL Analytics API (d1AnalyticsAdaptiveGroups, field rowsWritten) to find the database and the day, then wrangler d1 insights --sort-by=writes to find the query, then log meta.rows_written from each query result to find the code path.

Will Cloudflare refund a surprise D1 bill?

Sometimes. Little Bear Apps had a $4,586.64 invoice waived after about 18 days of support escalation. Ask, but don't plan on it, and don't count on the timing.