Cassandra — Data Modeling & Partition Keys
The single habit that makes or breaks a Cassandra schema: you design tables around the queries you'll run, not the entities you'd normalize in SQL. Get this one idea wrong and every later chapter is fighting the data model.
Learning objectives
- Explain query-first data modeling and why it routinely means duplicating the same data into multiple tables.
- Distinguish a partition key from a clustering key, and explain what each one actually controls.
- Design a table for a "user's recent orders" access pattern with the correct partition and clustering keys.
- Recognize a wide-partition hotspot before it ships, and explain why a low-cardinality partition key causes one.
◆ The problem
An engineer who has spent years in SQL opens a Cassandra table designer with the same habit that's always worked: normalize the entities (users, orders, products), then write whatever ad hoc query the feature needs, relying on JOINs and secondary indexes to make it fast enough later. In Cassandra that habit fails on day one — there is no JOIN, and only two kinds of query are genuinely cheap: an equality match on the partition key, and a sorted range scan on the clustering key within that partition. Everything else either doesn't run, or runs by scanning far more of the cluster than you intended (Chapter 04 covers exactly how badly).
The fix isn't a better index — it's a different starting question. Instead of "what are my entities?", Cassandra data modeling starts from "what queries will this feature actually run?" and builds one table per query pattern, not one table per entity. If a product needs both "a user's recent orders" and "all orders with a given status," that's two tables, orders_by_user and orders_by_status, each holding a copy of the same order data, each shaped for its one query. Disk space is cheap and JOINs don't exist; duplicating data at write time is the trade Cassandra deliberately makes so that reads stay a single-partition lookup no matter how many ways the data needs to be queried.
| SQL habit | Cassandra habit | |
|---|---|---|
| Design starts from | The entities (normalized tables) | The queries the app will actually run |
| A new query pattern needs | An index, or a JOIN | Usually a new table, holding a copy of the data |
| Data duplication | Avoided (normalization) | Deliberate — one copy per query pattern |
| Where consistency across copies is enforced | The database (foreign keys, JOINs read live data) | The application, or Cassandra's own tools (Chapter 02, Chapter 04) |
A Cassandra PRIMARY KEY is really two keys glued together, and confusing their jobs is the single most common beginner mistake:
PRIMARY KEY ((partition_column), clustering_column_1, clustering_column_2)
The partition key — the column(s) inside the inner parentheses — decides which node(s) own the data. Cassandra hashes the partition key and uses that hash to place the row on a node, via the consistent hashing scheme covered generally in NoSQL Foundations, Chapter 02. Every row sharing the same partition key value lives together, physically, on the same replica set.
The clustering key — everything after it — decides the on-disk sort order within that one partition. It has nothing to do with which node owns the data; it only controls the order rows come back in when you read the partition, and which range queries (>, <, BETWEEN, ORDER BY) are cheap.
▲ Common mistake
Trying to WHERE or ORDER BY on a column that's neither the partition key nor a clustering column. Cassandra refuses the query outright rather than silently scanning the cluster — because without a partition key, it has no way to know which of the (possibly hundreds of) nodes hold matching rows. The database is protecting you from writing a query that looks fine on a 10-row dev table and falls over in production. ALLOW FILTERING can force it to run anyway; treat needing that keyword as a design smell, not a fix (Chapter 04 covers exactly why).
◆ Story
A team migrating an orders service from Postgres to Cassandra keeps its old instinct: order_id is the primary key, because that's what it was in Postgres. The very first screen they need to build — "show this user their 20 most recent orders" — turns into a query Cassandra can't run efficiently, because user_id isn't the partition key of anything.
The naive table:
CREATE TABLE orders ( order_id uuid PRIMARY KEY, user_id uuid, order_date timestamp, status text, total decimal ); -- WHERE user_id = ? is not the partition key here — Cassandra has no way to -- know which node(s) hold this user's orders without checking all of them. SELECT * FROM orders WHERE user_id = ? ORDER BY order_date DESC LIMIT 20; -- rejected, or only runs with ALLOW FILTERING — and even then it's a full-cluster scan
The query-first fix, applying §1 and §2 of this chapter together: partition by user_id (so one user's orders live on one partition, owned by one small set of replicas), cluster by order_date DESC (so the newest orders are already sorted first on disk, and "give me the most recent 20" is just "read the first 20 rows of the partition"):
CREATE TABLE orders_by_user ( user_id uuid, order_date timestamp, order_id uuid, status text, total decimal, PRIMARY KEY ((user_id), order_date, order_id) ) WITH CLUSTERING ORDER BY (order_date DESC); SELECT * FROM orders_by_user WHERE user_id = ? LIMIT 20; -- single partition, already sorted newest-first — this is the query Cassandra is built for
Note order_id is included as a second clustering column purely to guarantee uniqueness when two orders share the same order_date down to the millisecond — a common, cheap habit in Cassandra key design.
💻 Code example
CREATE TABLE orders_by_user ( user_id uuid, order_date timestamp, order_id uuid, status text, total decimal, PRIMARY KEY ((user_id), order_date, order_id) ) WITH CLUSTERING ORDER BY (order_date DESC); SELECT * FROM orders_by_user WHERE user_id = ? LIMIT 20;
NoSQL Foundations, Chapter 02 told the story of a leaderboard partitioned by date, where every write on launch day landed on one partition while the rest of the cluster sat idle — and showed hash partitioning as the general fix, since a good hash spreads keys near-uniformly regardless of their natural distribution. That fix only works if the partition key itself has many distinct values for the hash to spread.
Partition orders_by_user by status instead of user_id and you've broken that assumption: status might have four possible values (PENDING, SHIPPED, DELIVERED, CANCELLED). No matter how good Cassandra's hash function is, or how many nodes you add to the cluster, there are only ever four partitions in the entire system. Every PENDING order, from every user, forever, lands on the same replica set. That partition grows without bound — a wide partition — and becomes a permanently hot, oversized target for both writes and reads, while most of the cluster does nothing. This is the same hotspot failure mode as the leaderboard story, just caused by the partition key's cardinality instead of by a range-partitioning strategy.
▲ Common mistake
Reaching for a natural, business-meaningful column as a partition key — status, country, a boolean flag — without first checking how many distinct values it actually has. A partition key needs enough distinct, evenly-used values that no single partition can grow unbounded. And even a partition key that looks fine on paper (user_id) can still go wide for one outlier — a bot account or a power user placing millions of rows — which is why partition size is worth monitoring in production, not just reasoning about at design time.
Query-first modeling buys fast, predictable, single-partition reads for every query pattern you've designed a table for — but it moves the cost of that speed to write time. If orders_by_user and orders_by_status both hold a copy of the same order, a status change is now two writes, not one, and Cassandra gives you no cross-table transaction to make those two writes atomic the way a SQL UPDATE inside a BEGIN/COMMIT would (Transaction Mastery, Chapters 01–02).
Cassandra's BATCH statement can group several statements into one request, but it is not a substitute for that guarantee across different partitions — a multi-partition batch is a genuinely common anti-pattern, since it forces the coordinator node to act as a transaction manager across replicas it doesn't own, adding latency and failure modes without adding real atomicity. BATCH is best reserved for statements that touch a single partition, where it's a legitimate, cheap way to apply several related writes together.
This is the central bargain of the whole query-first model: read-time simplicity and speed, paid for at write time and by the application's own responsibility to keep duplicate copies consistent. It's also exactly why the next chapter matters — Cassandra lets you choose, per write, how strong a guarantee you want that a given copy actually landed before you move on.
Want a visual for this concept?
Generate a diagram tailored to “Cassandra — Data Modeling & Partition Keys” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →