Cloudflare D1 Free Tier 2026: Real Limits and a Working Notes API

Cloudflare D1 Free Tier 2026: Real Limits and a Working Notes API

Databases🛠 cloudflare-d1

A desk-reviewed guide to Cloudflare D1's free tier, covering the daily read and write limits that matter and a working Workers notes API you can deploy today.

free-tiercloudflare-d1sqliteserverless-databaseworkers

Cloudflare D1’s free tier is enough to run a real CRUD app — a notes API, an internal tool, a small SaaS backend — but only if you understand how scanned-row accounting works and what happens when you hit a hard cap. This guide walks through the actual numbers and builds a deployable API.

About this guide: Written for indie hackers and bootstrappers shipping on free tiers. All limits are quoted from official Cloudflare documentation, and every number below traces to a published spec. This guide is based on official Cloudflare documentation and published limits — we did not run D1 hands-on. Where behaviour matters, we point you to the docs that define it.

What D1 Free Tier Actually Gives You

D1 is Cloudflare’s managed serverless SQLite, backed by Durable Objects and callable from Workers and Pages Functions (D1 docs). The free tier gives you real CRUD capacity — 5 million rows read and 100,000 rows written per day — but it is a daily-quota system with hard errors at the caps, not a soft limit.

D1 speaks SQLite — the same SQL dialect documented at SQLite’s language reference — so expect SQLite semantics, not Postgres parity. There are no schemas to migrate to a separate cluster, no connection strings to manage, and no egress or bandwidth charges (D1 pricing). Quotas reset daily at 00:00 UTC (D1 limits).

Here are the free-tier numbers, from the D1 pricing page and D1 limits:

Limit Free tier
Rows read 5,000,000 per day
Rows written 100,000 per day
Storage 5 GB per account
Max storage per database 500 MB
Databases 10 per account
Queries per Worker invocation 50
Max query duration 30 seconds
Max row/string/BLOB size 2 MB
Max SQL statement size 100 KB
Bound parameters per query 100
Max columns per table 100
Time Travel restore window 7 days

Ten databases per account means you can split projects cleanly: one database per app, each with its own 500 MB ceiling.

How Rows Read, Writes, and the meta Meter Work

Rows read counts rows scanned, not rows returned, per D1 limits. A full scan of a 5,000-row table costs 5,000 reads. Every query returns a meta object with rows_read and rows_written, which is your primary budget meter until the daily reset at 00:00 UTC.

This is the single most important accounting detail on the free tier. If your query touches every row in a table to return one result, you pay for every row it touched. An indexed lookup that touches three rows costs three reads. The difference between those two numbers is the difference between 5 million daily reads lasting a month or lasting an afternoon.

Every query result exposes meta.rows_read and meta.rows_written via the D1 Worker API. Log them on every endpoint during development:

console.log("rows_read", result.meta.rows_read, "rows_written", result.meta.rows_written);

That one line is your budget meter. Watch it while you test, and you will know exactly which endpoints burn reads before production traffic does.

Other invocation-level limits, per D1 limits: 50 queries per Worker invocation on Free, a 30-second maximum query duration, and each database is single-threaded — concurrent queries queue rather than run in parallel. You also get up to 6 simultaneous connections per Worker invocation.

The Traps That Drain Your 5M Read Budget

The fastest way to exhaust the free tier is an unindexed query on a medium table, because scanned rows count against you. Hidden write burn also comes from seed scripts, bots, and startup loops. Below are the six traps to avoid, with the fix for each.

  • Unindexed scans. A query on a column with no index scans every row. Index every column you filter or sort on — see D1 use indexes best practices.
  • COUNT(*). Counting rows means scanning them. Cache counts or maintain a counter table instead of counting on every request.
  • Pagination without cursors. OFFSET 10000 still scans the rows it skips. Use keyset pagination (WHERE created_at < last_seen) backed by an index.
  • SELECT * on wide tables. You read every column even if you use one. Select only the columns you return.
  • Seed scripts hitting write caps. 100,000 rows written per day is easy to burn with a bulk import or a loop that re-seeds on every deploy. Seed deliberately, once.
  • Local vs remote state divergence. --local and --remote databases are separate, as the D1 get started guide notes. A schema fix applied locally but not remotely will produce confusing behaviour in production.

The diagnostic tool for all of these is EXPLAIN QUERY PLAN, documented in the SQLite language reference. Run it before shipping any query — it tells you whether SQLite is using an index or scanning the table.

Tutorial: A Tiny Notes API on Workers + D1

You will create a Workers project, provision a local and a remote D1 database, apply a schema, and deploy endpoints that show the difference between an indexed lookup and a full scan. Local state and remote state stay separate — every command below says which one it touches.

Follow the D1 get started guide if you want the canonical version; here is the working path.

Step 1 — Create the Worker (requires Node 16.17.0 or higher):

npm create cloudflare@latest -- d1-tutorial

Choose: Worker only, TypeScript, no deploy.

Step 2 — Create the D1 database:

npx wrangler d1 create notes-db

Copy the reported database_id.

Step 3 — Bind D1 in wrangler.jsonc:

{
  "name": "d1-tutorial",
  "main": "src/index.ts",
  "compatibility_date": "2026-01-01",
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "notes-db",
      "database_id": "paste-your-id"
    }
  ]
}

Step 4 — Create schema.sql:

CREATE TABLE IF NOT EXISTS notes (
  id INTEGER PRIMARY KEY,
  title TEXT NOT NULL,
  body TEXT,
  created_at TEXT NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(created_at);

Step 5 — Create seed.sql:

INSERT INTO notes(title, body, created_at) VALUES
('first note', 'hello from D1', datetime('now')),
('second note', 'indexed by created_at', datetime('now', '-1 hour'));

Step 6 — Run schema and seed locally:

npx wrangler d1 execute notes-db --file=./schema.sql --local
npx wrangler d1 execute notes-db --file=./seed.sql --local

Step 7 — Replace src/index.ts:

interface Env {
  DB: D1Database;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname === "/notes/recent") {
      const cutoff = url.searchParams.get("cutoff") ?? "2020-01-01";
      const result = await env.DB.prepare(
        "SELECT id, title, created_at FROM notes WHERE created_at >= ? ORDER BY created_at DESC LIMIT 20"
      ).bind(cutoff).all();

      console.log("rows_read", result.meta.rows_read, "rows_written", result.meta.rows_written);
      return Response.json(result.results);
    }

    if (url.pathname === "/notes/search") {
      const q = url.searchParams.get("q") ?? "";
      const result = await env.DB.prepare(
        "SELECT * FROM notes WHERE title LIKE ? LIMIT 20"
      ).bind(`%${q}%`).all();

      console.log("rows_read", result.meta.rows_read, "rows_written", result.meta.rows_written);
      return Response.json(result.results);
    }

    if (url.pathname === "/notes" && url.method === "POST") {
      const body: { title: string; body?: string } = await request.json();
      const result = await env.DB.prepare(
        "INSERT INTO notes(title, body, created_at) VALUES (?, ?, ?)"
      ).bind(body.title, body.body ?? "", new Date().toISOString()).run();

      console.log("rows_read", result.meta.rows_read, "rows_written", result.meta.rows_written);
      return Response.json({ success: result.success }, { status: 201 });
    }

    return new Response("not found", { status: 404 });
  },
};

Two endpoints matter here. /notes/recent is the indexed lookup: it filters and sorts on created_at, which idx_notes_created covers. /notes/search is the unindexed one: title LIKE ? with a leading wildcard cannot use an index, so it scans the table.

Step 8 — Show indexed vs unindexed query plans. /notes/recent uses indexed created_at >= ? + ORDER BY created_at DESC (backed by idx_notes_created); /notes/search uses title LIKE ? with a leading wildcard, a full scan.

npx wrangler d1 execute notes-db --local --command="EXPLAIN QUERY PLAN SELECT id, title, created_at FROM notes WHERE created_at >= '2020-01-01' ORDER BY created_at DESC LIMIT 20;"
npx wrangler d1 execute notes-db --local --command="EXPLAIN QUERY PLAN SELECT * FROM notes WHERE title LIKE '%invoice%' LIMIT 20;"

Step 9 — Run locally and test:

npx wrangler dev
curl http://localhost:8787/notes/recent
curl "http://localhost:8787/notes/search?q=first"
curl -X POST http://localhost:8787/notes -H "content-type: application/json" -d '{"title":"third note"}'

Watch the wrangler dev logs — every request prints rows_read and rows_written. The recent endpoint should touch only the rows it returns; the search endpoint will scan every row in the table.

Step 10 — Deploy to production:

npx wrangler d1 execute notes-db --file=./schema.sql --remote
npx wrangler d1 execute notes-db --file=./seed.sql --remote
npx wrangler deploy

Note the --remote flag. Your local database and your production database are entirely separate — the schema and seed must be applied to both.

Step 11 — Create a real backup:

npx wrangler d1 export notes-db --remote --output=backup.sql

Step 12 — Optional free-tier break demo. Create seed_50k.sql:

WITH RECURSIVE cnt(x) AS (
  SELECT 1
  UNION ALL
  SELECT x + 1 FROM cnt WHERE x < 50000
)
INSERT INTO notes(title, body, created_at)
SELECT 'note-' || x, 'body-' || x, datetime('now', '-' || x || ' seconds')
FROM cnt;
npx wrangler d1 execute notes-db --file=./seed_50k.sql --local

Then hit /notes/search?q=note and watch rows_read spike in the wrangler dev logs. That single request scans 50,000 rows — one percent of your entire daily read budget, spent on one unindexed query.

Where This Breaks at the Free Tier

Over the caps, reads and writes return hard query errors, not degraded service, per D1 limits. Storage that exceeds the per-database limit blocks new writes until you delete data. Write-heavy fan-out can also return overloaded because each database is single-threaded. This is the ceiling.

Run the 50k-row seed and put an unindexed LIKE search behind a public endpoint, and the arithmetic gets ugly fast: a 50,000-row table means every search request scans 50,000 rows. At 100 requests, you have burned 5 million reads — your entire day. Then queries start returning errors. There is no graceful degradation; you get hard query failures until the 00:00 UTC reset.

Concurrency has its own failure mode. Because each database is single-threaded, a spike of simultaneous writes can return overloaded. For a low-traffic side project this rarely matters; for a write-heavy endpoint it will. All of this is specified in D1 limits.

Time Travel Is Not a Backup

D1’s point-in-time recovery gives Free databases a seven-day restore window, with ten restores per ten minutes per database, per the D1 Time Travel reference. It helps with mistakes, but it is not a durable backup strategy. Export your SQL to a file on a schedule for a real backup.

Time Travel lets you restore a database to any point within the last seven days on Free (30 days on Paid), limited to ten restores per ten minutes per database. That saves you from a bad migration or a fat-fingered DELETE. It does not protect you from account-level problems, and it does not give you a copy you control. The D1 Time Travel reference covers restores; for durable copies, use export. The D1 import/export best practices page documents the workflow:

npx wrangler d1 export notes-db --remote --output=backup.sql

Run that on a schedule. A SQL file in your own storage is a backup; a seven-day restore window is a safety net.

When to Leave D1 Free

Upgrade to Workers Paid and D1’s paid limits lift within minutes if you only need more headroom. If you hit the 500 MB per-database ceiling or need concurrent write performance, export your data and move to a Postgres-shaped service. D1 remains easy to leave.

The cheapest exit is vertical: Workers Paid lifts D1’s query, read, write, and storage limits within minutes of upgrading, per Workers platform pricing. If you outgrow SQLite semantics entirely, your data exports as plain SQL, so moving is a dump-and-import. Postgres-shaped alternatives include Supabase, Neon, and CockroachDB; SQLite-shaped ones include Turso; Redis-shaped queues live at Upstash. If you want to keep Workers and connect an external database instead, Hyperdrive is the door — see Workers storage options to compare every option.

FAQ

These are the questions indie hackers ask before committing to D1 Free. The short answers: yes for small CRUD, hard errors at caps, and no, Time Travel is not backup. Details follow under each question. Read the longer answers before building.

Can I run a real CRUD app on D1 free forever?

Yes, if traffic stays modest and you index every queried column. Five million reads and 100,000 writes per day (D1 pricing) comfortably serve a small SaaS or internal tool with low request volume. The failure mode is not time — it is growth: one unindexed endpoint with rising traffic will eat the daily budget before you notice.

What actually happens when I exceed the daily read or write cap?

You get hard query errors, not throttling or degraded performance, per D1 limits. Queries fail until the daily reset at 00:00 UTC. Over the storage limit, new writes are blocked until you delete data. This is why logging meta.rows_read in development matters — you want to find the expensive endpoint before your users do.

Is Time Travel a real backup?

No. Time Travel is point-in-time recovery: a seven-day restore window on Free, with ten restores per ten minutes per database (D1 Time Travel). It undoes recent mistakes but is not a durable, off-platform copy. For a real backup, export your database to a SQL file with wrangler d1 export and store it yourself.

Verdict: Free D1 Is a Real Side-Project Database — With a Budget

For a free-tier SaaS or internal tool, D1 removes the database bill without forcing you off SQL. Monitor rows_read and rows_written daily, index every column you filter or sort on, and keep scheduled exports. Then use it until the caps hurt, then upgrade or move.

Our recommendation: use D1 Free for side projects and internal tools where traffic is predictable and writes are light. Schedule the wrangler d1 export backup so a mistake never becomes data loss. Leave when single-threaded writes or the 500 MB per-database cap start hurting — and know that leaving is a SQL export, not a rewrite.