JetStream KV looks like a distributed HashMap. It is not.
A KV bucket is a specially configured JetStream stream (KV_<bucket>). Each key is a NATS subject ($KV.<bucket>.<key>). The stored value is the latest message on that subject. For event-driven systems, the productive mental model is: watch for changes, design keys as hierarchies, treat entity types as key prefixes — not as separate buckets.
There is no WHERE clause. You query by key, key-prefix wildcards, or application-maintained index keys.
Prerequisites: Core NATS (Part 1), JetStream (Part 2). Researched against docs.nats.io and nats.go kv.go on July 6, 2026.
TL;DR
- Bucket
X→ streamKV_X→ subjects$KV.X.> - Key
order.42→ subject$KV.orders.order.42→ latest message = current value watchis the native reactive read path — it delivers PUT, DELETE, and PURGE events in real time- Entity types belong in key prefixes (
orders-v1.<id>), not in separate buckets - KV guarantees monotonic reads/writes per key; it does not guarantee read-your-writes on follower gets
- Secondary indexes are an application pattern — maintain index keys on write, clean up on delete
What You Will Learn Here
- How buckets map to streams and subjects
- Why KV is a change stream, not a queryable table
- Key-prefix design for multi-entity buckets
- Querying without SQL: wildcards,
ListKeysFiltered, secondary-index pattern - Watch lifecycle: snapshot, sentinel, live updates
- KV selection criteria — when to use it and when to pick something else
- A concrete implementation with watch and index keys
Buckets Are Streams
When you create a KV bucket, JetStream materializes a stream:
nats kv add app-objects --history 5 --ttl 1h
nats kv status app-objects
# JetStream Stream: KV_app-objects
nats stream info KV_app-objects
# Subjects: $KV.app-objects.>
| Concept | Implementation |
|---|---|
| Bucket name | app-objects |
| Underlying stream | KV_app-objects |
| Subject pattern | $KV.app-objects.> |
| Individual key | $KV.app-objects.<key> |
| Latest value | Latest message on that subject (MaxMsgsPerSubject, default 1) |
kv.Put("orders-v1.abc-123", json)
│
v
PUBLISH → $KV.app-objects.orders-v1.abc-123
│
v
Stream KV_app-objects (retention, replicas, limits apply)
│
v
kv.Get("orders-v1.abc-123") = last message on that subject
Key design rule: One bucket is one consistency domain. Put entity types in key prefixes, not in separate buckets — unless you have a strong isolation reason.
Bucket: app-objects (one stream, shared replication and limits)
orders-v1.{orderId} ← entity type as prefix
customers-v1.{customerId}
idx.status.active.{orderId} ← secondary index (app-maintained)
Watch all orders:
Subscribe / Watch: $KV.app-objects.orders-v1.>
This uses Core NATS wildcard rules from Part 1: > matches all trailing tokens.
KV as a Change Stream
The most important API for reactive systems is watch, not get.
watch subscribes to changes on matching keys. It uses an ordered consumer and delivers:
- Snapshot — last value per matching key (unless
UpdatesOnly) - Nil sentinel — signals initial snapshot is complete
- Live updates — PUT, DELETE, PURGE as they happen
watcher, _ := kv.Watch(ctx, "orders-v1.>", jetstream.UpdatesOnly())
defer watcher.Stop()
for entry := range watcher.Updates() {
if entry == nil {
continue // initial snapshot complete
}
switch entry.Operation() {
case jetstream.KeyValuePut:
log.Printf("PUT %s rev=%d", entry.Key(), entry.Revision())
projectOrder(entry.Key(), entry.Value())
case jetstream.KeyValueDelete, jetstream.KeyValuePurge:
log.Printf("DEL %s rev=%d", entry.Key(), entry.Revision())
removeOrder(entry.Key())
}
}
JetStream KV Mental Model
========================
kv.Put(key, val) ──► subject $KV.bucket.key ──► stream KV_bucket
│
┌─────────────────────────────────────────┤
│ │
v v
kv.Get(key) kv.Watch("prefix.>")
"latest value view" "change stream view"
(point read) (reactive projection)
Editorial judgment: If your service polls KV in a loop, you are fighting the abstraction. Watch is the native integration point for projectors and cache warmers.
Consistency model
Per official KV documentation:
- KV guarantees immediate consistency for monotonic writes and monotonic reads per key
- KV does not guarantee read-your-writes on direct
getrequests served by followers or mirrors - For stricter reads, route gets to the stream leader
Cross-store consistency with a primary database or any external source of truth remains eventual — KV is a projection, not truth.
Querying Without WHERE
KV has no SQL-style query API. You have three practical options:
1. Key-prefix wildcards
Design keys as dot-separated hierarchies and watch or list by prefix:
// All active orders — if keys are designed as orders-v1.{status}.{id}
watcher, _ := kv.Watch(ctx, "orders-v1.*")
// nats.js
const keys = await kv.keys("orders-v1.*");
2. ListKeysFiltered (Go jetstream package)
Go’s jetstream package (December 2024+) provides filtered key listing:
lister, _ := kv.ListKeysFiltered(ctx, "orders-v1.*")
for key := range lister.Keys() {
entry, _ := kv.Get(ctx, key)
process(entry.Value())
}
lister.Stop()
Note: This API is Go-centric as of July 2026. Portable alternatives use Watch with MetaOnly or keys() with wildcards in nats.js.
3. Secondary-index pattern (application-maintained)
Maintain extra keys on write that act as indexes:
func upsertOrder(ctx context.Context, kv jetstream.KeyValue, o Order) error {
id := o.ID
payload, _ := json.Marshal(o)
// Primary record
if _, err := kv.Put(ctx, "orders-v1."+id, payload); err != nil {
return err
}
// Index keys — your "WHERE clause"
idxKey := fmt.Sprintf("idx.status.%s.%s", o.Status, id)
if _, err := kv.Put(ctx, idxKey, []byte(id)); err != nil {
return err
}
return nil
}
// Query active orders
lister, _ := kv.ListKeysFiltered(ctx, "idx.status.active.*")
This is an application pattern, not a NATS feature. You must:
- Update index keys when status changes (delete old index key, write new one)
- Clean up index keys on delete
- Make writers idempotent — duplicate puts should not leave stale index keys
Concrete Implementation: Watch-Driven Cache Warmer
A service that keeps an in-memory cache warm from KV changes:
import { connect, StringCodec } from "nats";
const nc = await connect({ servers: process.env.NATS_URL });
const js = nc.jetstream();
const kv = await js.views.kv("app-objects");
const cache = new Map<string, object>();
const sc = StringCodec();
const watcher = await kv.watch({ key: "orders-v1.>" });
(async () => {
for await (const entry of watcher) {
if (entry === null) {
console.log("snapshot complete, cache size:", cache.size);
continue;
}
const key = entry.key;
if (entry.operation === "DEL" || entry.operation === "PURGE") {
cache.delete(key);
continue;
}
cache.set(key, JSON.parse(sc.decode(entry.value)));
}
})();
// Fast read path — from memory, not KV
function getOrder(id: string): object | undefined {
return cache.get(`orders-v1.${id}`);
}
Pair this with optimistic locking for coordination:
// Leader election / lock — only one worker runs the export
rev, err := kv.Create(ctx, "lock.export."+jobID, []byte(workerID))
if err != nil {
return ErrAlreadyRunning
}
defer kv.Delete(ctx, "lock.export."+jobID)
KV Selection Criteria
Reach for KV when you need shared, persistent, watchable state with keyed access and a reactive read path (watch). Pick a database, Object Store, or raw stream when the workload needs relational queries, large blobs, unbounded history, or authoritative entity storage.
| Use KV for | Use something else for |
|---|---|
| Shared config and feature flags | Relational queries across arbitrary fields |
| Distributed locks and CAS coordination | Full-text search (→ OpenSearch) |
| Low-latency keyed reads of small blobs | Large file storage (→ Object Store) |
| Watch-driven projections and cache warming | Source of truth for business entities |
| Cross-service shared state with history ≤ 64 revisions | Audit log with unbounded history (→ raw stream) |
KV holds projections and coordination state — not ad-hoc query surfaces or domain truth. Your primary database or domain service owns truth; KV is the watchable copy other services subscribe to.
Common Mistakes
- One bucket per entity type. Creates operational overhead. Use key prefixes in one bucket unless isolation requires separation.
- Treating
getas the primary integration. Watch is the reactive path;getis for point reads. - Assuming SQL semantics. No joins, no
WHERE, no transactions across keys. - Ignoring index maintenance. Secondary-index keys go stale without cleanup on update/delete.
- Confusing KV with truth. KV is a projection or cache layer. Your primary database or domain service owns truth — see Part 4.
Next in series: EDA with NATS: One Truth, Many Read Models — CQRS Projections
Sources
- Key/Value Store — bucket semantics, watch, consistency
- Using KV (developer guide) — put, get, delete, keys, history
- KV Walkthrough — bucket → stream mapping
- Streams — MaxMsgsPerSubject — per-key retention
- nats-io/nats.go — jetstream/kv.go —
$KVsubject templates,ListKeysFiltered, watch options - Core NATS (Part 1) — wildcard rules for
$KV.*.>subscriptions