+1 (740) 926-6856

Getting Changes Out of Cassandra: CDC, Debezium, and the Dual-Write Trap

Cassandra is usually not the last stop for a row. The search team wants an index, analytics wants the table in the warehouse, another service wants an event every time an order changes state. The question arrives the same way every time: can we just stream changes out of the cluster?

You can. The mechanism exists, it is supported, and it is narrower than most teams expect. This is what change data capture on Cassandra actually gives you, what work it leaves on your side, and the two cases where we tell clients not to use it.

What Cassandra CDC actually is

CDC is a per-table flag that changes what happens to commit log segments:

ALTER TABLE orders.order_state WITH cdc = true;

With cdc_enabled: true in cassandra.yaml, a commit log segment containing mutations for any CDC-enabled table is hard-linked into cdc_raw when it is discarded, instead of being deleted. That is the whole contract. Cassandra does not deliver events anywhere. It leaves you a directory of binary commit log segments per node and expects a consumer to read and delete them.

Four consequences follow directly from that design, and every one of them is something you will have to engineer around.

1. It is per-replica, not per-cluster. A write at RF=3 lands in the commit log of three nodes, so a naive consumer sees the same mutation three times. Deduplication is your problem. The usual key is the partition key plus clustering key plus the cell write timestamp, which is stable across replicas for the same mutation.

2. It is mutation-level, not row-level. The commit log records what was written, not the resulting row. An UPDATE that touches one column gives you one column, not the row's new state. Deletes appear as tombstones — range tombstones included. If your downstream consumer wants "here is the row as it now stands," you must read the row back from Cassandra after the event, and accept that you will read a state newer than the event you were handed.

3. There is no ordering guarantee across partitions, and across replicas only the write timestamp orders things. Within a partition, timestamps are usable. Across the cluster, treat the feed as an unordered set of timestamped facts and make consumers idempotent. Anything that needs global ordering does not belong on this feed.

4. Back pressure is a disk limit, and it bites. cdc_total_space_in_mb (default 4096, or an eighth of the volume) caps cdc_raw. When the consumer falls behind and that cap is reached, writes to CDC-enabled tables are rejected with a WriteFailureException while non-CDC tables keep serving. That is a deliberate safety valve, and it is also a production outage for one table caused by a stalled downstream consumer. Alert on cdc_raw directory size long before the cap, and decide in advance whether you would rather drop the feed than drop writes.

One improvement worth knowing: since 4.0, segments are linked into cdc_raw on discard rather than being readable only after flush, and an index file per segment tells the consumer how far the segment has been synced. Consumers can follow the active segment instead of waiting on flush, so latency is seconds rather than however long a memtable sits. If you are on 3.11, assume flush-bound latency and plan accordingly.

Using Debezium instead of writing your own reader

Parsing commit log segments yourself is possible — the CommitLogReader API is public — and we have not once seen a home-grown reader that stayed correct through an upgrade. Use the Debezium connector for Cassandra if you go this route.

The important structural fact: unlike Debezium's connectors for MySQL or Postgres, the Cassandra connector is an agent that runs on every Cassandra node, because cdc_raw is local to the node. That changes the operational picture:

  • It is a process on your database hosts, competing for CPU and I/O with the database. Budget for it, and keep it off the same disk as the commit log if you can.
  • Node replacement, scale-out, and rolling restarts must add or remove agents. Bake it into your node lifecycle runbook or you will silently lose part of the feed on the next bootstrap.
  • The agent deletes processed segments from cdc_raw. If it dies and nobody notices, you are on the clock against cdc_total_space_in_mb and, eventually, write rejections on that table.
  • It emits to Kafka with the duplication described above intact. Dedup lives in your stream processing layer or in an idempotent sink.

The connector also needs a schema view and handles schema changes conservatively. Adding a column is routine. Dropping or retyping one while the feed is live is the kind of change that deserves a maintenance window and a consumer you have tested against the new shape.

The alternative most teams should consider first: the outbox

Before committing to CDC, ask what the downstream consumer actually needs. If the answer is "business events" — order placed, subscription cancelled — rather than "every physical mutation," an application-level outbox is usually simpler and more robust:

CREATE TABLE app.event_outbox (
  shard      int,
  bucket     text,        -- e.g. '2026-02-14T09'
  event_id   timeuuid,
  aggregate  text,
  payload    text,
  PRIMARY KEY ((shard, bucket), event_id)
) WITH CLUSTERING ORDER BY (event_id ASC)
  AND default_time_to_live = 604800;

The service writes the domain row and the outbox row, then a publisher walks recent buckets and ships events onward. You get events shaped for consumers, ordering within a shard, a TTL that cleans up after itself, and no agent on your database nodes.

Be honest about the trade-off: Cassandra has no multi-partition transaction, so the domain write and the outbox write are not atomic. A BATCH with both statements gives you a logged batch — atomicity in the sense that Cassandra will retry until all statements apply, at a real latency and coordinator cost, and still not isolation. In practice you design the consumer to tolerate a missing or duplicated event and you reconcile. That is the same idempotency work CDC requires, done in a place you control, with payloads you designed. (On Cassandra 5.1, Accord's multi-partition transactions change this calculus; we would still not rebuild a working outbox to chase it on day one.)

And then there is the pattern we push back on hardest: dual writes from the application — write to Cassandra, then write to Kafka or Elasticsearch directly. It works until the second write fails, the process is killed between the two, or a retry lands out of order. Then the systems disagree, with no record of how. If you cannot say how you would detect and repair that divergence, you do not have a pipeline; you have a drift generator with good latency.

Choosing

A short decision path we use in reviews:

  • Analytics or warehouse loads, hourly or daily. Skip CDC. Run a Spark job against the cluster, or export SSTables. Batch is cheaper than a live feed you must operate.
  • Search index or cache that can tolerate seconds of lag and needs every column change. CDC with Debezium is a fair fit, provided you own dedup and can staff the agents.
  • Business events for other services. Outbox table. Almost always.
  • Strict ordering, exactly-once, or cross-partition atomicity. Cassandra is not the source of truth you want for that flow. Say it early. This is the same honesty we apply in when Cassandra is the wrong tool — the failure mode is trying to buy a guarantee the storage engine does not sell.

If you turn CDC on, monitor these

  1. cdc_raw size per node, as a percentage of cdc_total_space_in_mb. Page well before the cap; writes to CDC tables stop there.
  2. Consumer lag per node, measured as the age of the oldest unprocessed segment. Per node, not averaged — one stalled agent is the whole problem.
  3. Agent liveness, tied to the node's own health checks so a replaced node without an agent is an alert, not a discovery.
  4. Duplicate rate at the sink. A steady ~3x at RF=3 is expected. A change in that ratio means a replica or agent has stopped feeding.
  5. Commit log disk I/O. The agent reads the same volume the write path is writing. On saturated disks this shows up as p99 write latency, not as a CDC metric.

CDC on Cassandra is a reasonable tool with sharp edges: real mechanics, thin delivery guarantees, and an operational footprint on your database nodes. Teams get into trouble by assuming it behaves like a relational CDC feed. It does not, and the gap is exactly the work.

If you are designing a change feed off a production cluster — or trying to work out why writes to one table started failing while everything else looked fine — get in touch. A cluster and data-model review covers the CDC path alongside the compaction and repair posture it competes with for I/O.