Skip to main content

Wide-Column / Column-Family Stores

Level 2: Level 2: Data Storage & Modelinghard30 minwide-columncassandramodeling

Model one denormalized table per query: a partition key that spreads load and co-locates the read, clustering for the sort, time-bucketed bounded partitions, tunable quorum.

A distributed, sorted map of maps

Wide-column stores (Cassandra, ScyllaDB, HBase, Bigtable) are the write-heavy workhorse of internet-scale systems: message history, activity feeds, event logs, time-series, and anything ingesting a firehose of writes that must never block. The mental model is not a spreadsheet of columns. It is a distributed, sorted map of maps: data is grouped into partitions (spread across the cluster by a hash of the partition key), and within a partition, rows are sorted by clustering columns. Get those two concepts right and this family is straightforward; get them wrong and it falls over.

Why it is write-optimized. Cassandra uses an LSM tree. A write appends to a commit log and an in-memory memtable and returns immediately: no in-place update, no read-before-write. Memtables flush to immutable SSTables on disk, later merged by compaction. This makes writes extremely cheap and sequential, so a cluster absorbs millions of writes per second and scales writes linearly by adding nodes. The cost is read amplification (a read may touch several SSTables) and the operational weight of compaction.

Query-first modeling

There are no joins and essentially no ad-hoc queries. You cannot efficiently query a column that is not part of the key. So you model one denormalized table per access pattern: decide the query first, then build a table whose partition key and clustering columns serve exactly that query in a single partition read. If you have two query shapes, you write the data twice into two tables. This feels wasteful to a relational mind and is completely normal here; storage is cheap, and denormalization is the price of linear-scale reads and writes.

Check yourself
Your messages table is partitioned by 'conversation_id'. A new screen needs 'all messages sent by user X'. What is the idiomatic Cassandra move?

The partition key decides which node owns the data and must both spread load evenly and gather everything a query needs into one partition. The clustering columns decide the sort order inside the partition, so a "most recent first" query becomes a contiguous slice. For message history: partition by conversation_id so all of a conversation's messages live together, cluster by created_at descending so "load the latest 50" is the first 50 rows of the partition.

The two lethal failure modes

  1. Unbounded partitions. Partition purely by conversation_id and a chatty conversation grows forever. Cassandra partitions have practical limits (aim for under ~100MB and ~100k rows); an unbounded partition eventually causes slow reads, GC pressure, and node instability. The fix is time-bucketing: make the partition key composite, (conversation_id, month), so each partition is bounded and old buckets age out.
  2. Hot partitions. A celebrity conversation or viral thread concentrates traffic on the one node owning that partition. Mitigate with sub-partitioning: add a bucket to the key ((conversation_id, bucket) where bucket is 0..N) to spread a hot entity across N partitions, at the cost of a scatter-gather read.
Check yourself

Diagnose each situation as one of the two lethal partition failure modes.

A years-old group chat partitioned only by 'conversation_id' keeps growing
A celebrity AMA thread is read by millions of users at once
An IoT sensor appends readings forever to a partition keyed by 'device_id'
Every like on a viral post increments counters in one post partition

Consistency is tunable per query. Cassandra is a Dynamo-style AP system with tunable consistency: you choose how many replicas must acknowledge. ONE (fast, may read stale), QUORUM (majority). If reads and writes both use QUORUM on replication factor 3, read-quorum (2) plus write-quorum (2) overlap by at least one replica, so a read always sees the latest acknowledged write (read-your-writes freshness) for that key while tolerating one node down. That is quorum consistency, not linearizability: the overlap does not order concurrent writes, which can land on different quorums and produce conflicting versions that still need reconciling.

Interview nuance: The classic question is "why not just add a secondary index in Cassandra?" Answer: Cassandra secondary indexes query across all partitions (a scatter-gather that does not scale) and are an anti-pattern for high-cardinality columns; the idiomatic solution is a second denormalized table, not an index.

Table
PRIMARY KEY ((conversation_id, month), created_at DESC) laid out physically: the partition key picks the owning node and gathers the conversation, the clustering column pre-sorts it, so 'load the latest 50' is the first 50 rows of the current-month partition, one contiguous slice on one node.
partition key (conversation_id, month)owning noderowcreated_at (clustering, DESC)what it shows
(conv_42, 2026-07)node B107-23 09:14newest message: 'latest 50' starts here
(conv_42, 2026-07)node B207-23 08:57rows are pre-sorted; the slice just reads forward
(conv_42, 2026-07)node B307-22 18:03still the same partition on the same node
(conv_42, 2026-06)node D106-30 23:59month bucket bounds partition size; old buckets age out
(conv_7, 2026-07)node A107-21 11:02a different conversation hashes to a different node
PRIMARY KEY ((conversation_id, month), created_at DESC) laid out physically: the partition key picks the owning node and gathers the conversation, the clustering column pre-sorts it, so 'load the latest 50' is the first 50 rows of the current-month partition, one contiguous slice on one node.

Recap: Wide-column stores are LSM-based write machines; model one denormalized table per query, choose a partition key that spreads load and co-locates the query, cluster to serve the sort, always bound partitions with time-bucketing, sub-partition hot keys, and tune quorum for the consistency you need.

Check yourself
Time to design chat message history yourself. Which primary key serves 'latest 50 messages in a conversation' fast and stays healthy for years?

Apply

Your turn

The task this lesson builds to.

Design the Cassandra table(s) for a messaging app's message history optimized for 'load recent messages in a conversation.'

Think about

  1. How do partition key and clustering columns serve the query?
  2. How do you avoid unbounded and hot partitions?
  3. What consistency does a quorum read/write give?

Practice

Make it stick

A second problem on the same idea, so it survives past today.

Design the Cassandra data model for Discord's message storage, roughly a trillion messages, where channels range from a 2-person DM to a 500k-member server firehose, and the read 'jump to any point in a channel's history and page' must stay fast. Explain the partition strategy that survives both extremes and how you handle deletes/edits in an append-optimized store.

Think about

  1. Why can no single fixed idea of 'bucket per conversation' serve both a DM and a firehose channel?
  2. What lets 'jump to any point in history' compute its partition directly from the message id?
  3. What do tombstones do to range reads, and how do you keep them out of the hot path?