Chapter 06 · Query-First Data Modeling and Denormalization

Start from Application Queries, Ordering, Cardinality, Retention, and Consistency

Turn AtlasMart request contracts into partition-addressable Cassandra read shapes before schema decisions become expensive.

Intermediate90–120 minutesQuery inventory + bounded-partition design labApache Cassandra 5.0.9 · cqlsh/nodetool · UCSLast reviewed: September 2026

Learning outcomes

AtlasMart’s team has an entity diagram—products, customers, orders and events—but Cassandra cannot infer efficient access paths from that diagram. The modeling input is the actual request contract: which values are known at query time, which ordering is required, how many rows can accumulate together, how long data lives, what consistency is needed and what latency envelope the application is trying to protect.

01

Turn exact application queries into a query inventory before writing CQL.

02

Choose partition and clustering keys from known lookup values, required order and bounded growth.

03

Separate declared latency/consistency/retention targets from measurements actually captured in the lab.

04

Explain why a convenient entity table can be wrong when the application needs a different access path.

05

Map each important AtlasMart read to an explicit materialized read shape and identify its write fan-out consequence.

Version and lab contract
  • Server: Apache Cassandra 5.0.9, pinned container image cassandra:5.0.9; Java 17 inside the image.
  • Topology: three disposable nodes atlasmart-cass-1..3, cluster atlasmart-course, one DC dc1, racks rack1..rack3, 16 vnodes per node, Docker network atlasmart-cassandra.
  • Replication / consistency: chapter keyspace atlasmart_model, NetworkTopologyStrategy, RF=3 in dc1; examples use QUORUM unless a different level is stated next to the operation.
  • Storage: chapter tables explicitly use UnifiedCompactionStrategy (UCS); no chapter-specific gc_grace_seconds or default TTL override. TTL is introduced only when a query has explicit retention semantics.
  • Security: the disposable Docker network is isolated for learning; authentication, client TLS, internode TLS and hardened JMX are not enabled. Do not expose CQL/JMX ports broadly or reuse this posture for production.
  • Resources: the full three-node lab is intended for a development machine with enough headroom for three Cassandra JVMs (plan roughly 6 GiB+ RAM for the containers plus host/Docker overhead and several GiB of disposable disk). If that is impractical, use one pinned node and RF=1 only for schema/query-shape exercises and label the topology difference.
  • Client: mandatory work uses the bundled cqlsh/nodetool. No application driver is required in Chapter 06, so driver retry/idempotency behavior is discussed as a design obligation but not fabricated as lab evidence.
  • Evidence: capture your own row counts, trace events, timings and node state. The generated lesson never claims unexecuted throughput, p95/p99 latency, fan-out cost or convergence measurements.
Execution and safety note

Run commands only against the disposable Apache Cassandra course lab or another explicitly approved non-production environment. Confirm node, keyspace, table, container, volume, path, and datacenter targets before destructive, failure-injection, cleanup, repair, restore, security, or topology operations. Capture current state and expected rollback/recovery evidence first; output and timings can differ by host, operating system, Java runtime, Docker/runtime, driver, and Cassandra configuration.

1. Begin with query contracts, not nouns

Relational modeling commonly begins with normalized entities and relationships. Cassandra modeling reverses the direction: the table shape is chosen to make a known request local and ordered. A query inventory records the values supplied by the caller, the predicates, ordering, result bound, cardinality growth, retention, consistency level and service-level objective (SLO) that matter to that request. An SLO is a target; it is not proof that the local lab meets production latency.

ID Application query Ordering / bound Consistency / retention input Candidate read shape
Q1 Fetch product p-501 by ID one row QUORUM; product lifecycle products_by_id
Q2 List active laptops in a category by price price ascending; category must remain bounded QUORUM; catalog lifecycle products_by_category
Q3 Show a customer’s newest orders for one month ordered_at descending; month bucket QUORUM; order retention policy orders_by_customer_bucket
Q4 Show one order’s event timeline event time ascending; one order QUORUM; audit/event retention order_events_by_order

Notice that “orders” is not yet a table design. “Newest orders for customer C in month M, newest first, bounded to one month” is a designable request. The month bucket is not decorative: it constrains partition growth and gives the caller a deterministic partition key.

2. Primary-key decisions encode distribution and order

In PRIMARY KEY ((customer_id, order_month), ordered_at, order_id), the composite partition key (customer_id, order_month) determines which partition is addressed and therefore which token/replicas own the data. The clustering columns ordered_at, order_id organize rows within that partition. CLUSTERING ORDER BY defines on-disk/read order; it is not a request to globally sort arbitrary partitions.

sql · create read shapes from the inventory
CREATE TABLE atlasmart_model.products_by_id (  product_id text PRIMARY KEY,  name text, category text, price_cents int, active boolean, source_version bigint) WITH compaction = {'class':'UnifiedCompactionStrategy'};CREATE TABLE atlasmart_model.products_by_category (  category text, price_cents int, product_id text, name text, active boolean, source_version bigint,  PRIMARY KEY ((category), price_cents, product_id)) WITH CLUSTERING ORDER BY (price_cents ASC, product_id ASC)  AND compaction = {'class':'UnifiedCompactionStrategy'};CREATE TABLE atlasmart_model.orders_by_customer_bucket (  customer_id text, order_month date, ordered_at timestamp, order_id text, status text, total_cents bigint,  PRIMARY KEY ((customer_id, order_month), ordered_at, order_id)) WITH CLUSTERING ORDER BY (ordered_at DESC, order_id ASC)  AND compaction = {'class':'UnifiedCompactionStrategy'};CREATE TABLE atlasmart_model.order_events_by_order (  order_id text, event_at timestamp, event_id timeuuid, event_type text, detail text,  PRIMARY KEY ((order_id), event_at, event_id)) WITH CLUSTERING ORDER BY (event_at ASC, event_id ASC)  AND compaction = {'class':'UnifiedCompactionStrategy'};

The schema deliberately duplicates product attributes between products_by_id and products_by_category. That is not an accidental violation of normalization—it is the cost of serving Q1 and Q2 as direct read shapes. Lesson 2 turns that cost into an explicit write-fan-out budget.

3. Reproducible AtlasMart lab

bash · standalone disposable Chapter 06 baseline
# Reuse the course cluster if it exists; otherwise create only these disposable resources.docker network inspect atlasmart-cassandra >/dev/null 2>&1 || docker network create atlasmart-cassandradocker volume create atlasmart-cass-1-datadocker volume create atlasmart-cass-2-datadocker volume create atlasmart-cass-3-data# Start node 1 only if it does not already exist.docker inspect atlasmart-cass-1 >/dev/null 2>&1 || docker run -d --name atlasmart-cass-1 --hostname atlasmart-cass-1 --network atlasmart-cassandra -e CASSANDRA_CLUSTER_NAME=atlasmart-course -e CASSANDRA_DC=dc1 -e CASSANDRA_RACK=rack1 -e CASSANDRA_ENDPOINT_SNITCH=GossipingPropertyFileSnitch -e CASSANDRA_NUM_TOKENS=16 -v atlasmart-cass-1-data:/var/lib/cassandra cassandra:5.0.9# Wait until node 1 answers before starting peers:docker exec atlasmart-cass-1 nodetool statusdocker inspect atlasmart-cass-2 >/dev/null 2>&1 || docker run -d --name atlasmart-cass-2 --hostname atlasmart-cass-2 --network atlasmart-cassandra -e CASSANDRA_CLUSTER_NAME=atlasmart-course -e CASSANDRA_DC=dc1 -e CASSANDRA_RACK=rack2 -e CASSANDRA_ENDPOINT_SNITCH=GossipingPropertyFileSnitch -e CASSANDRA_NUM_TOKENS=16 -e CASSANDRA_SEEDS=atlasmart-cass-1 -v atlasmart-cass-2-data:/var/lib/cassandra cassandra:5.0.9docker inspect atlasmart-cass-3 >/dev/null 2>&1 || docker run -d --name atlasmart-cass-3 --hostname atlasmart-cass-3 --network atlasmart-cassandra -e CASSANDRA_CLUSTER_NAME=atlasmart-course -e CASSANDRA_DC=dc1 -e CASSANDRA_RACK=rack3 -e CASSANDRA_ENDPOINT_SNITCH=GossipingPropertyFileSnitch -e CASSANDRA_NUM_TOKENS=16 -e CASSANDRA_SEEDS=atlasmart-cass-1 -v atlasmart-cass-3-data:/var/lib/cassandra cassandra:5.0.9# Wait until all three nodes report UN before creating RF=3 data.docker exec atlasmart-cass-1 nodetool statusdocker exec atlasmart-cass-1 cqlsh -e "SELECT release_version,cluster_name,data_center,rack FROM system.local;"docker exec atlasmart-cass-1 cqlsh -e "CREATE KEYSPACE IF NOT EXISTS atlasmart_model WITH replication = {'class':'NetworkTopologyStrategy','dc1':3};"docker exec atlasmart-cass-1 cqlsh -e "DESCRIBE KEYSPACE atlasmart_model"

On Windows, run these Docker commands from WSL/Git Bash or adapt the existence checks to PowerShell. If three Cassandra JVMs are too heavy for the machine, a single cassandra:5.0.9 node with RF=1 is sufficient for the chapter's query-shape exercises, but it is not equivalent evidence for RF=3/QUORUM behavior.

sql · load a small deterministic fixture
CONSISTENCY QUORUM;INSERT INTO atlasmart_model.products_by_id (product_id,name,category,price_cents,active,source_version) VALUES ('p-501','AtlasBook 14','laptops',129900,true,1);INSERT INTO atlasmart_model.products_by_category (category,price_cents,product_id,name,active,source_version) VALUES ('laptops',129900,'p-501','AtlasBook 14',true,1);INSERT INTO atlasmart_model.orders_by_customer_bucket (customer_id,order_month,ordered_at,order_id,status,total_cents) VALUES ('c-17','2026-09-01','2026-09-07T10:00:00Z','o-9001','PAID',129900);INSERT INTO atlasmart_model.orders_by_customer_bucket (customer_id,order_month,ordered_at,order_id,status,total_cents) VALUES ('c-17','2026-09-01','2026-09-07T11:00:00Z','o-9002','PACKING',4599);SELECT product_id,name,price_cents FROM atlasmart_model.products_by_id WHERE product_id='p-501';SELECT ordered_at,order_id,status,total_cents FROM atlasmart_model.orders_by_customer_bucket WHERE customer_id='c-17' AND order_month='2026-09-01' LIMIT 20;

Record actual output. Then enable TRACING ON for one partition-key read and save the trace. The trace proves which coordinator/replicas participated for that request; it does not establish production capacity or a general latency guarantee.

sql · trace one bounded read
TRACING ON;CONSISTENCY QUORUM;SELECT ordered_at,order_id,status,total_centsFROM atlasmart_model.orders_by_customer_bucketWHERE customer_id='c-17' AND order_month='2026-09-01'LIMIT 20;TRACING OFF;

4. Boundary case: cardinality and retention can invalidate an otherwise correct key

Suppose Q3 changes from “one month” to “all orders ever,” and one marketplace customer can generate millions of orders. Removing order_month would make the query syntactically convenient but turns partition growth into an unbounded operational risk. The correction is not a magic cluster setting; it is a query/API decision about buckets, pagination and retention. The caller may need to walk month buckets intentionally.

Wrong approach

Do not create one generic orders table keyed only by order_id and assume Cassandra will later scan/join/sort it efficiently for every customer timeline. That starts from storage nouns and pushes the application’s access problem into expensive cross-partition work.

5. Observable design review

Decision Evidence to collect What would force a redesign?
customer-month bucket rows/bytes per partition; months touched per request bucket still grows too large or most reads span many buckets
category partition category cardinality/skew; rows/bytes; hot-key request rate a few categories dominate volume/traffic
clustering order requested order and pagination behavior application needs incompatible orderings
QUORUM availability/latency under replica loss business consistency/failure requirement differs

Check your understanding

  1. Why does a query inventory include retention?
  2. Why is a month bucket part of the partition key?
  3. Does CLUSTERING ORDER BY globally sort all customer orders?
  4. What does a local trace prove?
  5. Why can Q1 and Q2 justify duplicate product fields?
Review the answers

1. Retention helps bound how much data can accumulate in a partition and determines whether time bucketing or TTL policy is part of the access design.

2. It keeps each customer-order partition bounded and gives the caller an explicit partition address for one month.

3. No. Clustering order applies within a partition; global ordering across partitions would require application orchestration or a different read shape.

4. It shows the observed coordinator/replica activity for that request in that environment, not production throughput or an SLO guarantee.

5. They are materially different read paths; duplicating the needed fields lets each query target a direct table instead of performing a join or scan at read time.

Production judgment

A Cassandra table is an operational commitment, not just a schema object. Before approving the read shape, record expected partition cardinality and byte growth, retention/TTL behavior, read and write rates, tail-latency objectives, RF/CL, failure domains, write fan-out, retry/idempotency rules, reconciliation ownership, compaction and tombstone consequences, repair/backup requirements, observability signals, and the migration/rollback path. The same denormalization that removes a read-time join can multiply writes, storage, repair traffic and opportunities for projection drift.

Do not derive universal size or latency thresholds from this local lab. Production decisions require measurements with the real key distribution, payloads, concurrency, disk/network/JVM behavior and failure modes. SAI/vector features are intentionally not used to rescue a poor primary access model here; those mechanisms have their own later chapters and costs.

Lesson 2 makes the duplication visible as a write-time fan-out budget and asks who owns keeping those read shapes consistent.

Summary and next bridge

Query-first modeling starts with exact reads, order, bounds, retention, consistency and latency objectives, then encodes those requirements into partition and clustering keys. The result is intentionally workload-specific. Next, the same product mutation will be written into multiple query tables so the cost of denormalization becomes measurable rather than rhetorical.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.