Chapter 06 · Query-First Data Modeling and Denormalization

One Table per Query Pattern: Denormalization as an Explicit Write-Time Cost

Treat denormalization as an intentional read-performance choice whose write fan-out and failure modes must be owned.

Intermediate90–120 minutesDenormalization + write-fan-out drift labApache Cassandra 5.0.9 · cqlsh/nodetool · UCSLast reviewed: September 2026

Learning outcomes

AtlasMart can now state its read patterns, but a product update affects more than one table: product details, category browsing and inventory-facing projections may each need a materialized row. Cassandra’s write path makes repeated writes cheap relative to distributed joins, but denormalization transfers complexity from read time to write/reconciliation time. This lesson makes that transfer explicit.

01

Explain “one table per query pattern” as a design heuristic, not a literal rule that every syntactic query needs a new table.

02

Calculate write fan-out for a mutation and distinguish logical application writes from RF-expanded replica writes.

03

Use a source version to detect projection drift across duplicated rows.

04

Identify when multiple compatible queries can share one table and when incompatible ordering/partitioning requires a new shape.

05

Design idempotent, observable projection updates and a reconciliation path.

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. A query table is a materialized read contract

The phrase “one table per query” is shorthand for a deeper rule: a Cassandra table should serve a coherent family of requests that share the same partition address and clustering order. Two filters that require incompatible partition keys or orderings often need different tables. Conversely, a single table can serve several bounded variants if they use the same access path.

For AtlasMart, products_by_id answers direct lookup, products_by_category answers category/price traversal, and inventory_by_product answers store availability for one product. Reusing one generic table would force scans or client joins; creating these projections creates write fan-out instead.

sql · create three query-oriented product projections
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.inventory_by_product (  product_id text, store_id text, available int, updated_at timestamp, source_version bigint,  PRIMARY KEY ((product_id), store_id)) WITH compaction = {'class':'UnifiedCompactionStrategy'};

2. Count fan-out at two different layers

A single business mutation—“product p-501 price changed to 119900, source version 8”—may require two or three logical projection writes. With RF=3, each logical write is then replicated to natural replicas according to the keyspace and consistency protocol. Do not multiply those numbers together and call the result “database transactions”; they are distinct costs and failure surfaces.

Layer Example What to measure
Business event 1 product change event rate, payload, idempotency key/version
Projection fan-out 2 product read-shape updates (+ inventory only if inventory changed) logical writes/event, partial-failure rate
Replication RF=3 per affected partition replica/network/storage cost, CL latency
Reconciliation compare source_version/content drift count, age, repair throughput

3. Reproducible fan-out and drift 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 · apply one product event to two read shapes
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,7);INSERT INTO atlasmart_model.products_by_category (category,price_cents,product_id,name,active,source_version) VALUES ('laptops',129900,'p-501','AtlasBook 14',true,7);SELECT product_id,name,price_cents,source_version FROM atlasmart_model.products_by_id WHERE product_id='p-501';SELECT product_id,name,price_cents,source_version FROM atlasmart_model.products_by_category WHERE category='laptops' AND price_cents=129900 AND product_id='p-501';

Now inject a realistic partial dual-write failure: update only the ID projection to source version 8. This is safe because the fixture is disposable and the reset is deterministic.

sql · deliberately create projection drift
UPDATE atlasmart_model.products_by_id SET price_cents=119900,source_version=8 WHERE product_id='p-501';SELECT product_id,price_cents,source_version FROM atlasmart_model.products_by_id WHERE product_id='p-501';SELECT product_id,price_cents,source_version FROM atlasmart_model.products_by_category WHERE category='laptops' AND price_cents=129900 AND product_id='p-501';
Why this is more subtle than an UPDATE

The category table’s primary key includes price_cents. A price change is a key move, not an in-place clustering-key update. The projection workflow must write the new row and delete the old row using the old key. That design consequence comes directly from query-first clustering.

sql · repair the materialized category view
INSERT INTO atlasmart_model.products_by_category (category,price_cents,product_id,name,active,source_version) VALUES ('laptops',119900,'p-501','AtlasBook 14',true,8);DELETE FROM atlasmart_model.products_by_category WHERE category='laptops' AND price_cents=129900 AND product_id='p-501';SELECT product_id,price_cents,source_version FROM atlasmart_model.products_by_category WHERE category='laptops' AND price_cents=119900 AND product_id='p-501';

4. Wrong approach: uncontrolled dual writes

Writing table A and then table B from an HTTP handler without an idempotency key, source version, retry classification or reconciler makes “denormalization” synonymous with silent inconsistency. Cassandra does not provide cross-table foreign-key integrity that repairs this for you. Logged batches are not a generic replacement for an application consistency design; batch semantics are taught later and have partition/topology costs.

A safer pattern identifies one authoritative business change, assigns a stable event/version, makes each projection update idempotent, records success/failure, retries only when semantics allow it and periodically reconciles projections against the authority. The exact implementation may be synchronous application code, an outbox/event pipeline or a streaming processor; ownership and observability are mandatory either way.

5. When a new table is justified

Question Same table may work New read shape likely needed
Partition address same partition-key values known different lookup key
Order compatible clustering order/range different primary sort requirement
Bound same bounded partition query would touch many partitions
Payload same fields are acceptable large payload/retention shape differs materially
Update cost fan-out remains owned/observable projection count makes consistency/ops unacceptable

Check your understanding

  1. What is write fan-out?
  2. Why did the price change require insert plus delete in products_by_category?
  3. Does RF=3 mean the application should issue three INSERTs?
  4. What makes projection retries safer?
  5. When can multiple queries share one table?
Review the answers

1. The number of materialized query projections an application/business mutation must update; it is separate from Cassandra replication factor.

2. price_cents is part of that table’s primary key, so changing it moves the row to a different clustering position/key.

3. No. The application issues the logical CQL write; Cassandra replicates it to natural replicas according to RF and CL.

4. A stable event/idempotency identity, explicit source version, classified errors and writes that can be replayed without corrupting state.

5. When they share a compatible partition address, clustering order, result bound and payload/retention model.

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 3 uses the same idea to eliminate read-time joins and broad cross-partition scans by precomputing the shape the application actually renders.

Summary and next bridge

Denormalization is an exchange: predictable single-table reads in return for write fan-out, storage duplication and projection-consistency work. A source-version field and deterministic reconciliation make that exchange observable. Next, AtlasMart redesigns a join-heavy order view so the user-facing read is precomputed rather than reconstructed across arbitrary partitions.

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.