KV, and what it is for

Workers KV is a get-by-key store. That is the whole shape of it, and knowing where the shape stops is most of knowing when to use it.

What it is good at

Where relational data goes instead is the data layer, which binding to reach for is bindings, and audit is the capability that refuses KV outright and says why.

Reading one value by its exact key, very fast, from anywhere. A session. A short-lived room code. A cached rendering keyed by its inputs.

Reads are eventually consistent and edge-cached, which is what makes them fast — and is also the property that decides several of the answers below.

What belongs in D1 instead

Anything you would ever want to ask a question about.

You wantWhere it goes
This exact keyKV
Every row where…D1
How many…D1
The most recent tenD1
Joined against my own tableD1

An audit log is the clearest case, and the kit states it in as many words: KV is deliberately not an option for the audit trail, because an audit log is a query workload — by actor, by action, by time range, by resource, by outcome — and KV can answer none of those.

The same reasoning rules out a leaderboard, a purchase history and a support inbox. All three look like key-value data until the first time somebody asks a question.

One namespace per purpose

SESSIONS. CACHE. Never a junk-drawer namespace holding four unrelated kinds of thing.

A namespace is cheap, and one per purpose means you can reason about a namespace’s TTLs, its key shape and its size without knowing what else is in it.

Typed on both sides

No untyped parsing. A value is validated with a Zod object on read and on write, exactly as a table’s rows are.

The failure that prevents is specific: a shape you changed six months ago, still in the store under an old key, parsed by code that assumes the new shape. Validating on read turns that into a caught error rather than an undefined two functions away.

Keys are namespaced per capability

auth:session:<id>. The capability’s own name leads, the kind follows, the identifier last.

That is the same partitioning argument as everything else in the kit: a flat namespace with no convention is a namespace where two features collide once and nobody can tell which one is wrong.

TTLs are explicit

Always. A key with no expiry is a key that lives forever, and a store full of those is a store nobody can reason about.

Where the value has a natural lifetime — a session, a code, a cached page — that lifetime is the TTL, and writing it down is how the store stays the size you expect.

Metadata, and the thing that bites

KV lets you attach metadata to a value. It is genuinely useful — a small structured fact you can read without fetching the value.

A write replaces the whole entry, metadata included. Update a value and forget to pass the metadata again, and the metadata is gone. Not merged, not preserved — gone, silently, with the write reporting success.

So a typed accessor is worth more here than almost anywhere else: it carries both halves together, so there is no call shape that writes one and drops the other.

Eventual consistency, in practice

A write is not immediately visible everywhere. Usually that is fine — a session written on sign-in is read on the next request from the same region.

It is not fine for anything where two readers disagreeing matters: a balance, a lock, a counter, a uniqueness check. Those want D1, where the write either happened or did not.

If you find yourself reading a KV value to decide whether to write it, the value is in the wrong store.

Reading it

c.var.kv is the typed registry, one accessor per declared store:

const session = await c.var.kv.auth.sessions.get(id);

Validated on the way out, with the shape the store declared.

ESC