Skip to content

Production Data Systems Handbook / Chapter 15

Key-Value, Document, and Wide-Column Stores

Design access-pattern-first stores by making keys, denormalization, partition heat, growth, and reconciliation explicit.

One Room, One Key, Too Much Traffic

Consider a chat product that stores messages by conversation. The common read is wonderfully precise: given a conversation, return its newest messages in order. A wide-column table seems made for it. Use the conversation ID as the partition key, order rows by message sequence, and the database can find one partition instead of searching the entire history.

Then the product hosts a live event. One room attracts hundreds of thousands of participants. Nearly every write now lands on the same partition because the very locality that made reads cheap also concentrates the load. The cluster has spare capacity, yet this room times out. Adding nodes does little if one ordered stream still has one destination.

This is the bargain behind key-value, document, and wide-column stores. They can offer predictable keyed access, sparse or flexible records, horizontal distribution, and high throughput because the application commits to particular paths through the data. The database has less need to discover an efficient plan for an arbitrary question. The application, however, must choose the keys, aggregate boundaries, duplication rules, and repair paths before the workload makes those choices expensive to change.

“NoSQL scales” conceals that bargain. The useful question is: which reads and writes become simple because their access paths are known, and which guarantees or questions become application work?

Three Shapes, Three Contracts

The three families overlap, and products often blur their boundaries, but each gives the application a different unit to reason about.

A key-value store retrieves a value by an exact key. Sessions, idempotency results, cache entries, feature-flag materializations, rate-limit buckets, and one-object read models often fit this contract. The value may be structured internally, but the main path remains “get or conditionally update this key.” The design has to settle expiration, maximum value size, concurrent update behavior, and what happens when a few keys receive most of the traffic. A global counter is a valid key-value shape and frequently a poor distributed workload.

A document store makes an aggregate the natural unit. A draft, profile, catalog item, configuration, form submission, or order view can carry nested fields that are commonly read together. This removes joins from that path, not from the underlying reasoning. A small aggregate forces the application to coordinate several documents. An oversized aggregate turns unrelated writers into competitors and lets histories, attachments, or child collections grow without bound. Flexible fields ease some changes, but mixed document versions still require validators, compatibility rules, and migrations.

A wide-column store exposes the access path most plainly. A partition key places related rows together; clustering columns order them within that partition. Messages by conversation and time, readings by device and timestamp, or events by tenant and hour can support high write rates and efficient bounded range reads. A table is often built for one query rather than around one normalized entity. That efficiency depends on knowing which key the query supplies and how large and hot each partition may become.

A product can use all three shapes without making any of them its universal database. Chat may keep message identity behind an exact key, conversation settings in a document, and ordered message history in a wide-column table. The labels matter less than the contract of each path: its key, unit of atomicity, growth bound, consistency need, and recovery source.

Follow the Hot Room

Return to the chat history. With conversation_id as the partition key and message_sequence as the clustering key, an ordinary room has excellent locality. A recent-history request reads one ordered slice. Appends go to the same logical stream. Per-conversation ordering is easy to state.

The live event exposes three separate pressures.

First, the current partition receives a disproportionate write rate. Average CPU and cluster-wide latency can remain healthy while that partition queues work. The dashboard therefore needs per-partition or heavy-key heat, not only fleet averages.

Second, the partition grows. Retention, repair, compaction, export, and deletion all have to traverse it. A design that survives normal reads may still fail its maintenance workload.

Third, one ordered stream limits the remedies. Adding a time bucket—perhaps conversation_id + hour—bounds growth and allows old buckets to settle, but the present hour may still be hot. Adding synthetic shards spreads writes within the hour, but recent-history reads must fan out, merge the shard results by sequence, and decide how much disorder they tolerate. Relaxing global room order can distribute writes further, but that is a product and correctness decision, not a key trick.

Every partition remedy moves cost. Hashing or synthetic sharding spreads heat but weakens locality. Time buckets bound size but complicate reads across a boundary. Tenant buckets isolate a large customer but affect support, billing, authorization, and export paths. The right key is not the one with the neatest schema. It is the one whose worst credible distribution gives reads, writes, repair, and deletion an acceptable place to run.

A Copy Needs an Owner

Access-pattern-first designs usually duplicate data. Chat history might copy the sender’s display name and avatar URL into each message read model. A moderation queue might copy message text and room visibility. An unread view might copy the latest sequence seen by each user. These copies save joins and make known reads cheap.

Duplication becomes dangerous when the system cannot say what a copied value means. The display name embedded in an old message might be a historical snapshot; if so, a later profile rename must not rewrite it. An avatar might be current presentation data and refresh asynchronously. Moderation status should have one authoritative owner even if several queues display it. A privacy deletion must reach every copy regardless of which one normally serves reads.

For each duplicated fact, decide:

  • which system owns the authoritative value;
  • whether the copy is historical, current within a freshness target, or recomputed;
  • how changes and deletes propagate;
  • how drift is detected;
  • which source can rebuild the copy.

Without those decisions, denormalization does not remove consistency work. It hides the work until support, audit, migration, or incident response needs the copies to agree.

Document aggregates make the same question visible at their boundary. An order document may contain line items, the shipping address used at checkout, a customer display name, and summaries of payment and fulfillment. The address and product name may be deliberate historical snapshots. Payment authorization remains payment-system truth. Fulfillment state remains derived from shipment events. Putting these fields in one document improves the support read only if their different ownership and refresh rules remain legible.

Put the Invariant Where It Fits

These stores often provide strong operations within a limited scope: one key, item, document, partition, or explicitly bounded transaction. Use that scope rather than assuming it is either useless or equivalent to an arbitrary relational transaction.

“Update this session only if its version still matches” can use a conditional write on one key. “Move this draft from open to submitted only once” may fit one document. “Append the next message sequence for this conversation” may fit one partition, subject to the store’s concurrency model.

“Reserve inventory, charge payment, create a shipment, and mark the order complete exactly once” does not become one invariant merely because an order document contains summaries of all four facts. Nor does “a user stays below a global quota across every tenant” naturally fit records partitioned by tenant. Privacy deletion is also cross-record work once personal data has been copied into messages, moderation queues, search indexes, exports, and backups.

When the store does not enforce an invariant, the substitute must be concrete: idempotency keys, conditional writes, version checks, durable events, compensation, reconciliation, and an operator-visible repair queue. For important multi-entity rules, keeping a relational authority and projecting keyed read models from it is often simpler than rebuilding transactional coordination in application code.

The Workload Includes Cleanup and Change

Many designs are tested with fresh records and their first query. Production adds time.

Deletes, expirations, and overwrites can leave tombstones or obsolete versions for the storage engine to compact later. A high-churn workload may accept writes quickly while cleanup debt raises read latency, consumes disk, or lengthens repair. Measure expiry and delete behavior at production volume; do not treat retention as a background detail.

Documents and partitions also need explicit growth bounds. A document that accumulates every audit event or attachment eventually becomes expensive to read, update, migrate, and recover. A device partition that retains years of readings makes an ordinary range query coexist with an extraordinary repair unit. Split unbounded collections, bucket histories, and state retention before the first convenient append becomes a permanent data model.

Query change is the other slow pressure. Version one fetches by ID. Version two filters by status, sorts by last activity, searches text, exports a customer’s history, and locates every copy affected by a privacy request. Secondary indexes can support planned alternate paths, but they do not restore unlimited query freedom: their partitioning, consistency, write amplification, cardinality, and coverage still need review.

If a plausible future question has no keyed path, make the consequence explicit. Add a derived model, keep an authoritative relational path, schedule a bounded batch workflow, or reject the requirement. “We will scan everything” and “we will use whichever index exists” are not architecture.

Write the Access-Pattern Contract

An access-pattern table earns its rectangular form because a reviewer needs to compare every important path across the same attributes. Fill it with expected numbers and named guarantees, not “high,” “fast,” or “eventual.”

Query or write Key and bounded result Order or range Consistency and atomic scope Growth or heat risk Fallback, repair, or rebuild
Fetch a session session_id; one value None Conditional update on one key; logout staleness is bounded Expiration churn; shared service-account keys Expire or revoke, reissue, and measure revocation lag
Store an idempotency result client_id + request_id; one value None Create-if-absent must cover the side-effect contract Retry storms; retention too short or too long Reconcile against authoritative operation state
Read an order support view order_id; one document None Aggregate update only; payment and fulfillment remain external authorities Document growth; copied summaries drift Rebuild from order, payment, and fulfillment events
List recent messages conversation_id + bucket; bounded rows Descending message sequence Ordered within the chosen partition scope Popular rooms and current buckets become hot Split/shard exceptional rooms; merge reads; rebuild from message log
Read unread state user_id + conversation_id; one value None Conditional marker or repairable derived counter Fan-out on large rooms; retries cause drift Recompute from message sequence and read markers
Delete customer data Customer identity plus a registry of every copy Crosses paths and partitions Completion means every required system confirms deletion Missed copies; tombstone and compaction load Durable deletion ledger, verification queries, and repair queue

Before approval, challenge the table with peak and maintenance workloads. Which tenant, room, device, counter, or time bucket dominates? How large can one document or partition become? What happens during a retry storm, bulk import, backfill, expiry wave, privacy deletion, or region recovery? Which copied fields can drift, and how quickly will anyone know? Can the team restore the authoritative facts and rebuild every derived path?

The table should also expose absence. If moderation, export, audit, support, or deletion requires a scan that the design omitted, the missing row is evidence—not an inconvenience to hide after product selection.

Review the Design Under Skew

Model the chat history now. Choose the partition key, clustering order, bucket size, retention window, and exact recent-read path. Give an ordinary room and a large public room expected message rates and history sizes. Then run four disturbances through the design: a live event, a bulk import, a room deletion, and repair after a missed write.

For every mitigation, write down the cost it moves elsewhere. If you shard the current bucket, show how reads merge. If you relax ordering, say what users can observe. If you copy data into moderation or search paths, name the owner and deletion proof. If a relational source or durable log remains authoritative, show which keyed views can be rebuilt from it.

Then take an order model and draw one document boundary. Mark each nested field as authoritative, historical snapshot, or refreshable copy. Split any collection that can grow without bound. Identify the first invariant that crosses the document, and decide whether coordination, compensation, or a different source of truth should own it.

These systems are strongest when the workload can state its paths before choosing its products. A well-designed key makes the common operation small and predictable. A production design also knows who pays when the key is hot, the record grows, the question changes, or the copies disagree.