The development experience with D1 is genuinely good: a local SQLite that wrangler dev creates automatically, queries that respond in a few milliseconds, no server to configure, no connection string to manage. This zero friction in development tends to create an illusion that the bank will behave the same way in production. It won't. The 2GB ceiling per bank, the subrequests that add up, the cost of lines written by UPDATE and the enforcement of foreign keys that requires manual opt-in per session are the four limits that appear in production and that quickstart never mentions.
The 2GB ceiling that no one plans
D1 imposes a limit of 2GB per database. This number is fixed — there is no option to increase it for a specific bank by purchasing additional capacity. The paid plan allows up to 10 D1 banks, which means a theoretical total of 20GB distributed between separate instances.
For a simple CRUD application with modest data volume, 2GB is enough for years. For applications with logs, event history, user data uploads, or tables that grow with usage, the limit appears sooner than expected. The problem isn't reaching 2GB itself — it's the moment you realize it's going to arrive, with data in production and without a defined partitioning strategy.
The cleanest path for applications with predictable growth is to partition data by domain from the beginning: one database for active transactional data, another for history, another for logs. A SaaS application can partition by tenant ranges — tenants 1 to 1000 in bank A, 1001 to 2000 in bank B. The Worker decides which bank to access based on the tenant ID, without the user noticing. This architecture needs to be thought out before the first data arrives, because refactoring the partitioning with a production database close to the limit is a delicate operation.
The hidden cost of subrequests
Each query executed in D1 counts as a subrequest in the Worker's budget. The limit for Workers is 1000 subrequests per invocation. This seems spacious until you map out what a single HTTP request does: authenticate the token (1 query), load the user (1 query), check permissions (1 query), fetch the list of resources (1 query), and so on. Ten queries on an endpoint is common.
The N+1 problem quickly transforms this number. An endpoint that lists 50 orders and then fetches the items from each order individually executes 1 + 50 = 51 queries. Combined with 5 KV reads for cache and 3 R2 accesses for metadata, this single request uses 59 subrequests. Still within the limit, but with a small margin for more complex endpoints.
db.batch() solves N+1 without changing the data structure: you group multiple queries into a single call, and they all execute in a single subrequest. The result comes back as an array with one element per query. For the orders and items pattern, db.batch() with the dynamically constructed queries reduces 51 subrequests to 2 — one query for the orders, one with IN for all items at once.
Write amplification: what $1/million lines written really means
The D1 billing model for writes is per affected line, not per operation. A UPDATE that modifies 5,000 lines costs 5,000 writes, regardless of whether it is a single call to the bank. At $1 per million lines written, this UPDATE costs $0.005 per execution.
This number seems small, but batch operations have a cumulative effect. A daily job that updates status for 100,000 records as part of nightly processing costs $0.10 per run, $3 per month for that job alone. Multiplied by several similar jobs, the cost of writes can easily exceed the cost of reads.
The free tier has a limit of 100 thousand lines written per day. A single batch update operation can consume this entire limit. This means that the free tier is not compatible with processing pipelines that do bulk updates — for any workload with bulk writes, the paid plan is the only way to go.
Patterns that reduce writing costs: append-only instead of update (inserting a new state record instead of updating the existing one), periodic compression instead of continuous updates, and processing in larger batches less frequently instead of granular and frequent updates.
FOREIGN KEYS and PRAGMA: the trick per session
SQLite does not enforce foreign keys by default. This behavior is inherited by D1 without modification. If you define FOREIGN KEY (user_id) REFERENCES users(id) in the schema and insert a row with a user_id that does not exist in table users, D1 accepts the INSERT without error — unless you ran PRAGMA foreign_keys = ON in that session.
The critical detail is "in that session". PRAGMA does not persist between connections. Each Worker invocation that needs foreign key enforcement needs to execute PRAGMA as the first operation. If your code initializes the database via a helper, add PRAGMA there — and document this, because it will eventually escape the attention of someone on the team.
The cost of not doing this is silent: you accumulate orphaned records without any errors in the log. Discovering the problem means manually searching for broken references, and fixing it means deciding whether to delete the invalid records or create the missing parent records. If the volume of corrupted data is large, the correction becomes a delicate migration into production.
A startup helper that always runs PRAGMA before any other operation is the simplest possible investment against this problem. Query PRAGMA foreign_keys = ON takes less than a millisecond. The time to debug invalid data in production is considerably longer.
Also read
- Durable Objects in production: what the bill will look like and the limits that surprise
- Cloudflare Workers in production: what changes after hello world
- KV in production: the patterns that work and those that deceive at the beginning
- Workers: CPU and memory limits — which the documentation doesn't explain well
- Cloudflare D1: The SQLite database at the edge — and why 'edge' doesn't mean what it seems
- Slow queries in D1: how to diagnose and optimize
