A guide to Deno KV
Deno ships with a database called Deno KV. This guide explains what kind of database it is, shows you how to use it, and then takes a close look at the key space, which is where all of its querying power actually lives. Everything here runs on your machine; the same API also runs on Deno Deploy against a replicated database, and the few places where that matters are called out as they come up.
What a key-value store is
A key-value store is a database built around one idea: you store a value under a key, and you get the value back by presenting the same key. At its core it is a Map that survives the process. There are no tables, no columns, no joins, and no query language. The store supports a handful of operations, and each of them is fast and predictable:
- put a value under a key
- get the value for a key
- delete a key
- scan a range of keys in order
That last operation is what separates a database from a persistent hash map. Deno KV is a sorted key-value store: it keeps every entry ordered by key at all times. Because the keys are sorted, asking for "every key that starts with ["users"]" or "the last ten keys under ["logs"]" is a cheap, ordered walk rather than a full scan. You can think of the sort order as the store's only index.
That framing tells you what the trade is. A relational database answers questions you did not plan for, because a query planner can join and filter its way to almost anything. A key-value store answers only the questions you designed the keys for, and answers them very fast. If you need to look users up by email as well as by id, nothing will build that second index for you; you will store the email as a second key yourself, and this guide shows the pattern that keeps the two keys honest.
Key-value stores fit data that is naturally looked up by identity or by range: sessions, user records, configuration, counters, caches, feature flags, queues of work, anything shaped like "the X for Y". They fit badly when you need ad-hoc filtering across many attributes, aggregation over large sets, or constraints spanning many entities, which is when a relational database earns its complexity.
Deno KV is this kind of store, built into the runtime. Locally it is backed by SQLite, either in memory or in a file. On Deno Deploy the same API is backed by FoundationDB, replicated and shared across your deployment's isolates. Your code does not change between the two.
Using Deno KV
Opening a database
The API is currently unstable, so the runtime keeps it behind a flag. Run any file that uses it with --unstable-kv:
deno run --unstable-kv main.ts
Or grant it once in deno.json, after which no flag is needed:
{
"unstable": ["kv"]
}
Deno.openKv() opens a database and resolves to a Deno.Kv handle that every other operation hangs off:
// A persistent database that Deno manages on disk, shared by the
// surrounding project (or private to this script when no deno.json exists).
const kv = await Deno.openKv();
// A specific SQLite file.
const kvAtPath = await Deno.openKv("./data/app.sqlite");
// A private in-memory database, gone when the process ends.
const kvInMemory = await Deno.openKv(":memory:");
A handle should be closed when you are done with it. kv.close() does it explicitly, and Deno.Kv also supports the using declaration, which closes the handle automatically when the scope ends:
using kv = await Deno.openKv(":memory:");
// use kv freely; it closes itself at the end of this scope
The :memory: form is ideal for tests and for trying the examples in this guide, since every run starts blank and leaves nothing behind.
Writing and reading
kv.set(key, value) writes one value under one key. kv.get(key) reads it back:
await kv.set(["greeting"], "hello");
const entry = await kv.get<string>(["greeting"]);
console.log(entry.key); // ["greeting"]
console.log(entry.value); // "hello"
console.log(entry.versionstamp); // "00000000000000010000"
Notice that keys are arrays. That is not an idiom, it is the type: a key is a sequence of parts, and the whole next section is about why. Notice also that get did not return the bare value. It returned an entry with three fields: the key, the value, and a versionstamp identifying the write that produced this value. The versionstamp looks like bookkeeping now and becomes the concurrency story later.
Reading a key that does not exist is not an error. The promise still resolves, and the entry reports absence with a null value and a null versionstamp together:
const missing = await kv.get(["no-such-key"]);
console.log(missing.value); // null
console.log(missing.versionstamp); // null
There is nothing to catch and no existence check to make first; absence is just one of the answers a read can give. kv.delete(key) makes a key absent, and deleting an already-absent key is fine:
await kv.delete(["greeting"]);
One caution on types: the <string> in kv.get<string> is a claim, not a check. The database stores whatever it was given and does not know your types, so the parameter only tells the compiler what you believe is there. Leave it off and value is typed unknown, which is the honest default at trust boundaries.
kv.getMany(keys) reads up to ten keys in one call and, importantly, from one consistent moment, where ten separate get calls would be ten separate observations with room for writes to land in between:
const [retries, verbose] = await kv.getMany<[number, boolean]>([
["config", "retries"],
["config", "verbose"],
]);
Listing
kv.list(selector) walks a range of keys in sorted order and yields full entries. You consume it with for await:
await kv.set(["users", "robin"], { name: "Robin" });
await kv.set(["users", "ada"], { name: "Ada" });
for await (const entry of kv.list({ prefix: ["users"] })) {
console.log(entry.key, entry.value);
}
// ["users", "ada"] { name: "Ada" }
// ["users", "robin"] { name: "Robin" }
The entries arrive in key order regardless of insertion order. Two details about prefixes surprise almost everyone:
- A prefix selects keys that are strictly longer than it.
{ prefix: ["users"] }matches["users", "ada"]but not["users"]itself, so a parent key and its children can coexist without the parent appearing in every listing. - Matching is by whole parts, not by string prefix.
["users"]does not match["usersextra"], because those are different parts, not a string and its extension.
Selectors can also be explicit ranges: { start, end } walks from one key up to but not including another, and a prefix can carry a start or end bound inside it. The options do the practical work: limit caps the walk, and reverse: true walks backward, so "the latest ten log entries" is one call when the last key part encodes time:
for await (const entry of kv.list({ prefix: ["logs"] }, { limit: 10, reverse: true })) {
// newest first, assuming ["logs", timestamp] keys
}
For pagination across requests, the iterator exposes a cursor string after each batch, and passing it back resumes the walk where it stopped.
Atomic operations
Everything so far was a single read or a single write. kv.atomic() builds a transaction: any number of mutations that commit together or not at all, guarded by any number of checks.
The simplest use is keeping two keys consistent. Because there are no queries, a lookup by email needs its own key, and that key must never disagree with the user record:
const user = { id: 1, email: "robin@example.com", name: "Robin" };
const result = await kv.atomic()
.check({ key: ["users", user.id], versionstamp: null })
.check({ key: ["users_by_email", user.email], versionstamp: null })
.set(["users", user.id], user)
.set(["users_by_email", user.email], user.id)
.commit();
if (!result.ok) {
// a user with this id or email already exists; nothing was written
}
A check makes the commit conditional. It names a key and the versionstamp you expect it to have, and versionstamp: null means "I expect this key not to exist". If any check fails, no mutation happens and commit resolves to { ok: false } rather than throwing, because losing a race is a normal outcome, not an exception.
The same mechanism solves the classic read-modify-write race. Read a value, and the entry you get back carries the versionstamp; check against it when writing, and the write only lands if nobody else wrote in between:
let committed = false;
while (!committed) {
const entry = await kv.get<number>(["balance"]);
if (entry.value === null) break;
const result = await kv.atomic()
.check(entry) // an entry is exactly { key, versionstamp }, by design
.set(["balance"], entry.value - 30)
.commit();
committed = result.ok;
}
If the check fails, the loop reads again and retries with fresh data. This is optimistic concurrency: no locks, just proof that what you read is still current.
Counters deserve better than that loop, because every increment invalidates every other writer's versionstamp and forces retries. The sum mutation adds to a key without reading it, treats a missing key as zero, and never conflicts with other sums, since additions commute:
await kv.atomic().sum(["visits"], 1n).commit();
const visits = await kv.get<Deno.KvU64>(["visits"]);
console.log(visits.value?.value); // 1n
The stored value is a Deno.KvU64, a wrapper marking the entry as an unsigned 64-bit integer; its value property holds the bigint inside. min and max are its siblings for low-water and high-water marks.
The key space
Now the part that repays study. All querying in Deno KV is key design, so the exact rules of the key space decide what your application can ask for.
Keys are sequences of typed parts
A key is an array of parts, and each part is one of exactly five types: string, number, bigint, boolean, or Uint8Array. Anything else, an object, a Date, a nested array, is rejected with a TypeError at write time. Some realistic keys:
["users", 42, "profile"]
["posts", "2023-04-23", "comments"]
["products", "electronics", "smartphones", "apple"]
["orders", 1001, "shipping", "tracking"]
The convention these examples share is worth adopting: string parts describe what kind of thing and which sub-resource, while the typed parts in between identify which one. The key carries structure; the value carries the data.
Why arrays instead of a delimited string like "users:42:profile"? Injection. With string keys, a user who names themselves "42:profile" collides with the structure of the key itself, and every read that concatenates has a potential attack in it. Parts have invisible boundaries in the encoded form, so no value a part can hold ever changes how many parts the key has.
Parts keep their type
Two keys are the same key only if their parts match in both value and type. These are three different keys, holding three independent values:
await kv.set(["user", 1], "by number");
await kv.set(["user", "1"], "by string");
await kv.set(["user", 1n], "by bigint");
This bites people who build keys from parsed input: url.searchParams.get("id") gives you "42", and if you sometimes write ["users", 42] and sometimes ["users", "42"], you have two disjoint sets of users. Pick the type each position uses and convert at the boundary, every time.
Keys are ordered, and the order is the query language
Keys sort part by part, left to right, like words in a dictionary. Within a single type, the order is what you would hope:
Uint8Arrayparts compare by their bytes.stringparts compare by their UTF-8 bytes.numberparts compare numerically, from-Infinitythrough the finite numbers toInfinity, withNaNsorting after everything.bigintparts compare mathematically.booleanparts putfalsebeforetrue.
Across different types, parts group by type first, and value never overcomes type: on the current runtime the groups sort as Uint8Array, then string, then bigint, then number, then boolean, so 0n sorts before -1 because every bigint comes before every number. (The official documentation's type list disagrees with itself on the bigint/number order; the order given here is what both its own worked example and the runtime actually do.)
The honest advice is to never depend on the cross-type order. Give each key position a single type, and the within-type rules carry you. The rule with real daily consequences is the number one:
// number parts: numeric order
["scores", 2.5], ["scores", 9], ["scores", 10]
// string parts: byte order
["scores", "10"], ["scores", "2.5"], ["scores", "9"]
"10" before "9" is the same trap string sorting sets everywhere, and in stores whose keys are only strings people zero-pad numbers to survive it. Here the fix is to store the number as a number, and range queries like "scores between 5 and 15" become plain start/end selectors.
Because ordering is the query language, key design works backward from reads. "Latest posts per author" wants ["posts", authorId, timestamp], so a prefix on the author walks their posts in time order and reverse serves the feed. If you also need "latest posts overall", that is a second read pattern, so it gets a second key, maintained in the same atomic commit as the first.
Values
A value can be almost any JavaScript value, serialized with the structured clone algorithm: undefined, null, booleans, numbers, strings, bigints, Uint8Array, arrays, plain objects, Map, Set, Date, RegExp, and Deno.KvU64. Circular references inside a value are fine. What cannot be stored is behavior: functions, symbols, and class instances with their prototypes do not survive serialization, so store data and reconstruct behavior after reading.
Two consequences of cloning are easy to miss. The store keeps a snapshot, so mutating an object after set does not change what was written. And every read returns a fresh copy, so mutating what get gave you changes nothing in the database and nothing another read sees. If you want a change to stick, write it.
Versionstamps
Every successful commit mints a versionstamp, and every key written in that commit gets the same one. Reading a key returns the versionstamp of the write that produced its current value. Three properties make versionstamps the store's concurrency primitive:
- They increase monotonically across the whole database, so newer writes always compare greater.
- They are strings that compare correctly with ordinary
<and>, no parsing needed. - They change on every write to a key, so "the versionstamp I read is still the versionstamp there" means "nobody has written in between", which is exactly what
checkverifies.
Treat the contents as opaque. The format is an implementation detail of the backend; the guarantees above are the contract.
Limits
The key space is bounded, and the bounds say what the store is for:
| Limit | Value |
|---|---|
| Key size | 2 KiB, encoded |
| Value size | 64 KiB, serialized |
Keys per getMany | 10 |
Keys per watch | 10 |
Both size limits are measured after encoding, so usable payloads are slightly smaller than the round numbers. An entry is a record, not a file: anything that feels like a file belongs on the file system or in object storage, with KV holding its path or URL as a value.
Beyond the basics
Three more features round out the API, each with a contract worth reading twice:
- Expiry.
kv.set(key, value, { expireIn: ms })marks an entry for deletion after a deadline. Deletion is not punctual; locally an expired value can remain readable well past its deadline, because cleanup runs on the store's own cadence. Use expiry as hygiene for sessions and caches, and if correctness requires "unusable after the deadline", store the deadline in the value and check the clock when reading. - Watch.
kv.watch(keys)returns aReadableStreamthat immediately reports the current state of up to ten keys and then emits again when they change. After a burst of writes you are guaranteed to see the latest state, not every intermediate one, which suits live views and cache invalidation but not audit trails. - Queues.
kv.enqueue(value)commits a message andkv.listenQueue(handler)registers the process's handler for delivery. Delivery is at least once, so handlers must tolerate duplicates;delayschedules messages for later, andkeysIfUndeliveredparks a message under known keys if every retry fails. An atomic operation can alsoenqueue, pairing a state change with its follow-up work in one commit.
On Deno Deploy, the same database is replicated across regions. Reads gain a consistency option ("strong" or "eventual") for trading freshness against latency, queues deliver across isolates, and watches fire on writes made anywhere. None of the code in this guide changes.
For a test-driven treatment of the same ground, where every claim above is pinned by a running assertion, see the Deno KV reference.