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.

The Spikefrost Team30 Jul 20267 min read

Five stores, five different guarantees. Picking correctly at design time is cheap; discovering the mismatch in production is not.

Start at the top. Most application data is relational and belongs in D1 — the other stores answer specific needs D1 is the wrong shape for.

yes

no

yes

no

no

yes

no

yes

no

yes

What are you storing?

A file or
media object?

R2 + assets
images, video, exports, documents

Must writes for one key
happen in strict ORDER,
or do clients share LIVE state?

Durable Object
booking, inventory, chat room, game

Do you need to query,
join, sort or report on it?

Is a few seconds of
staleness acceptable?

KV
cache, feature flags, sessions

D1 — the default
orders, users, catalog, audit rows

Over 10 GB, needs Postgres
features, or must sit in a
chosen region?

Postgres
enterprise · large or regulated tables

Start at the top. Most application data is relational and belongs in D1 — the other stores answer specific needs D1 is the wrong shape for.

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:

A booking system using each store for what only it can do. The Durable Object owns the contended decision; D1 owns the queryable record; KV serves reads that tolerate staleness.

accepted

periodic

Booking request

Durable Object
per resource
serializes the decision

D1
bookings table —
queryable, recoverable

Reports · admin ·
customer history

KV
availability summary

Public availability page
cached, seconds-stale is fine

A booking system using each store for what only it can do. The Durable Object owns the contended decision; D1 owns the queryable record; KV serves reads that tolerate staleness.

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

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.