Cassandra can handle a million writes per second on modest hardware. That's not marketing copy; it's been reproduced in independent benchmarks on commodity servers. It's the result of a deliberate architectural choice about what a write actually does. Once you understand the write path, the rest of Cassandra's behavior follows directly: the reads, the schema constraints, the tombstone warnings you've been ignoring.
Why writes are fast: the memtable and commit log
When Cassandra receives a write, it does two things, and only two things:
First, it appends the write to a commit log: a sequential, append-only file on disk. This is purely for crash recovery. If the process dies before the write is persisted elsewhere, the commit log lets Cassandra replay what it missed on restart. Sequential appends to disk are fast. A single spinning disk can handle hundreds of megabytes per second of sequential writes, because the disk head doesn't have to seek. SSDs are faster still.
Second, it writes to a memtable: an in-memory sorted data structure, essentially a sorted map keyed by partition key and clustering columns. RAM writes are fast enough to be essentially free compared to the disk operation.
That's it. No reading existing data. No checking whether the row exists first. No index updates. No row-level locking. The write is acknowledged to the client as soon as both operations succeed: a sequential disk write and a RAM write. This is why Cassandra's write latency is measured in microseconds and its throughput scales horizontally with hardware.
Compare this to PostgreSQL: a write requires reading the relevant page into the buffer pool (a disk read if it's not already cached), applying the change, writing a WAL entry (sequential, like Cassandra's commit log), and eventually writing the dirty page to the heap file. There's also index maintenance: every indexed column requires finding the right position in the B-tree and updating it, which involves random I/O. For a table with three indexes, one insert is potentially four random disk operations. At scale, random I/O is the bottleneck, and Postgres has more of it by design.
SSTables
Memtables don't grow forever. When a memtable reaches a configured size threshold (or a time limit), it's flushed to disk as an SSTable, a Sorted String Table. The SSTable is immutable: once written, it is never modified. It contains a sorted, sequential snapshot of everything that was in the memtable at flush time.
Here's the consequence: after several flushes, you have several SSTables on disk, all potentially containing data for the same partition key. SSTable 1 might have the original version of a row. SSTable 3 might have an update to two columns of that row. SSTable 5 might have a tombstone marking the row deleted. The "current" state of any given row is the result of merging all versions across all SSTables, applying timestamps to determine which write wins for each column.
This is the fundamental tension: Cassandra writes are fast because writes never read existing data. But the cost is that the existing data becomes fragmented across multiple immutable files on disk.
Compaction
Compaction is the background process that cleans up the mess. It periodically reads multiple SSTables and merges them into a new, smaller set, resolving which version of each row is authoritative, discarding data that's been superseded by newer writes, and removing tombstones (more on those shortly).
After compaction, reads are faster: fewer SSTables to check, less fragmented data. But compaction itself is expensive. It reads and writes large amounts of data sequentially. A major compaction on a large partition can temporarily double disk usage (old SSTables plus the new merged one). It consumes I/O bandwidth that would otherwise serve reads and writes. Cassandra operators tune compaction strategies to match their workload: STCS (Size-Tiered Compaction Strategy, which groups similarly-sized SSTables together), TWCS (Time-Window Compaction Strategy, which groups SSTables by time range), or LCS (Leveled Compaction Strategy, which maintains size limits per level).
Compaction is not optional. A system that never compacts accumulates SSTables indefinitely. Read performance degrades. Disk usage grows with historical versions. You can tune when and how aggressively compaction runs, but it will run.
Why reads are harder
To answer a read query, Cassandra must check every place the data might live: the memtable (in RAM) and some number of SSTables on disk (from newest to oldest). Two mechanisms reduce the number of SSTables it has to check:
Bloom filters: a probabilistic data structure that can quickly answer "is partition key X definitely not in this SSTable?" A bloom filter can have false positives (it might say "maybe" when the key isn't there) but no false negatives (if it says "no," the key genuinely isn't in that SSTable). This lets Cassandra skip most SSTables for any given key lookup without reading the actual data files.
Partition indexes: each SSTable has an index of partition keys to byte offsets, so once Cassandra decides to check an SSTable, it can jump directly to the right location rather than scanning the whole file.
These help, but they don't make reads as fast as writes. A write is always O(1): one sequential append, one RAM write. A read is O(number of SSTables containing this partition), and that number grows between compactions. In the worst case (many SSTables, no successful bloom filter rejection, large partition with lots of history), reads touch many files and take many milliseconds.
The data model consequence
Because Cassandra is fast for reads by partition key and expensive for everything else, the data model must be designed around the queries, not around the natural structure of the data.
In a relational database, you model the domain and write the queries to match. You normalize to avoid duplication, add indexes for the columns you filter on, and trust the query planner to figure out the join. In Cassandra, you model the queries first and build the tables to serve them. If you have three different query patterns (look up by user ID, look up by email, look up by session token), you probably need three separate tables. The same data, stored three times, in three different layouts.
There are no server-side joins. There are no arbitrary WHERE clauses across non-partition columns without a secondary index, and secondary indexes have their own tradeoffs. Aggregations require reading the full partition. This is not a limitation waiting to be fixed. It is the design. The schema is a query plan written in DDL.
Tombstones: the silent killer
Deletes in Cassandra are writes. Because SSTables are immutable, you can't remove a record from an existing file. Instead, Cassandra writes a tombstone, a deletion marker with a timestamp. Any read that encounters a tombstone for a key knows to return "no data" for that key in that time range.
Tombstones accumulate. Every partition you've ever deleted has a tombstone sitting in some SSTable. A range scan over a partition (common in time-series or event-log patterns) has to traverse every tombstone for rows in the range and decide "this is deleted, skip it." If a partition has a high update or delete rate, a range scan can hit tens of thousands of tombstones before returning any live data.
Cassandra has a configurable tombstone warning threshold (default: 1,000 tombstones scanned per query) and a tombstone failure threshold (default: 100,000). Cross the failure threshold and the query fails with an error. This is not hypothetical; it happens in production, usually in systems that do frequent deletes and don't have aggressive enough compaction to clean them up.
Tombstones are eventually removed by compaction, but only after a grace period
(the gc_grace_seconds setting, defaulting to 10 days). This period
exists to prevent a deleted row from "resurrecting": if a node was offline when
the delete was written and comes back before the tombstone is removed, the node
would otherwise see only its stale, undeleted copy and serve it. The grace period
gives all replicas time to sync the tombstone before it's purged.
Managing tombstones in practice means: using TTL (time-to-live) instead of explicit deletes where possible (TTL expiration is built into the SSTable format and handled more cleanly), tuning compaction to run aggressively on partitions with high delete rates, and being careful about data models that mix high-write and high-delete patterns in the same partition.
Cassandra's write speed is real, and so is the cost
Cassandra's architecture is well-suited to a specific problem: write-heavy workloads at high scale, where the data access pattern is known in advance and reads are by partition key. Time-series data, event streams, IoT sensor readings, user activity logs: all of these fit the model well.
The cost is that you're programming for the database. The schema is a contract about how data will be accessed; change the access pattern and you may need to rebuild the tables. Reads are best-effort unless you've designed for them explicitly. Deletes have a cost that accumulates until compaction runs. Arbitrary queries are not the deal.
If you're reaching for Cassandra because a benchmark impressed you, spend some time first on whether your read patterns match the model. A system that writes a million rows per second and takes 30 seconds to answer a dashboard query isn't fast. It's fast in one direction.
Back to writing