Cloudflare D1 Guide: Pricing, Limits & Best Practices (2026)
Cloudflare D1 is Cloudflare's managed, serverless SQLite database for Workers. You bind it to a Worker, query it with prepared statements, and pay only for rows read, rows written and storage. It's the simplest way to add a relational database to a Workers app. You get no connection strings, no connection pools and no separate database bill: D1 is included in the $5/month Workers Paid plan, and there's a free tier for side projects.
The trade-offs are clear once you know them. Each database is single-threaded and capped at 10 GB. You're billed on rows scanned, not rows returned, so indexes and query shape affect cost directly. This guide covers pricing and limits as of September 2026, the Workers and Hono setup, read replication with the Sessions API, migrations, indexes, batching, Time Travel backups, local development, common errors and a production checklist. Every figure is checked against Cloudflare's official documentation.
For first-hand context, this site runs on Cloudflare Workers, Hono and D1, rendered on the server in TypeScript. I described the build in building a portfolio website with Cloudflare Workers, Hono, and D1. This post is the D1 reference I wish I'd had then.
Key takeaways
- Free tier: 5 million rows read and 100,000 rows written per day, and 5 GB of storage. The limits reset at 00:00 UTC, and queries fail once you reach them.
- Paid tier: 25 billion rows read and 50 million rows written per month are included in the $5/month Workers Paid minimum, and read replicas cost nothing extra.
- Hard limits: 10 GB per database (500 MB on Free), 1,000 queries per Worker invocation (50 on Free) and 30 seconds per query.
- Cost comes from rows scanned. Check
meta.rows_read, add indexes on the columns you filter by and confirm withEXPLAIN QUERY PLAN. - Backups are automatic. Time Travel is always on, with 30 days of history on Paid and 7 on Free, and restores cost nothing.
What is Cloudflare D1, and when should you use it?
D1 is a SQLite database that Cloudflare runs for you. Your Worker reaches it through a binding, a typed object on env, rather than over a network connection you manage yourself. Queries use SQLite's SQL dialect, so most existing SQLite knowledge and tools still apply.
The most important architectural fact is in the D1 FAQ: "each individual D1 database is inherently single-threaded, and processes queries one at a time." Cloudflare's own example puts throughput at about 1,000 queries per second for 1 ms queries and about 10 per second for 100 ms queries. Once the queue fills, the database returns an overloaded error. Fast, indexed queries aren't only cheaper, they're what lets one database keep up.
D1 fits well when:
- Your app already runs on Workers or Pages, and you want relational data without running a server.
- The data fits in 10 GB per database, or splits naturally into many databases, for example one per customer or tenant.
- The workload is mostly reads, which read replication can serve closer to users.
Look elsewhere if a single dataset will grow well past 10 GB and can't be split, or if you need heavy concurrent writes to one database. Read-heavy content sites and internal tools are the kind of workload D1 suits best. For the broader question of when a custom stack beats a CMS, see custom CMS vs WordPress architecture.
Cloudflare D1 pricing and free tier (as of September 2026)
These figures come from the official D1 pricing page, checked on 25 September 2026. D1 has no separate subscription: it's part of the Workers plan, and Workers Paid has "a minimum charge of $5 USD per month for an account."
| Metric | Workers Free | Workers Paid |
|---|---|---|
| Rows read | 5 million / day | First 25 billion / month included, then $0.001 per million rows |
| Rows written | 100,000 / day | First 50 million / month included, then $1.00 per million rows |
| Storage | 5 GB total | First 5 GB included, then $0.75 per GB-month |
| Read replicas | No extra charge | No extra charge |
| Data transfer | Not charged | Not charged |
Some details the table leaves out:
- Free limits are daily. They reset at 00:00 UTC, and queries fail once you hit them, so one backfill script or traffic spike can take a free-tier site offline until midnight UTC. Paid limits reset on your billing date and roll into overage charges instead of failing.
- Writes cost far more than reads. On Paid, a million rows written costs $1.00 and a million rows read costs $0.001, a factor of 1,000. Keep writes lean and avoid rewriting rows that haven't changed.
- Scale to zero. Cloudflare bills only for queries you run and storage you use. An idle database costs only its storage.
D1 limits that shape your design
From the D1 limits page:
| Limit | Free | Paid |
|---|---|---|
| Databases per account | 10 | 50,000 (increase on request) |
| Maximum database size | 500 MB | 10 GB |
| Maximum storage per account | 5 GB | 1 TB (increase on request) |
| Time Travel history | 7 days | 30 days |
| Queries per Worker invocation | 50 | 1,000 |
| Maximum query duration | 30 seconds | 30 seconds |
| Maximum row, string or BLOB size | 2 MB | 2 MB |
| Maximum SQL statement length | 100 KB | 100 KB |
| Bound parameters per query | 100 | 100 |
| Columns per table | 100 | 100 |
| Maximum import file size | 5 GB | 5 GB |
Two limits cause the most trouble in practice. The 100 bound parameters limit breaks bulk inserts that build one huge VALUES (?, ?), (?, ?)… statement. Split them into chunks or use batch(). The 10 GB cap is deliberate: Cloudflare wants you to scale out across many smaller databases rather than grow one large one, which is why paid accounts can hold 50,000 databases.
Setting up D1 with a Worker
The getting started guide uses Wrangler to create the database and write the binding into your config:
npx wrangler@latest d1 create app-db
# Answer "Yes" to add the binding to wrangler.jsonc automatically
npx wrangler d1 execute app-db --local --file=./schema.sql # local copy
npx wrangler d1 execute app-db --remote --file=./schema.sql # production
The binding in wrangler.jsonc looks like this. The binding value becomes the property name on env:
{
"d1_databases": [
{
"binding": "DB",
"database_name": "app-db",
"database_id": "<id printed by wrangler d1 create>"
}
]
}
By default, D1 places a new database near the location the create request came from. You can pass a location hint (wnam, enam, weur, eeur, apac or oc). The data location docs say hints are preferences, not guarantees, and are "not currently supported for South America, Africa, and the Middle East." If you need data residency, set the eu or fedramp jurisdiction. You can only set it when you create the database.
Querying D1 from Hono
Hono's Workers guide types bindings by passing them as a generic, so c.env.DB is a typed D1Database. Always use prepare() with bind(). Cloudflare's docs recommend prepared statements to prevent SQL injection.
import { Hono } from "hono";
type Bindings = { DB: D1Database };
const app = new Hono<{ Bindings: Bindings }>();
app.get("/api/posts", async (c) => {
const { results, meta } = await c.env.DB
.prepare("SELECT slug, title, published_at FROM posts WHERE status = ? ORDER BY published_at DESC LIMIT 20")
.bind("published")
.run();
console.log({ rowsRead: meta.rows_read, ms: meta.duration });
return c.json(results);
});
app.get("/api/posts/:slug", async (c) => {
const post = await c.env.DB
.prepare("SELECT * FROM posts WHERE slug = ?")
.bind(c.req.param("slug"))
.first();
return post ? c.json(post) : c.notFound();
});
export default app;
run() returns results plus a meta object. first() returns the first row or null. The return object reference lists the meta fields worth logging. rows_read is "the number of rows read (scanned) by this query," which is exactly what you're billed for. total_attempts shows whether D1 had to retry.
Read replication and the Sessions API
Every D1 database has one primary that takes all writes. Read replication adds read-only copies in each of the six regions (ENAM, WNAM, WEUR, EEUR, APAC, OC), so reads can be served near your users. Replication is asynchronous, and Cloudflare warns that a replica can be "arbitrarily out of date" at any moment. To stay consistent, you route queries through a session.
A session follows a bookmark, a marker for a version of the database. Queries in the session never see data older than that bookmark. This gives you read-your-own-writes behaviour even when a later request lands on a different replica. The documented pattern passes the bookmark between requests in an x-d1-bookmark header:
app.use("/api/*", async (c, next) => {
const bookmark = c.req.header("x-d1-bookmark") ?? "first-unconstrained";
const session = c.env.DB.withSession(bookmark);
c.set("db", session);
await next();
c.res.headers.set("x-d1-bookmark", session.getBookmark() ?? "");
});
app.post("/api/comments", async (c) => {
const db = c.get("db");
const { postId, body } = await c.req.json();
const { meta } = await db
.prepare("INSERT INTO comments (post_id, body) VALUES (?, ?)")
.bind(postId, body)
.run();
return c.json({ id: meta.last_row_id, servedByPrimary: meta.served_by_primary });
});
To make this type-check, add a Variables type such as { db: D1DatabaseSession } to the Hono generic. Use "first-primary" when the first query must see the very latest data, such as an admin dashboard just after a save. Use "first-unconstrained", the default, for public reads that can tolerate a moment of lag. You switch replication on in the dashboard (D1, then Settings, then Enable Read Replication) or through the REST API. The pricing page says it "does not charge extra for read replicas."
Replicas reduce round-trip time for readers far from the primary. That shows up in time to first byte, one of the inputs I cover in Core Web Vitals and SEO optimization.
Migrations with Wrangler: local vs --remote
D1 includes a migration system in Wrangler. Per the migrations reference, migrations are .sql files in a migrations/ folder, and applied migrations are recorded in a d1_migrations table. You can change the folder (migrations_dir), the table (migrations_table) and a glob pattern (migrations_pattern) in your Wrangler config, which helps when an ORM writes migrations into subfolders.
npx wrangler d1 migrations create app-db add_comments_table
# edit migrations/0002_add_comments_table.sql
npx wrangler d1 migrations list app-db --local
npx wrangler d1 migrations apply app-db --local # test against the local copy
npx wrangler d1 migrations apply app-db --remote # then production
--local targets the copy that wrangler dev uses, and --remote targets the real database on Cloudflare. Cloudflare recommends passing the database name rather than the binding name to "avoid accidentally running migrations on the wrong binding." In CI, run apply --remote as a step before wrangler deploy, so new code never ships ahead of its schema.
Indexes and query cost: controlling rows read
D1 bills rows read as "how many rows a query reads (scans), regardless of the size of each row." A query that filters 5,000 rows down to 10 without an index still costs 5,000 rows read. The indexes guide gives a three-step routine:
-- 1. Index the columns you filter or sort by
CREATE INDEX IF NOT EXISTS idx_posts_status_published ON posts(status, published_at);
-- 2. Refresh planner statistics
PRAGMA optimize;
-- 3. Confirm the index is used: look for "USING INDEX", not "SCAN"
EXPLAIN QUERY PLAN
SELECT slug, title FROM posts WHERE status = 'published' ORDER BY published_at DESC LIMIT 20;
To find which queries to fix, Cloudflare suggests looking for a high ratio of rows read to rows returned, using meta.rows_read. Indexes aren't free, though. The pricing FAQ notes that "writing to columns referenced in an index will add at least one (1) additional row written." Indexes also take up storage, and creating one writes a row for every row it indexes. Create indexes in migrations, not on each request, and index only columns your queries actually filter on.
Batching and transactions
env.DB.batch() sends several prepared statements in one call. According to the D1 database API, the statements "execute and commit, sequentially, non-concurrently." If one fails, it "aborts or rolls back the entire sequence." In practice, batch() is how you run a transaction in D1.
const insert = c.env.DB.prepare("INSERT INTO order_items (order_id, sku, qty) VALUES (?, ?, ?)");
await c.env.DB.batch([
c.env.DB.prepare("INSERT INTO orders (id, customer_id) VALUES (?, ?)").bind(orderId, customerId),
...items.map((i) => insert.bind(orderId, i.sku, i.qty)),
c.env.DB.prepare("UPDATE customers SET order_count = order_count + 1 WHERE id = ?").bind(customerId),
]);
Batching also cuts round trips between the Worker and the database. Don't put BEGIN TRANSACTION and COMMIT in SQL files you import. The import docs say to remove them if you see "cannot start a transaction within a transaction."
Local development with wrangler dev
Per the local development docs, wrangler dev uses local mode by default, powered by Miniflare and workerd, and "data is persisted across each run." The local database lives under .wrangler/state in your project, so add that folder to .gitignore. Use --persist-to to keep it somewhere shared.
npx wrangler dev # local D1, local data
npx wrangler d1 execute app-db --local --command "SELECT COUNT(*) FROM posts"
npx wrangler dev --persist-to=./.dev-state # custom state location
You can point a binding at the real database with "remote": true, but Cloudflare warns that "any changes you make when running against a remote database cannot be undone." A safe routine is to run migrations locally, seed test data locally, and touch --remote only in CI or when deliberately checking production.
Backups: Time Travel and export
Time Travel is D1's point-in-time recovery. Per the docs: "You do not need to enable Time Travel. It is always on." It keeps 30 days of history on Workers Paid and 7 days on Free, and "database history and restoring a database incur no additional costs." A restore overwrites the database in place and returns the bookmark from before the restore, so you can undo it.
# Restore to a moment (Unix timestamp for 2026-09-24 00:00 UTC)
npx wrangler d1 time-travel restore app-db --timestamp=1790208000
# Keep an off-platform copy as a standard SQL dump
npx wrangler d1 export app-db --remote --output=./backup.sql
Time Travel protects against bad migrations and accidental deletes, but only for 30 days, and only inside Cloudflare. For longer retention, or a copy you control, schedule wrangler d1 export. The same import and export page lists three caveats. Export isn't supported for databases with virtual tables. "A running export will block other database requests," so schedule it off-peak. Large integers are limited by JavaScript's number precision. For how this fits a wider backup policy, see my overview of modern cloud infrastructure on AWS, Azure and GCP.
Common D1 errors and how to fix them
The messages below come from Cloudflare's D1 debugging guide. D1 already "automatically retries read-only queries up to two more times when it encounters a retryable error," according to the retry guide. For writes, add your own retries with exponential backoff, but only for idempotent queries.
| Error | What it means | Fix |
|---|---|---|
D1_TYPE_ERROR | A value's type doesn't match the column | Usually undefined passed to bind(). Pass null instead |
D1_COLUMN_NOTFOUND | The column doesn't exist | Check the name, and check the migration ran on --remote |
D1_EXEC_ERROR | SQL syntax or execution failure in exec() | Fix the reported line |
| Free tier read or write limit reached | Daily free allowance used up | Wait for 00:00 UTC or upgrade to Workers Paid |
| "Exceeded maximum DB size." | Database hit its 500 MB or 10 GB cap | Delete data or split across databases |
| "D1 DB is overloaded." | The single-threaded queue is full | Speed up queries with indexes and reduce query frequency |
| "D1 DB storage operation exceeded timeout" | A query ran too long | Optimize the query and spread load over time |
| "Network connection lost." / "Replica disconnected from primary." | A temporary fault | Safe to retry idempotent queries |
Cloudflare D1 production checklist
- Pick the location hint or jurisdiction before creating the database. A jurisdiction can't be added later.
- Keep all schema changes in
migrations/, apply them with--localfirst, then--remotein CI before deploying. - Use
prepare().bind()for every query that takes user input. Never build SQL strings by hand. - Index the columns in your
WHEREandORDER BYclauses, runPRAGMA optimize, and check withEXPLAIN QUERY PLAN. - Log
meta.rows_readandmeta.durationfor your hottest routes, and watch the rows-read-to-rows-returned ratio. - Group related writes into
batch()so they succeed or fail together. - If you enable read replication, route queries through
withSession()and pass the bookmark back to the client. - Stay under 100 bound parameters per statement by chunking bulk inserts.
- Add retries with backoff for idempotent writes. D1 already retries reads.
- Write down your Time Travel restore command before you need it, and schedule
wrangler d1 exportfor off-platform backups. - If traffic is real, move to Workers Paid. Free-tier limits make queries fail rather than bill you.
For the requirement-gathering side of a checklist like this, the approach in system analysis techniques for modern IT professionals applies directly.
FAQ
Is Cloudflare D1 free?
Yes, within limits. The Workers Free plan includes 5 million rows read and 100,000 rows written per day, 5 GB of total storage and up to 10 databases of 500 MB each. Beyond that you need Workers Paid, which has a $5/month minimum and includes 25 billion rows read and 50 million rows written per month.
How big can a D1 database be?
10 GB per database on Workers Paid and 500 MB on Free. Paid accounts can have up to 50,000 databases and 1 TB of total storage, and both can be raised on request. That's why D1 designs often use one database per tenant.
Does D1 support transactions?
Yes, through batch(). The statements in a batch run one after another, and if any fails, the whole batch rolls back. Don't put BEGIN TRANSACTION and COMMIT in your SQL.
Is D1 read replication free?
Yes. Cloudflare charges the same rows-read and rows-written rates whether a query is served by the primary or a replica, and adds nothing for the replicas themselves.
How do I back up a D1 database?
Time Travel backs it up automatically and lets you restore to any minute in the last 30 days (7 on Free) with wrangler d1 time-travel restore. For a copy outside Cloudflare, run wrangler d1 export --remote --output=backup.sql on a schedule.
I build and maintain Cloudflare Workers and D1 applications, including this site. If you'd like a second pair of eyes on your setup, you can get in touch here.