Wide-Column Modeling: Query-First Tables, Partitions, and Bucketing
Wide-column databases such as Cassandra and HBase are designed around partitioned, distributed access patterns. The central modeling rule is different from a relational database:
Start from the query, then design the table that answers it directly.
Normalization is not the primary goal. Predictable partition-key access is.
Query-first design
Suppose the bookstore needs:
Show the latest 50 events for one customer.
A query-shaped table can be:
events_by_customer
partition key: customer_id
clustering: event_time DESC, event_id
Conceptually:
customer 42 partition
βββ 2026-10-01 15:10 purchase
βββ 2026-10-01 14:55 login
βββ ...
The query can route to one partition.
Partition key
The partition key decides where data lives.
Good partition keys usually provide:
- predictable routing,
- balanced distribution,
- bounded partition size,
- useful locality.
Bad key:
country
if 80% of all users are in one country.
That creates skew.
Clustering columns
Clustering columns organize rows inside a partition.
Example:
PRIMARY KEY ((customer_id), event_time, event_id)
The partition key is:
customer_id
The clustering columns are:
event_time, event_id
That shape supports ordered range reads within one customer.
Runnable Cassandra example
The repository already contains:
This chapter adds:
scripts/cassandra/nosql_bucketed_events.cql
The additional demo shows a time-bucketed partition key.
Run after starting the local Cassandra container:
cd scripts/cassandra
docker compose up -d
docker exec -i cassandra-notes cqlsh < nosql_bucketed_events.cql
Unbounded partitions
Bad design:
partition key = customer_id
if one customer can accumulate billions of events over many years.
The partition keeps growing.
Use bucketing.
Time bucket
Partition key:
(customer_id, month)
Example partitions:
(customer 42, 2026-08)
(customer 42, 2026-09)
(customer 42, 2026-10)
Now each partition has a planned lifetime/size.
Bucket granularity
Choose bucket size from:
- write rate,
- row size,
- retention,
- query window.
High-volume telemetry may need:
hourly buckets
Lower-volume application events may use:
monthly buckets
Do not choose a calendar bucket without estimating partition size.
Estimate partition size
Approximate:
rows per period
Γ average row size
= partition data size
Then include indexing/storage overhead.
The exact acceptable size depends on the engine and workload.
Duplicate tables for duplicate queries
Suppose you need:
- latest events by customer,
- all failures by service,
- events by device and hour.
A wide-column model may maintain:
events_by_customer
failures_by_service
events_by_device_hour
The same logical event is duplicated.
This is intentional query-driven denormalization.
Write fan-out
One logical event may produce several writes:
new event
β
ββββΊ events_by_customer
ββββΊ failures_by_service
ββββΊ events_by_device_hour
That shifts work from read time to write time.
The application must define what happens when only some writes succeed.
Materialized projections
Possible strategies:
- application writes every table,
- asynchronous event/CDC pipeline updates projections,
- database-managed materialized view where supported.
Each has different consistency and failure behavior.
No joins by default
A wide-column database generally expects the read model to already contain the fields needed by the query.
Instead of:
SELECT ...
FROM orders
JOIN customers ...
Example relational input rows:
orders
| order_id | customer_id |
|---|---|
| 501 | 42 |
customers
| customer_id | name |
|---|---|
| 42 | Alice |
A relational join would return:
| order_id | customer_id | name |
|---|---|---|
| 501 | 42 | Alice |
Cassandra does not execute this join; a query-shaped Cassandra row would store the fields needed by that read together.
store a query-shaped row.
That may include copied display fields.
Historical versus current copied values
As with document modeling, duplicated values need semantics.
Example:
customer_name_at_order
can be intentionally historical.
But:
current_customer_plan
may need propagation if the customer changes plan.
Name the field to make the meaning explicit.
Cassandra CQL example
CREATE TABLE events_by_customer_month (
customer_id text,
bucket_month text,
event_time timestamp,
event_id uuid,
event_type text,
payload text,
PRIMARY KEY (
(customer_id, bucket_month),
event_time,
event_id
)
) WITH CLUSTERING ORDER BY (event_time DESC);
Query:
SELECT *
FROM events_by_customer_month
WHERE customer_id = 'customer-42'
AND bucket_month = '2026-10'
LIMIT 50;
Example input rows:
| customer_id | bucket_month | event_time | event_id | event_type | payload |
|---|---|---|---|---|---|
| customer-42 | 2026-10 | 2026-10-01 12:05:00 | e2 | purchased | book-101 |
| customer-42 | 2026-10 | 2026-10-01 12:00:00 | e1 | viewed | book-101 |
| customer-42 | 2026-09 | 2026-09-30 23:00:00 | e0 | viewed | book-205 |
| customer-77 | 2026-10 | 2026-10-01 12:10:00 | e3 | viewed | book-205 |
Result:
| customer_id | bucket_month | event_time | event_id | event_type | payload |
|---|---|---|---|---|---|
| customer-42 | 2026-10 | 2026-10-01 12:05:00 | e2 | purchased | book-101 |
| customer-42 | 2026-10 | 2026-10-01 12:00:00 | e1 | viewed | book-101 |
The query supplies the full partition key.
Partition-local range query
Because event_time is a clustering column, a time range can be efficient:
SELECT *
FROM events_by_customer_month
WHERE customer_id = 'customer-42'
AND bucket_month = '2026-10'
AND event_time >= '2026-10-01T00:00:00Z'
AND event_time < '2026-10-02T00:00:00Z';
Using the same rows, the time-range result is:
| event_time | event_id | event_type | payload |
|---|---|---|---|
| 2026-10-01 12:05:00 | e2 | purchased | book-101 |
| 2026-10-01 12:00:00 | e1 | viewed | book-101 |
The table exists for this query pattern.
Query not supported by the table
Suppose you ask:
find every event where payload contains "database"
The partition key does not help.
Possible solutions:
- search engine,
- dedicated query table,
- supported secondary-index feature,
- analytics pipeline.
Do not force a full-cluster filtering query into the transactional path.
ALLOW FILTERING
In Cassandra, ALLOW FILTERING can permit queries the normal primary-key model
does not support.
It is not a replacement for data modeling.
Use it only when the data volume/query behavior is understood.
Secondary indexes
Modern wide-column systems provide secondary indexing options.
Use them selectively.
Ask:
- how selective is the field?
- does the query still fan out?
- what write/index cost exists?
- is the index supported for the exact workload?
The primary partition model remains fundamental.
Consistency levels
Wide-column systems often let clients choose consistency per request.
Example concepts:
ONE
LOCAL_QUORUM
QUORUM
ALL
The choice affects:
- latency,
- availability,
- read freshness.
See the dedicated consistency note for deeper treatment.
Replication factor
Replication factor controls how many replicas store a partition.
Example:
RF = 3
Each partition has three copies.
This improves fault tolerance but increases storage/network work.
Quorum intuition
For three replicas:
quorum = 2
A quorum write waits for two acknowledgements.
A quorum read consults enough replicas to provide overlapping read/write sets.
Real behavior depends on topology and engine rules.
Multi-datacenter design
A production cluster may replicate across regions/datacenters.
Prefer topology-aware consistency such as local-quorum concepts when low cross-region latency matters.
The exact design must follow business consistency requirements.
Hot partitions
A partition key can be logically correct but operationally hot.
Example:
partition key = global_event_type
if every checkout writes:
event_type = purchase
One partition/node range becomes overloaded.
Split by:
- tenant,
- time,
- random shard,
- natural entity,
when semantics allow.
Write sharding
A synthetic shard can spread writes:
partition key = (service, minute, shard)
where:
shard = hash(event_id) % 16
Reads must query all 16 shards and merge results.
Trade-off:
better write distribution
vs
more read fan-out
Use only when the hotspot is real.
Time-series modeling
A common shape:
(device_id, day) -> timestamp-ordered readings
This supports:
- one device,
- one bounded period,
- chronological reads.
It is very different from a warehouse query:
average temperature across every device for 3 years
Send global analytics to an analytical system.
TTL and retention
Wide-column systems often support per-row/cell TTL.
Useful for:
- telemetry retention,
- temporary events,
- session-like records.
High-volume expiration has storage-engine consequences such as tombstones and compaction work.
Retention is part of physical design.
Tombstones
A delete/expiration may create a tombstone rather than immediately erasing data from every replica.
Why:
replica A sees delete
replica B temporarily offline
Without a deletion marker, replica B might later reintroduce old data.
Too many tombstones can hurt reads.
Compaction
LSM-style engines create immutable files over time.
memtable
β flush
βΌ
SSTables
β
βΌ
compaction
Compaction:
- merges files,
- removes obsolete values,
- consumes I/O/CPU.
Write-heavy/TTL-heavy workloads need operational planning.
Read amplification
A read may consult several SSTables/structures.
Compaction strategy and bloom filters can reduce unnecessary work.
This is why "writes are fast" does not mean read cost is free.
Lightweight transactions
Cassandra provides compare-and-set style lightweight transactions.
They use consensus and cost more than normal writes.
Use them for narrow correctness requirements such as:
- claim a unique name,
- update only if version matches.
Do not make every write an LWT by default.
Idempotency
Retries can produce repeated writes.
Choose stable identifiers:
event_id
so a retried operation can overwrite/deduplicate the same logical record where appropriate.
Batch statements
A batch does not automatically make unrelated writes faster.
Use batches for semantic grouping where the database documents that behavior.
Cross-partition batches can increase coordination.
Clock/time semantics
If time controls clustering and retention:
- use UTC internally,
- define event time versus ingestion time,
- use stable tie-breakers such as event ID.
Two events can share the same timestamp.
Query-first workflow
For every required query:
- Write the exact WHERE conditions.
- Identify the partition key.
- Decide clustering order.
- Estimate partition growth.
- Add time/random bucketing if needed.
- Decide copied fields.
- Define write fan-out.
- Choose consistency level.
- Define retention/TTL.
- Test skew and hotspot cases.
When not to use a wide-column store
A relational database may be simpler when:
- joins are central,
- transactions span many entities,
- query patterns change frequently,
- constraints must be enforced relationally,
- data volume fits conventional scaling.
A wide-column system is not required merely because data is "large."
Common mistakes
Relational normalization carried over unchanged
Queries require joins the system is not designed to perform.
Partition key has low cardinality
Hotspots appear.
Partition grows forever
Reads/maintenance degrade.
Every new query uses filtering
Schema does not match access patterns.
Duplicated data has no ownership rule
Copies drift.
Global analytics run on transactional partitions
The wrong engine is doing the work.