Wide-column stores for huge write loads
Open in the interactive app: practice, quiz, mock interview →In one minute
- A wide column database stores rows grouped into partitions. A partition key decides which nodes own the data; a clustering key sorts rows inside the partition on disk.
- The model is a sorted, distributed map:
(partition key, clustering key, column) -> (value, timestamp). Each row can have a different set of columns, and missing columns cost nothing. - Bigtable and HBase are range-partitioned with a single writer per tablet; Cassandra and ScyllaDB hash-partition with leaderless replication and tunable consistency.
- You design tables from queries, not from entities. One query, one table, and denormalization is expected.
- The storage engine is an LSM tree, so writes are cheap and reads depend on compaction health.
How it works
Data model
Think of each table as a two-level map. The outer level is the partition key, hashed (Cassandra) or range-sorted (Bigtable, HBase) to place it on nodes. The inner level is a sorted map from clustering key to a set of cells. A query that names a partition and a clustering range reads one contiguous slice from one replica set, which is the fast path. A query that does not name the partition must ask every node.
Every cell carries a write timestamp. Concurrent writes to the same cell resolve by last write wins on that timestamp (Cassandra docs), and deletes write a tombstone with a timestamp that shadows older cells.
Two architectures
Bigtable and HBase. The key space is split into contiguous ranges called tablets or regions, each served by exactly one server at a time. A master assigns tablets and splits them as they grow. Data and logs live in a distributed file system (Colossus or HDFS), so a server failure means another server reopens the tablet from shared storage; in Bigtable only metadata moves to the replacement node, which is why recovery is fast (Google Cloud docs). Bigtable now also accepts read-only GoogleSQL queries, but they run on the same nodes and should still filter on the row key (Google Cloud docs). Consistency per row is strong because one server owns each row.
Cassandra and ScyllaDB. Nodes sit on a consistent-hash ring with virtual nodes. Each partition is replicated to N nodes (usually 3), and any node can coordinate a request. The client chooses consistency per query: QUORUM means a majority (n/2 + 1) of replicas, so QUORUM writes plus QUORUM reads overlap on at least one replica (Cassandra docs), so reads see the latest acknowledged write; ONE is faster and weaker. Hinted handoff, read repair, and periodic anti-entropy repair push replicas back into agreement.
Write and read path
A write goes to a commit log and a memtable on each replica, then flushes to SSTables, exactly as in any LSM tree. A read gathers cells for the requested partition from the memtable and the SSTables that might hold it (Bloom filters prune the rest), merges by timestamp, and drops shadowed cells.
Modelling rules
- Choose the partition key so that one query reads one partition, and partitions stay bounded, ideally under 100 MB and well under 100,000 rows.
- Use the clustering key for the sort you need, such as
ORDER BY created_at DESC. - Bucket unbounded series by time:
(sensor_id, day)rather thansensor_id. - Write the same data to several tables when you have several query shapes. Writes are cheap; scatter-gather reads are not.
Down the stack
Layer 1The contract
Cassandra promises, per partition, a sorted set of rows by clustering key and per-cell last-write-wins by timestamp. A write at consistency level QUORUM returns success once a majority of the partition's replicas have appended it to their commit log and memtable; a QUORUM read then sees it, provided clocks that generate timestamps are sane. There is no isolation across partitions. A logged batch guarantees that all its mutations will eventually apply, not that readers never see a partial batch. Compare-and-set (IF NOT EXISTS) runs Paxos, costing several round trips. Bigtable and HBase instead promise strongly consistent single-row reads and writes, because exactly one server owns each row at a time.
Layer 2The mechanism
A Cassandra mutation is serialised and appended to the active commit log segment (32 MiB by default), then applied to the memtable for its table. The default commitlog_sync: periodic fsyncs the commit log every 10 seconds rather than per write (Cassandra docs), so a single node's power loss can drop recent acknowledged writes. Cassandra accepts this because the write also exists on other replicas; batch and group modes fsync before acknowledging at a latency cost.
When a memtable flushes, it becomes an SSTable made of several component files: Data.db holds partitions in token order, compressed with LZ4 in chunks (16 KiB by default in recent versions, Cassandra docs); CompressionInfo.db maps uncompressed offsets to chunk positions; Filter.db is the partition Bloom filter; Statistics.db holds min and max timestamps and tombstone counts; and a partition index (Index.db plus Summary.db in the classic format, or a trie-based Partitions.db and Rows.db in the BTI format added in 5.0, which you opt into with sstable.selected_format: bti) maps partition keys to offsets in Data.db (Cassandra docs). Compaction strategies decide what merges with what: size-tiered (still the table default), leveled, time-window for time series, and the unified strategy added in 5.0 (Cassandra 5.0 features). Tombstones survive at least gc_grace_seconds (864,000 seconds, 10 days, by default, Cassandra docs) so that repair can propagate the delete before it is purged; otherwise a replica that missed the delete would resurrect the data.
Layer 3The machine
The write path is one sequential append plus an in-memory insert, which is why Cassandra sustains high write rates on modest hardware. The read path pays the costs. For each SSTable that might hold the partition, the node checks the in-memory Bloom filter, looks up the partition index, then reads and decompresses at least one whole compression chunk, even to return a 100-byte row. Small chunks reduce that read amplification; large chunks compress better. Partitions are placed by a 64-bit Murmur3 token, so related keys scatter across the ring by design.
The JVM is the other machine-level cost. Memtables, caches, and per-request objects generate garbage, and garbage collection pauses show up directly in p99 latency; Cassandra moved Bloom filters, compression metadata, and optionally memtables off-heap to reduce this. ScyllaDB rewrote the engine in C++ on Seastar, pinning one shard per CPU core with no shared memory between cores, and does its own caching with direct I/O instead of relying on the OS page cache.
Why it's built this way
- Writes must be cheap at very high rates, so each one is a sequential log append plus a memory insert, and on-disk files are immutable.
- Immutable files mean updates and deletes are new versions, so every cell carries a timestamp and deletes are tombstones.
- Any replica may miss a write, so tombstones must outlive repair windows or deleted data comes back.
- Reads must avoid scanning every node and file, so the partition key routes a query to a few replicas and Bloom filters prune SSTables.
- Per-write fsync on every node is expensive and replication already provides redundancy, so the default leans on replicas for durability.
Diagram
flowchart LR
C[Client] --> CO["Coordinator node"]
CO -->|"hash(partition key)"| RING{"Token ring"}
RING --> R1["Replica A"]
RING --> R2["Replica B"]
RING --> R3["Replica C"]
R1 --> P1["Partition: user 42"]
P1 --> ROWS["Rows sorted by clustering key"]
ROWS --> CELLS["Cells: value + timestamp"]Worked example
A messaging app stores 2 billion messages per day, each about 300 bytes, and serves "last 50 messages in this conversation" at 150,000 reads per second.
The table is keyed PRIMARY KEY ((conversation_id, month), sent_at DESC, message_id). A read names the partition and takes the first 50 clustering rows, so it touches one partition on 2 of 3 replicas at LOCAL_QUORUM. Monthly bucketing caps a busy group chat at a few hundred thousand rows. Without it, a five-year-old company channel would grow into a multi-gigabyte partition that is slow to read, repair, and compact.
Raw volume is 600 GB per day; with replication factor 3 and LSM overhead, roughly 2.2 TB per day of disk. At 4 TB usable per node and one year of retention, the cluster needs around 200 nodes before compression, closer to 100 with it. Writes at 23,000 per second average and 100,000 at peak are well within what 100 nodes absorb, since each Cassandra node handles tens of thousands of writes per second. The design constraint is disk and compaction, not CPU.
In the wild
- Monzo, 2019: new nodes own token ranges before they own data. Monzo added 6 nodes to a 21-node Cassandra cluster with
auto_bootstrap=false, expecting them to sit idle until data was streamed in. They took ownership of token ranges immediately and answered reads for that data with "not found", so balances and transactions looked wrong and payments failed; most customers recovered after about 2 hours, full recovery took far longer. A single-node test had not shown the quorum effect of six. Fixes included scale-up runbooks and alerts on spikes in missing rows (Monzo). - Discord, 2023: hot partitions and compaction on a 177-node cluster. Discord's messages cluster, partitioned by channel, grew from 12 Cassandra nodes in 2017 to 177. A busy channel's partition slowed quorum reads cluster-wide, and GC pauses and compaction backlogs added latency. Rust data services in front coalesced concurrent reads of one row, and a move to 72 ScyllaDB nodes cut p99 historical-read latency from 40 to 125 ms down to 15 ms; the last ranges were held up by tombstones until compacted (Discord).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Cassandra or ScyllaDB | Multi-region active-active, no single leader, linear write scale | Query-driven modelling, no joins, LWW conflicts | High write volume, known access patterns, multi-region |
| Bigtable or HBase | Strong per-row consistency, range scans, autosplit | Single owner per tablet, region failover pause | Large sorted scans, analytics-adjacent serving |
| Relational database | Joins, transactions, flexible queries | Harder to scale writes horizontally | Data fits a few nodes or needs ad hoc queries |
| Document database | Flexible nested documents, secondary indexes | Weaker at huge sorted time ranges | Entity-centric access with varied shapes |
Pitfalls in production
- Large partitions. One unbounded partition slows reads, repairs, and compaction on its replicas. Add a time or hash bucket to the partition key.
- Tombstone-heavy reads. Deleting many rows in a partition then scanning it forces reads over thousands of tombstones and can time out. Model queues differently and use TTL with time-window compaction.
- Skipping repair. Without regular anti-entropy repair, deleted data can reappear after
gc_grace_seconds. Run repair on a schedule shorter than the grace period. - Clock skew with LWW. A node with a fast clock makes its writes win. Run NTP carefully, and avoid read-modify-write patterns that rely on ordering.
- Secondary index or
ALLOW FILTERINGin hot paths. Both turn a single-partition read into a cluster-wide scan. Cassandra 5.0's storage-attached indexes (SAI) are cheaper to keep than the old indexes, but a query without the partition key still has the coordinator scan the token ring, and a high-cardinality column can mean a whole-cluster scan (Cassandra docs). For a hot path, create a second table instead.
Leadership lens
- Require a query list before schema review. A wide column schema without stated access patterns is not reviewable.
- Budget operational work: repairs, compaction strategy, node replacement, and capacity planning are recurring, not one-off.
- Decide consistency level per query and write it down, so nobody silently drops to
ONEunder load. - Plan for denormalization drift: when the same data lives in three tables, someone owns keeping them in sync.
Check yourself
Why does a query without the partition key perform badly in Cassandra?
Show answer
The coordinator cannot tell which nodes hold matching rows, so it must ask all of them and merge the results.
How do
QUORUMreads and writes give read-your-writes with replication factor 3?Show answer
Each touches at least 2 of 3 replicas, so any read set overlaps any write set on at least one replica holding the newest timestamp.
Why add a month to a conversation's partition key?
Show answer
It caps partition size, keeping reads, compaction, and repair cheap even for very active conversations.
What makes deleted data reappear in a leaderless store?
Show answer
A replica missed the tombstone, the tombstone was purged after the grace period, and repair later copied the old value back from that replica.
Sources and further study
Primary
- DeCandia et al., Dynamo: Amazon's Highly Available Key-value Store (SOSP 2007): the source of Cassandra's ring, quorums, hinted handoff and Merkle-tree repair.
- Jepsen: Cassandra (2013): an early test of last-write-wins and lightweight transactions that showed how timestamp conflict resolution loses writes; versions have moved on, the mechanism has not.
Systems docs
- Cassandra: storage engine: commit log settings, memtables and every SSTable component, including BTI.
- Cassandra: SAI FAQ: how storage-attached index queries fan out when the partition key is missing.
- Bigtable: overview: tablets on Colossus, nodes that hold only pointers, and fast failover.
Books
- Designing Data-Intensive Applications (2nd ed.), Martin Kleppmann and Chris Riccomini, Ch 6 "Replication" (leaderless replication) and Ch 7 "Sharding": quorums, sloppy quorums and key-range versus hash sharding.
- Database Internals, Alex Petrov, Ch 12 "Anti-Entropy and Dissemination": read repair, hinted handoff and Merkle trees in depth.
Other curricula
- donnemartin/system-design-primer: models a wide-column store as a nested map with timestamps and still mentions super column families, which CQL-era Cassandra no longer exposes.
- Azure Architecture Center: Understand data models: stresses up-front row key design and limited secondary indexes, and suggests a time-series database when time-window queries slow down.
- roadmap.sh: System Design: its "Wide Column Store" node gives a one-paragraph definition naming Cassandra and HBase for write-heavy workloads.
Watch
- BigTable: One Database to Rule Them All? (Jordan has no life): a walk through the Bigtable paper's design.
- How does Bigtable work? (Google Cloud Tech): a short overview of tablets, nodes, and storage separation.
Free resources
- Bigtable (OSDI 2006): the paper that defined the model.
- Cassandra (LADIS 2009): the original Cassandra design from Facebook.
- Cassandra architecture overview: official docs on the ring, replication, and storage engine.
- Bigtable schema design: row key design advice that applies to any range-partitioned store.
- Basic rules of Cassandra data modeling (DataStax): the query-first approach explained with examples.