Apache Cassandra
Apache Cassandra is a distributed wide-column database designed for high write throughput, horizontal scale, multi-node availability, and predictable key-oriented access.
It is not a relational database with fewer features. Cassandra's data model is built around a different rule:
Design tables from the queries you need to execute.
Cassandra deliberately avoids cross-partition joins, foreign keys, and general multi-partition transactions because those operations require expensive coordination across the cluster.
Official documentation:
Where Cassandra is used
Common workloads include:
- very large write-heavy event datasets,
- time-series-style records,
- IoT telemetry,
- per-user activity history,
- geographically replicated application data,
- high-throughput keyed lookups,
- systems where adding nodes should increase capacity.
It is a poor fit when the application depends heavily on:
- arbitrary joins,
- frequent ad-hoc query patterns,
- cross-row relational constraints,
- multi-table transactions,
- global aggregates without a separate analytics layer.
Architecture
Cassandra is decentralized: there is no single permanent primary node for the whole database.
client
β
βΌ
any Cassandra node
coordinator
/ | \
/ | \
βΌ βΌ βΌ
node A node B node C
β β β
replicas for the partition
A client can contact a node, which acts as the coordinator for that request.
The coordinator:
- hashes the partition key,
- determines which nodes own the token range,
- contacts replicas,
- waits for enough responses to satisfy the requested consistency level.
Partitioning
Every Cassandra table has a partition key.
Example:
CREATE TABLE messages_by_user (
user_id uuid,
sent_at timestamp,
message_id uuid,
body text,
PRIMARY KEY ((user_id), sent_at, message_id)
) WITH CLUSTERING ORDER BY (sent_at DESC);
The primary key contains:
PRIMARY KEY (
(user_id), <- partition key
sent_at, message_id <- clustering columns
)
Rows for one user_id are colocated logically in one partition and ordered by the clustering columns.
user_id = A
βββββββββββββββββββββββββββββββββββββββ
β newest message β
β older message β
β older message β
βββββββββββββββββββββββββββββββββββββββ
user_id = B
βββββββββββββββββββββββββββββββββββββββ
β newest message β
β older message β
βββββββββββββββββββββββββββββββββββββββ
The partition key determines where data lives.
Why query-first modeling matters
This query is efficient because it supplies the partition key:
SELECT *
FROM messages_by_user
WHERE user_id = ?
LIMIT 50;
Example input rows in messages_by_user:
| user_id | sent_at | message_id | body |
|---|---|---|---|
| user-42 | 2026-09-30 20:05:00 | 22222222-2222-2222-2222-222222222222 |
Check the database notes |
| user-42 | 2026-09-30 20:00:00 | 11111111-1111-1111-1111-111111111111 |
Welcome |
| user-99 | 2026-09-30 20:07:00 | 33333333-3333-3333-3333-333333333333 |
Hello |
With user_id = 'user-42', the result is:
| user_id | sent_at | message_id | body |
|---|---|---|---|
| user-42 | 2026-09-30 20:05:00 | 22222222-2222-2222-2222-222222222222 |
Check the database notes |
| user-42 | 2026-09-30 20:00:00 | 11111111-1111-1111-1111-111111111111 |
Welcome |
Cassandra can route directly to the replicas that own that user's partition.
A query such as:
SELECT *
FROM messages_by_user
WHERE body LIKE '%database%';
Using the same three input rows, this is an anti-pattern rather than a runnable result example: Cassandra normally rejects the query because it has no partition-key restriction and body has no supporting index. It returns no result table unless the schema and query are redesigned.
does not match the table's access pattern.
In Cassandra, the normal solution is not "let the database scan everything." It is often:
- create another table for another query,
- use a secondary/indexing feature if appropriate,
- send search workloads to a search system,
- send global analytics to Spark/ClickHouse/a warehouse.
Denormalization
Relational design often avoids duplicated data.
Cassandra frequently duplicates data intentionally so each query can read one well-designed table.
For example:
messages_by_user
messages_by_conversation
recent_messages_by_team
One logical message can be written to several tables.
This moves complexity from reads to writes.
relational:
write once -> join at read time
Cassandra:
write multiple query-shaped copies -> simple keyed reads
Local setup
The repository includes:
scripts/cassandra/docker-compose.yml
Start the local node:
cd scripts/cassandra
docker compose up -d
docker compose ps
Wait until Cassandra is ready:
docker logs -f cassandra-notes
Open CQL shell:
docker exec -it cassandra-notes cqlsh
Stop the demo:
docker compose down
Runnable CQL demo
The repository also includes:
Load it:
docker exec -i cassandra-notes cqlsh < demo.cql
Then query:
docker exec -it cassandra-notes cqlsh -e "SELECT * FROM notes.events_by_user WHERE user_id = 'user-42';"
The demo starts with these event rows:
| user_id | event_time | event_id | event_type | payload |
|---|---|---|---|---|
| user-42 | 2026-09-30 20:00:00 | 11111111-1111-1111-1111-111111111111 |
login | {"device":"web"} |
| user-42 | 2026-09-30 20:05:00 | 22222222-2222-2222-2222-222222222222 |
view_product | {"sku":"DB-101"} |
| user-99 | 2026-09-30 20:07:00 | 33333333-3333-3333-3333-333333333333 |
login | {"device":"mobile"} |
The command selects the user-42 rows, newest first:
| user_id | event_time | event_id | event_type | payload |
|---|---|---|---|---|
| user-42 | 2026-09-30 20:05:00 | 22222222-2222-2222-2222-222222222222 |
view_product | {"sku":"DB-101"} |
| user-42 | 2026-09-30 20:00:00 | 11111111-1111-1111-1111-111111111111 |
login | {"device":"web"} |
The demo uses replication factor 1 because it is a single-node learning environment.
That is not a production topology.
Keyspaces
A keyspace is the top-level namespace and replication configuration.
Development example:
CREATE KEYSPACE notes
WITH replication = {
'class': 'SimpleStrategy',
'replication_factor': 1
};
For production, Cassandra's documentation recommends topology-aware replication rather than SimpleStrategy.
A typical multi-datacenter shape is:
CREATE KEYSPACE app
WITH replication = {
'class': 'NetworkTopologyStrategy',
'dc1': 3,
'dc2': 3
};
This describes how many replicas should exist in each datacenter.
Token ring
Cassandra hashes partition keys into tokens.
token space
node A node B
/
/
/
----- ring -----
/ \
/ \
node D node C
Modern Cassandra commonly uses virtual nodes so each physical node owns many token ranges.
When a node is added, ranges can be redistributed rather than manually assigning one huge contiguous section.
Replication factor
Replication factor is the number of copies of a partition.
For:
RF = 3
a partition is stored on three replicas.
partition P
β
βββ replica on node A
βββ replica on node C
βββ replica on node F
Replication improves availability and durability.
It also increases storage and network cost.
Consistency levels
Cassandra supports tunable consistency per request.
Common levels include:
ONE,LOCAL_ONE,QUORUM,LOCAL_QUORUM,ALL.
Suppose:
replication factor = 3
A quorum requires two replicas.
write at QUORUM:
replica A ACK
replica B ACK
replica C slow
client can succeed after A+B
Using quorum reads and writes creates overlapping replica sets, which provides stronger read-after-write behavior than using one replica for both.
The right consistency level depends on latency, availability, topology, and business correctness.
Multi-datacenter consistency
For globally distributed deployments, LOCAL_QUORUM is often important because it requires a majority only inside the local datacenter rather than waiting across long inter-region links.
Conceptually:
Europe application
β
βΌ
EU Cassandra DC
local quorum
β
βββββ async replication ββββΊ US Cassandra DC
This can give local-region latency while maintaining copies in multiple locations.
The exact guarantees must be evaluated carefully for the application's read/write patterns.
Storage engine
Cassandra uses an LSM-tree-style storage design.
Writes roughly flow through:
write
β
ββββΊ commit log
β
ββββΊ memtable
β
βΌ flush
SSTable
β
βΌ
compaction
Commit log
Sequential durable write record.
Memtable
In-memory sorted structure.
SSTable
Immutable on-disk sorted table.
New writes do not update an old SSTable in place.
Instead, newer values are written into newer structures and resolved during reads/compaction.
Compaction
Over time, many SSTables accumulate.
Compaction merges them:
SSTable 1 ββ
SSTable 2 ββΌβββΊ merged SSTable
SSTable 3 ββ
Compaction:
- removes obsolete values,
- consolidates files,
- affects read amplification,
- consumes CPU and disk I/O.
Different workloads can benefit from different compaction strategies.
Do not tune compaction without measuring actual data shape and read/write behavior.
Tombstones
Cassandra does not immediately erase deleted data from every replica.
A delete creates a tombstone.
old value
β
DELETE
βΌ
tombstone
β
βΌ
eventual compaction removes old data
Tombstones are required because an offline replica might otherwise later reintroduce deleted data.
Too many tombstones can make reads expensive.
Common causes:
- heavy TTL use,
- repeated deletes,
- poorly bounded partitions,
- modeling frequently changing data as append/delete patterns.
TTL
Rows or columns can expire:
INSERT INTO notes.sessions (
session_id,
user_id,
payload
)
VALUES (
'abc',
'user-42',
'...'
)
USING TTL 3600;
Example starting state: there is no row for session abc. Immediately after the insert, the table contains:
| session_id | user_id | payload | remaining TTL |
|---|---|---|---|
| abc | user-42 | ... |
about 3600 seconds |
TTL is useful for expiring:
- sessions,
- telemetry,
- event retention,
- temporary state.
Expiration creates tombstone/compaction implications, so very high-volume TTL workloads require deliberate design.
Clustering columns
Clustering columns define order inside a partition.
PRIMARY KEY ((device_id), event_time)
This makes queries such as:
SELECT *
FROM readings_by_device
WHERE device_id = ?
AND event_time >= ?
AND event_time < ?
ORDER BY event_time DESC;
Example readings_by_device input rows:
| device_id | event_time | reading |
|---|---|---|
| sensor-7 | 2026-09-30 10:00:00 | 21.4 |
| sensor-7 | 2026-09-30 09:00:00 | 20.8 |
| sensor-8 | 2026-09-30 09:30:00 | 18.2 |
For device_id = 'sensor-7' and a range covering both rows, the result is:
| device_id | event_time | reading |
|---|---|---|
| sensor-7 | 2026-09-30 10:00:00 | 21.4 |
| sensor-7 | 2026-09-30 09:00:00 | 20.8 |
natural and efficient.
The table is designed for that exact query family.
Bucketed partitions
An unbounded partition can become too large.
Bad design:
partition key = customer_id
customer has 10 years of billions of events
Better:
partition key = (customer_id, month)
Example:
PRIMARY KEY ((customer_id, bucket_month), event_time, event_id)
Now data is split:
(customer 42, 2026-08)
(customer 42, 2026-09)
(customer 42, 2026-10)
Bucket size should follow expected partition size and query windows.
Lightweight transactions
Cassandra supports compare-and-set style operations for single-partition coordination.
Example:
UPDATE usernames
SET user_id = ?
WHERE username = ?
IF user_id = null;
Example input table (bind username = 'alice' and user_id = 'user-42'):
| username | user_id |
|---|---|
| (no rows) |
If the condition succeeds, the result is:
| username | user_id |
|---|---|
| alice | user-42 |
The conditional write reports [applied] = true; if another row already had that username, [applied] would be false and the table would stay unchanged.
Lightweight transactions require consensus and are more expensive than normal Cassandra writes.
Use them for specific correctness requirements, not every write.
Secondary indexing
Cassandra offers secondary-index mechanisms, including Storage-Attached Indexing in modern versions.
Indexes can be useful, but they do not turn Cassandra into a general relational/ad-hoc query system.
Start from primary access patterns.
Use indexes only after understanding:
- selectivity,
- data distribution,
- query fan-out,
- operational cost.
Joins
Cassandra does not provide relational distributed joins.
Instead of:
SELECT *
FROM orders
JOIN customers ...
For example, relational input tables might be:
orders
| order_id | customer_id | total |
|---|---|---|
| 501 | 42 | 49.90 |
customers
| customer_id | name |
|---|---|
| 42 | Alice |
A relational join would return:
| order_id | customer_id | total | name |
|---|---|---|---|
| 501 | 42 | 49.90 | Alice |
Cassandra does not execute this join; the result illustrates the row the query-shaped table should store.
model the read directly:
orders_by_customer
βββββββββββββββββββββββββββββββββββ
β customer fields needed by query β
β order fields β
βββββββββββββββββββββββββββββββββββ
Duplicating a customer display name into a query table can be normal Cassandra modeling.
Repair
Replicas can temporarily diverge.
Cassandra uses mechanisms such as:
- hinted handoff,
- read repair behavior,
- anti-entropy repair.
Operational repair is important for replica convergence.
A cluster that "looks healthy" but is never repaired can accumulate consistency risk depending on failures and workload.
Backups
Snapshots capture SSTables at a point in time.
Backups must include:
- data,
- schema,
- recovery procedures,
- off-cluster storage.
Replication is not a backup.
An accidental application delete can be replicated to every replica.
Monitoring
Important Cassandra signals include:
- read/write latency,
- pending compactions,
- dropped messages,
- disk utilization,
- heap/GC pressure,
- tombstone warnings,
- partition size,
- repair status,
- coordinator timeouts,
- node availability.
Cassandra operations require cluster awareness; adding more nodes is not a substitute for monitoring.
Cassandra versus DynamoDB
Both are commonly used for key-oriented, horizontally scalable workloads, but they differ operationally.
Cassandra: - self-managed or managed through various providers, - CQL data model, - tunable consistency, - explicit cluster operations.
DynamoDB: - AWS-managed service, - capacity/billing/service limits controlled by AWS, - different API/index/transaction model.
The choice often comes down to cloud strategy, operational ownership, API requirements, and existing expertise.
Cassandra versus PostgreSQL
Choose PostgreSQL when:
- joins matter,
- transactions span many related rows,
- constraints are important,
- query patterns evolve frequently,
- one relational model serves the workload well.
Choose Cassandra when:
- data and request volume justify distribution,
- query patterns are known in advance,
- keyed/partitioned access dominates,
- high write throughput and multi-node availability matter.
Do not choose Cassandra merely because the dataset is "big."
Common mistakes
Modeling like a relational database
Normalization and join-dependent schemas work against Cassandra's strengths.
Poor partition key
A bad key creates hot nodes or huge partitions.
Unbounded partitions
Partitions should have a planned upper size.
ALLOW FILTERING as a design strategy
If a query needs filtering across large unrelated data ranges, the table model is probably wrong.
Using lightweight transactions everywhere
Consensus has a cost.
Ignoring repair
Replication systems require operational maintenance.
One-node production cluster
The architecture assumes redundancy.
Practical design workflow
For every feature:
- Write the exact query.
- Identify the partition key.
- Determine desired ordering inside the partition.
- Estimate partition size over time.
- Decide whether bucketing is required.
- Choose replication topology.
- Choose consistency levels.
- Estimate write amplification from denormalized tables.
- Define TTL/tombstone behavior.
- Test failure and recovery.