Choosing a data store
Five stores with genuinely different guarantees: D1 for relational records, Postgres for what exceeds it, Durable Objects for ordering and live state, KV for hot lookups, R2 for files. A decision tree, the capacity limits, and the mistakes that are expensive to undo.
Five stores, five different guarantees. Picking correctly at design time is cheap; discovering the mismatch in production is not.
The comparison that matters
| D1 | Postgres | Durable Object | KV | R2 | |
|---|---|---|---|---|---|
| Availability | All plans | Enterprise | All plans | All plans | All plans |
| Shape | Relational SQL (SQLite) | Relational SQL (Postgres) | One object, own SQLite | Key → value | Object storage |
| Consistency | Strong at primary; replicas may lag | Strong at origin | Serialized — one writer at a time | Eventual (seconds) | Read-after-write for new objects |
| Read-your-writes | Automatic via request session | Manual — disable query caching | Inherent | No | For new objects |
| Concurrency | Queries execute serially | Pooled, ~6 conns per invocation | Single-threaded per object | High, unordered | High |
| Ordering | No inherent ordering guarantee | No inherent ordering guarantee | Strict per object | None | None |
| Query | Full SQL, joins, aggregates | Full Postgres, extensions | Only via that object | Key lookup only | Key lookup / list |
| Capacity | 10 GB per database | Far higher | 10 GB per object | Large values, high volume | Effectively unbounded |
| Instances per account | 50,000 databases | ~25 configurations | Unlimited per class | — | — |
| Point-in-time recovery | Yes — 30 days | Provider-dependent | No | No | No |
| Region control | Not selectable | Selectable | Not selectable | Not selectable | Not selectable |
| Cost floor | None beyond your plan | ~$5/mo per database | None | None | None |
| Per environment | Automatic | One database per environment | Yes | Yes | Yes |
Four rows decide most designs:
- Ordering — if you need strict ordering, only a Durable Object gives it.
- Point-in-time recovery — if you need to rewind after a bad write, only D1 does. This is the row that most often surprises people, because it cuts against the assumption that Postgres is simply the more capable engine.
- Instances per account — 50,000 D1 databases against roughly 25 Postgres configurations. For a database-per-tenant architecture, D1 is the only one of the two that fits.
- Region control — the only reason a residency requirement has an answer.
D1 — the default
Business records go here. It's a real relational database with migrations, indexes, and transactions, and it's the only store with point-in-time recovery.
The two disciplines that keep it healthy, both covered in Database:
- Index every hot column. Queries execute serially, so one unindexed scan delays every request behind it. That makes a missing index an availability problem, and it degrades as data grows rather than failing loudly on day one.
- Read through the request session so replicas can serve reads while each visitor still reads their own writes.
Capacity to plan against: 10 GB per database, 1 TB per account, 2 MB maximum row size, 100 columns per table, and a 30-second ceiling on any single query. Ten gigabytes is a lot of business records and not much if you're storing blobs — which is what R2 is for.
Past that ceiling, Postgres is the next step — below.
Postgres — past D1's limits
Enterprise capability
A managed Postgres database, reached from your Worker over a pooled connection binding. Provisioned under an enterprise agreement rather than enabled by default; the full treatment is in Postgres alongside D1.
Reach for it on one of four specific requirements, not as a general upgrade:
| The requirement | Why D1 can't |
|---|---|
| A dataset over 10 GB in one database | That's D1's hard per-database ceiling |
| Postgres extensions, types, or dialect | D1 is SQLite |
| Data that must sit in a named country | D1's location isn't selectable; a Postgres region is |
| An existing Postgres schema you're migrating as-is | Avoids a dialect port |
Outside those four, D1 is the better choice — and that's not a limitation talking, it's the comparison table above: D1 has built-in point-in-time recovery, no cost floor, automatic per-environment provisioning, and scales to a thousand times more databases.
Three properties change how you write the code:
- Read-your-writes is not automatic. The connection layer caches reads by default (~60 s), so a user can save a record and then not see it. Disable caching for transactional reads, or route them through a second uncached binding. D1's session bookmarks do this for you; Postgres does not.
- Connections are scarce per request — about six concurrent, so size the driver pool below that and don't fan out parallel queries.
- Your Worker never holds the credentials. It receives a pooled local connection string; the origin credentials live in the binding's configuration.
Mixing both in one app is normal and often correct: Postgres for the large or regulated table, D1 for everything else.
Durable Objects — ordering and live state
A Durable Object is a single-threaded authority over one piece of state. That property is the entire value: while it processes one request, nothing else touches that object's state, so lost updates and double-bookings are impossible by construction rather than by careful coding.
Use one when:
- Many clients share live state they must all agree on — a chat room, presence, a live auction, a collaborative document.
- Work for one key must be strictly ordered — every event for one order, one customer, one document, processed in sequence.
You declare classes at deploy time; instances are created at runtime on first touch, so hundreds of thousands of rooms per class is normal and idle ones hibernate at no cost. Mechanics in Durable Objects.
Two constraints to design around: an object's storage is reachable only through that object — there are no cross-object queries, so anything you need to search or report on must also be written to D1 — and there is no point-in-time recovery. A Durable Object's state is durable, but you cannot rewind it. State you'd want to restore after a mistake belongs in D1, or should be mirrored there.
KV — hot lookups and flags
Fast key reads, and eventually consistent: a write takes seconds to be visible everywhere. That's the whole trade.
Good: cached computations, feature flags, configuration you flip during an incident, session lookups where a few seconds of staleness is harmless.
Wrong: anything read back immediately after writing, anything requiring a query, anything where two concurrent readers must agree.
The incident-flag case is genuinely valuable — a kill switch in KV changes behavior in seconds without a deploy, which is a meaningfully faster mitigation path than shipping code. See Failure domains.
R2 and assets — files
Files, media, generated documents, exports. Public assets are served from /assets/ through the CDN, automatically cached and immutably keyed, so they're never stale — and replacing one means a new key, not an overwrite.
The rule that saves you later: the app's source tree is capped at 256 MB and is not a file store. Media, datasets, and generated output belong in R2 or private artifacts. Every file in the tree is versioned and synced to every checkout forever, so a large file there is a permanent tax on everyone who works on the app. Details in Files and assets.
Combining them
Real applications use several, and the combinations are conventional enough to name:
The pattern generalizes: the Durable Object makes the decision, D1 records it, KV and the edge cache serve the read traffic. Contention is handled exactly once, in the one place that can handle it correctly, and the expensive read path never touches it.
On an enterprise deployment the same shape holds with Postgres substituted for the one table that needs it — the large ledger, or the records under a residency obligation — while D1 keeps everything else and its recovery and per-environment advantages.
The expensive mistake
Writing contended rows in plain D1 with no concurrency control:
-- Two requests read version 3, both write. One update is silently lost.
UPDATE bookings SET slot = ? WHERE id = ?
It passes every test you'll write, because tests don't race. Then two users book the same slot. The fixes, in increasing order of strength: a version column with a conditional update returning a conflict, a transactional operation route that enforces the invariant in one place, or a Durable Object per resource. Consistency and correctness works through all three.
Next
- Consistency and correctness — the guarantees each store actually provides.
- Durability, backup and recovery — what can be restored, and what cannot.
- Database — the hands-on guide to D1.
- Postgres alongside D1 — the enterprise option in full.
Frequently asked questions
Should we use Postgres instead of D1?
Usually not. Postgres is an enterprise capability for four specific needs: a dataset over 10 GB, Postgres-specific features, data pinned to a region, or an existing Postgres schema. Outside those, D1 is better — it has built-in 30-day recovery, no cost floor, and scales to far more databases.
What's the default choice?
D1, the relational database. Records, catalogs, orders, users, audit rows — anything you'll query, join, or report on. Reach for another store only when D1's guarantees are the wrong shape for the problem.
When do we actually need a Durable Object?
When many clients share live state they must all agree on, or when work for one key must happen in strict order. A booking system that must never double-book is the canonical case; a chat room is the obvious one.
Is KV a faster database?
No — it's a different guarantee. KV is eventually consistent, so a write can take seconds to be visible everywhere. It's excellent for cached values and feature flags and wrong for anything you need to read back immediately.
How large can these get?
A D1 database holds 10 GB with 1 TB per account; a Durable Object holds 10 GB of SQLite storage; R2 is effectively unbounded. Past D1's 10 GB, enterprise deployments can add Postgres. The app's own source tree is capped at 256 MB, which is why files belong in R2 rather than the repository.
Which choice is hardest to reverse?
Putting contended writes in plain D1 rows without version checks. It works in testing and produces lost updates under real concurrency, and by the time you notice, the wrong data is already in production.