CQL looks like SQL on purpose. You create tables, insert rows and select columns, and for a while Cassandra feels like a relational database with odd restrictions on WHERE clauses. Then something surprising happens. An UPDATE creates a row that did not exist. A row disappears after you null out one column. A list gains duplicate elements after a retry. Two writes a millisecond apart land in the wrong order. None of this is a bug. It follows directly from what Cassandra actually stores, which is not rows in the relational sense but timestamped cells grouped into sorted partitions.

Designing tables around queries, choosing partition keys and bounding partition size are covered in Cassandra data modeling basics. This article goes one level down, to the data model the storage engine implements. It covers the hierarchy from keyspace to cell, the metadata on every cell, the merge rule that reconciles replicas, and how INSERT, UPDATE, static columns, collections and deletes map onto that model. Once you can predict what cells a statement writes, the surprises above become obvious, and so do the fixes.

Advertisement

Two views of one table

Every Cassandra table has two faces. The CQL face is a table of rows and columns with a primary key. The storage face is a map of partitions: each partition key is hashed to a token that decides which replicas own it, and each partition holds a sorted sequence of rows, ordered by the clustering columns. Each row holds cells only for the non-key columns that were written; an unwritten column stores nothing, not even a null, so sparse tables are cheap.

What CQL shows you versus what a replica storesCQL view: a table of rowsdevice_id | ts | reading | fw (static)d-17 | 10:00 | 21.5 | 4.2d-17 | 10:01 | 21.7 | 4.2d-17 | 10:02 | null | 4.2rows look complete; nulls look like valuesStorage view: partition d-17 (token hash)static rowfw = 4.2 @ t=900row ts=10:00liveness @ t=1000 | reading 21.5 @ t=1000row ts=10:01liveness @ t=1060 | reading 21.7 @ t=1060row ts=10:02liveness @ t=1120 | reading: tombstone @ t=1125rows sorted by clustering key inside the partitionevery cell: value + write timestamp (+ TTL)replicas merge versions cell by cellMerge rule (per cell)highest write timestamp winsequal timestamps: deletion winsexpired TTL cell acts as tombstonerow exists if liveness or any live cellno read-before-write on normal writes
The CQL view shows complete rows with nulls. The storage view shows a partition with a static row and clustering-ordered rows, where each cell carries its own write timestamp and a deleted cell is a tombstone.

The hierarchy, from top to bottom, is: a keyspace, which holds replication settings; a table; a partition, identified by the partition key and the unit of distribution; a row, identified within its partition by the clustering key; and a cell, one column value in one row. A table without clustering columns has exactly one row per partition. A table with them can hold many rows per partition, stored contiguously and in clustering order. That is what makes a slice such as WHERE device_id = ? AND ts > ? a single sequential read.

The cell: value, timestamp, TTL

A cell is more than a value. Each one stores the value, a write timestamp (by convention microseconds since the epoch), and optionally a TTL with the expiry time it implies. A deleted cell becomes a tombstone: a marker with a timestamp and a local deletion time, kept so that the deletion can override older copies on other replicas and in older SSTables. The write timestamp is assigned per statement, either by the client driver or by the coordinator, depending on driver configuration. You can also set it explicitly with USING TIMESTAMP.

Two CQL functions expose this metadata, and they are the best debugging tool for the whole model:

CREATE TABLE telemetry.readings (
  device_id  text,
  ts         timestamp,
  reading    double,
  unit       text,
  fw         text STATIC,
  PRIMARY KEY ((device_id), ts)
) WITH CLUSTERING ORDER BY (ts DESC);

INSERT INTO telemetry.readings (device_id, ts, reading, unit)
VALUES ('d-17', '2026-09-30 10:00:00+0000', 21.5, 'C') USING TTL 604800;

SELECT reading, WRITETIME(reading), TTL(reading)
FROM telemetry.readings WHERE device_id = 'd-17';
--  reading | writetime(reading) | ttl(reading)
--     21.5 |   1790762400000000 |       604790

Columns of one row can therefore have different ages and expiry times: a row is a set of cells sharing a primary key, not an atomic record.

Advertisement

Reconciliation: last write wins, per cell

Writes in Cassandra are blind. A normal INSERT or UPDATE does not read the existing row. It appends new cells to the commit log and memtable, as described in the Cassandra write and read path. Different replicas, memtables and SSTables can therefore hold different versions of the same cell. Reads, compaction and repair reconcile them with one rule applied cell by cell: the version with the highest write timestamp wins. When a live cell and a tombstone carry the same timestamp, the deletion wins. Other exact ties are resolved deterministically by the storage engine, but correct applications never depend on how.

Three consequences follow. First, the merge is commutative and idempotent. Replaying the same write, or receiving writes in a different order on different replicas, converges to the same result, which is what lets hinted handoff, read repair and repair work without coordination. Second, order is decided by timestamps, not by arrival. If two application servers write the same cell and one server's clock runs 20 ms fast, its writes win for 20 ms after the other's, whichever arrives first. Third, cells merge independently. Two concurrent updates that set different columns of the same row both survive. Two that set the same column keep one value and silently drop the other, with no error.

When order matters, use well-synchronised client timestamps, model each change as a new row, or pay for a Paxos-based lightweight transaction.

Row liveness: why INSERT and UPDATE differ

Both INSERT and UPDATE are upserts: neither checks whether the row exists. They differ in one detail that surprises nearly everyone. An INSERT writes a primary key liveness marker for the row, carrying the statement's timestamp and TTL. An UPDATE writes only the cells for the columns it sets. A row is visible if it has live liveness info or at least one live cell. So:

-- Case A: row created by UPDATE
UPDATE telemetry.readings SET reading = 22.0
 WHERE device_id = 'd-9' AND ts = '2026-09-30 11:00:00+0000';
DELETE reading FROM telemetry.readings
 WHERE device_id = 'd-9' AND ts = '2026-09-30 11:00:00+0000';
-- SELECT returns no row: the only live cell is gone and there is no liveness marker.

-- Case B: row created by INSERT
INSERT INTO telemetry.readings (device_id, ts, reading)
VALUES ('d-9', '2026-09-30 11:00:00+0000', 22.0);
DELETE reading FROM telemetry.readings
 WHERE device_id = 'd-9' AND ts = '2026-09-30 11:00:00+0000';
-- SELECT returns the row with reading = null: the liveness marker keeps it alive.

TTL follows the same split. INSERT ... USING TTL applies the TTL to the liveness marker and to every cell written, so the whole row expires. UPDATE ... USING TTL applies it only to the cells being set. A row created by INSERT with a TTL and later updated without one keeps the updated cells after the marker expires, so the row survives in part. If you need row-level existence semantics, such as a presence flag or a membership table, create rows with INSERT and set TTLs consistently.

Setting a column to null is not a no-op either. INSERT ... VALUES (..., null) or SET col = null writes a tombstone for that cell. Drivers that bind every column of a prepared statement, including the ones the application left null, therefore create tombstones on every write. Leave the value unset instead, which modern protocol versions support, so that nothing is written for that column.

Static columns

A column declared STATIC belongs to the partition, not to a row. It is stored once per partition, in a special static row before the clustering rows, and every row of the partition returns the same value for it. In the example, fw is the firmware version of the device: one value per device, read alongside its readings without a second table or a join. Static columns exist only in tables with clustering columns, and writing one needs only the partition key. They follow the same last-write-wins rule, and a lightweight transaction conditioned on a static column is a common guard for per-partition state such as an owner.

Collections and user-defined types

Sets, lists and maps come in two storage forms, and choosing between them is a data-model decision, not a syntax detail.

FormStorageConsequences
Non-frozen set<text>, map<text,int>One cell per element. The element key (the set value or map key) is part of the cell path.Add or remove one element without reading. Per-element timestamps and TTLs. Overwriting the whole collection writes a tombstone for the old contents first.
Non-frozen list<text>One cell per element, keyed by a time-based id that the coordinator generates.Append and prepend are blind but not idempotent: a retried append adds a duplicate. Setting or removing by index, and removing by value, need an internal read-before-write.
frozen<...> collection or UDTOne cell holding the serialised value.Always written and read as a whole. No partial updates, no per-element TTL, no collection tombstones. Can be used in a primary key.
Non-frozen UDTOne cell per field.Update fields individually; the same tombstone rule applies when the whole value is overwritten.

The hidden cost is the overwrite tombstone. INSERT INTO t (k, tags) VALUES (1, {'a','b'}) on a non-frozen set does not just add two cells. It first writes a range tombstone that clears whatever set was there, because an INSERT must replace the value. A workload that re-inserts whole rows containing collections accumulates one tombstone per row per write, as covered in Cassandra tombstones. Use SET tags = tags + {'c'} for incremental changes and frozen types for values always written whole. Collections are read whole, so keep them small; anything unbounded belongs in clustering rows.

Deletes at every granularity

A delete never removes data in place. It writes a tombstone at the smallest scope that covers what you asked for, and compaction purges the shadowed data and the tombstone together after gc_grace_seconds, once repair has had a chance to spread the tombstone to every replica.

  • Cell tombstone: DELETE reading FROM ... WHERE device_id = ? AND ts = ?, or writing null.
  • Row tombstone: DELETE FROM ... WHERE device_id = ? AND ts = ?. It covers every cell of that row with an older timestamp, including the liveness marker.
  • Range tombstone: DELETE FROM ... WHERE device_id = ? AND ts < ?. One marker covers a slice of clustering keys, and it is far cheaper than thousands of row tombstones.
  • Partition tombstone: DELETE FROM ... WHERE device_id = ?. It covers the whole partition, including static columns.
  • TTL expiry: an expired cell turns into a tombstone. It is invisible to reads, but it is still data on disk until compaction.

A tombstone shadows only data with a lower timestamp. A write with an older explicit USING TIMESTAMP than a delete is silently invisible, which is useful for idempotent replays and baffling when you do not expect it. How tombstones sit in SSTables and get purged is covered in the SSTable format.

Worked example: tracing the cells

Follow one partition through five statements, with timestamps t in microseconds, simplified:

tStatementCells written in partition d-21
900UPDATE ... SET fw='4.1' WHERE device_id='d-21'static fw='4.1'@900
1000INSERT (d-21, 10:00, 21.5, 'C')row 10:00: liveness@1000, reading=21.5@1000, unit='C'@1000
1060UPDATE SET reading=21.7 WHERE ... ts=10:01row 10:01: reading=21.7@1060 (no liveness)
1100UPDATE SET fw='4.2' WHERE device_id='d-21'static fw='4.2'@1100 shadows @900
1125DELETE reading WHERE ... ts=10:01row 10:01: tombstone reading@1125

A read now merges these cells. Row 10:00 is complete. Row 10:01 has no liveness marker and its only cell is a tombstone, so it is gone. The static column reads 4.2 on every surviving row. Now suppose the statement at t=1060 had been retried by a client after a timeout and landed on a lagging replica with t=1130. The retry is newer than the delete, so the reading reappears. The fix is a client-generated timestamp that stays the same across retries, which makes the retry an exact duplicate that the merge absorbs.

Failure modes and trade-offs

  • Lost updates from read-modify-write. Reading a value, changing it in the application and writing it back loses concurrent changes. Use collection operations, counters, a lightweight transaction, or a new row per change.
  • Clock skew reordering. Last-write-wins is only as good as the clocks. Monitor NTP offset on application hosts and Cassandra nodes, and never mix client-assigned and coordinator-assigned timestamps for the same data.
  • Ghost rows and vanishing rows. Both come from row liveness. Pick INSERT or UPDATE deliberately for rows whose existence matters.
  • Collections as queues or logs. They grow, get read whole and churn tombstones. Model them as clustering rows with a TTL or with time-windowed compaction, compared in compaction strategies.
  • Explicit timestamps in the future. A bug that writes one far ahead makes that cell immune to every later normal write until the timestamp is passed. Validate explicit timestamps.

The trade-off is deliberate: blind writes and per-cell last-write-wins buy cheap writes, availability and simple convergence, at the cost of multi-cell transactions and, without overlapping consistency levels, read-your-own-writes.

What to do next

  1. Pick your busiest table and write down, for each statement the application runs, exactly which cells it writes, including liveness markers and tombstones.
  2. Run SELECT col, WRITETIME(col), TTL(col) on a few real rows and confirm the timestamps and TTLs match what you expect.
  3. Check your driver's timestamp generator and null-binding behaviour, and switch to client-side timestamps and unset values where appropriate.
  4. Search your schema for non-frozen collections that are re-inserted whole or can grow without bound, and convert them to frozen types or clustering rows.
  5. Review every row whose existence carries meaning, and make sure it is created with INSERT and carries a consistent TTL.
  6. Enable tracing on a representative read and look at how many tombstone cells it scans; if the count is high, revisit the delete pattern before tuning anything else.
Key takeaway: Underneath CQL, a Cassandra table is a map of partitions, each holding clustering-ordered rows of independently timestamped cells. Writes are blind appends, and replicas converge by keeping the highest timestamp per cell, with deletions winning ties. That single rule explains ghost and vanishing rows (INSERT writes a liveness marker, UPDATE does not), tombstones from null values and collection overwrites, duplicate list appends on retry, and reordering under clock skew. Model with the cells in mind, use stable client-side timestamps, and keep collections small.