+1 (415) 649-9454

Streaming Graph Changes: Neo4j CDC, Cursors, and Kafka

Every graph project eventually gets the same question from a neighbouring team: "can you tell us when something in the graph changes?" Historically the answers were bad — poll a lastUpdated property, write triggers with APOC, or bolt on an outbox table and hope nobody writes around it. Neo4j Change Data Capture (CDC) removes all of that. The database itself keeps an ordered, queryable log of committed changes, and you read it with a Cypher procedure or let the Kafka connector read it for you.

This tutorial turns CDC on, reads changes by hand so you understand the payload, then streams them to Kafka and to a downstream search index. Our import tutorial covered data going into the graph; this is the other direction.

What CDC actually gives you

  • Committed changes only. Nothing appears in the stream that was rolled back.
  • Ordered, per-transaction change events, each with a txId, a sequence number, the element's ID, its labels or relationship type, the keys of its constraints, and before/after property maps.
  • A change identifier (a cursor) that you persist on the consumer side. Restart from the cursor and you resume exactly where you stopped.
  • Enrichment modes that decide how much detail lands in the payload.

That last point is the only configuration decision that matters:

ModeWhat you getUse it when
OFFnothing (default on self-managed)CDC not in use
DIFFelement id, labels/type, keys, and only the properties that changedaudit trails, cache invalidation, event triggers
FULLthe entire before and after state of the elementreplicating the graph into another store

FULL costs more transaction-log volume than DIFF. Pick DIFF unless a consumer genuinely needs to rebuild whole records.

Step 1 — enable CDC

On a self-managed 5.x or 2025.x/2026.x database, CDC is a per-database setting applied from the system database:

:use system
ALTER DATABASE neo4j SET OPTION txLogEnrichment 'DIFF';
SHOW DATABASES YIELD name, options;

On Aura, set it on the instance (Console → instance → CDC, or enable_cdc via the Aura API); the mode values are the same. Note two things before you flip it in production: enrichment is applied to new transactions only, and your transaction logs get bigger, so check db.tx_log.rotation.retention_policy — the retention window is effectively how far back a stalled consumer can recover.

Step 2 — read changes by hand

CDC is exposed through three procedures. Get a starting cursor first:

CALL cdc.current();          // cursor as of now — start here for "changes from now on"
CALL cdc.earliest();         // oldest change still in the logs

Make a change in another session:

CREATE (c:Customer {id: 'C-1001', email: 'ada@example.com', tier: 'gold'});
MATCH (c:Customer {id: 'C-1001'}) SET c.tier = 'platinum';

Now drain from your cursor:

CALL cdc.query('<cursor-from-cdc.current>')
YIELD id, txId, seq, metadata, event
RETURN txId, seq, event.eventType, event.operation,
       event.labels, event.keys, event.state
ORDER BY txId, seq;

For the SET above, a DIFF-mode event looks like this:

{
  "eventType": "n",
  "operation": "u",
  "labels": ["Customer"],
  "keys": {"Customer": [{"id": "C-1001"}]},
  "state": {
    "before": {"properties": {"tier": "gold"}},
    "after":  {"properties": {"tier": "platinum"}}
  }
}

eventType is n for node or r for relationship; operation is c, u, or d. metadata carries the executing user, the transaction start/commit times, and the app/executingUser fields — which is what makes CDC usable as an audit source, not just a replication feed.

Two habits to build now. Filter server-side, not in your consumer:

CALL cdc.query($cursor, [
  {select: 'n', labels: ['Customer'], operation: 'u'},
  {select: 'r', type: 'PLACED'}
]) YIELD event RETURN event;

And persist the cursor — the id of the last event you successfully processed — in the same transaction as whatever side effect you performed. If you commit the side effect and lose the cursor, you get duplicates; if you commit the cursor and lose the side effect, you get silent data loss. Duplicates are the safer failure, so write the cursor last and make consumers idempotent.

Step 3 — a minimal Python consumer

import os, time, neo4j

driver = neo4j.GraphDatabase.driver(
    os.environ["NEO4J_URI"],
    auth=(os.environ["NEO4J_USER"], os.environ["NEO4J_PASSWORD"]),
)

SELECTORS = [{"select": "n", "labels": ["Customer"]}]

def load_cursor(session):
    rec = session.run("MATCH (s:CdcState {name:'search-index'}) RETURN s.cursor AS c").single()
    if rec and rec["c"]:
        return rec["c"]
    return session.run("CALL cdc.current()").single()[0]

def save_cursor(session, cursor):
    session.run(
        "MERGE (s:CdcState {name:'search-index'}) SET s.cursor = $c, s.updatedAt = datetime()",
        c=cursor,
    )

with driver.session(database="neo4j") as session:
    cursor = load_cursor(session)
    while True:
        rows = session.run(
            "CALL cdc.query($cursor, $selectors) YIELD id, txId, seq, event "
            "RETURN id, txId, seq, event", cursor=cursor, selectors=SELECTORS,
        ).data()
        for row in rows:
            handle(row["event"])      # your idempotent side effect
            cursor = row["id"]
        if rows:
            save_cursor(session, cursor)
        else:
            time.sleep(1.0)           # nothing new; back off

That is the whole pattern: poll, apply, advance, checkpoint. Run one consumer per downstream system with its own CdcState node, so a slow search indexer never blocks your audit exporter.

Step 4 — hand it to Kafka instead

For anything with more than one consumer, do not maintain your own poller. The Neo4j Connector for Kafka has a source mode built on CDC:

{
  "name": "neo4j-cdc-source",
  "config": {
    "connector.class": "org.neo4j.connectors.kafka.source.Neo4jConnector",
    "neo4j.uri": "neo4j+s://xxxx.databases.neo4j.io",
    "neo4j.authentication.basic.username": "neo4j",
    "neo4j.authentication.basic.password": "${file:/opt/secrets:neo4j_password}",
    "neo4j.source-strategy": "CDC",
    "neo4j.start-from": "NOW",
    "neo4j.cdc.poll-interval": "1s",
    "neo4j.cdc.topic.customers.patterns": "(:Customer)",
    "neo4j.cdc.topic.orders.patterns": "(:Customer)-[:PLACED]->(:Order)",
    "key.converter": "org.apache.kafka.connect.json.JsonConverter",
    "value.converter": "org.apache.kafka.connect.json.JsonConverter"
  }
}

Connect owns the offset, the retries, and the topic fan-out. Your consumers become ordinary Kafka consumers, and the graph stops being a special case in your event architecture.

What CDC is good for (and what it is not)

Good fits we see repeatedly on client engagements:

  • Cache and search invalidation — re-index only the entities that actually changed, instead of nightly full rebuilds.
  • Audit and compliancebefore/after plus executing user, without trigger code nobody wants to own.
  • Feeding ML feature stores — the streaming half of the graph-features pattern in our fraud detection post.
  • Fan-out to a read model — push denormalised documents to Elasticsearch or a warehouse, keeping Neo4j as the write model.

Where it is the wrong tool: CDC is not a backup, it is bounded by transaction-log retention. It is not synchronous — do not use it where a consumer must react before the transaction commits. And it is not free: FULL enrichment on a write-heavy graph can materially increase log volume and disk I/O, so measure it on a rehearsal environment before enabling it in production.

Before you enable it in production

  1. Set enrichment to DIFF first; upgrade to FULL only when a consumer proves it needs whole records.
  2. Size transaction-log retention against your worst realistic consumer outage — a weekend is a good floor.
  3. Store one cursor per consumer, checkpoint after the side effect, and make every side effect idempotent.
  4. Alert on consumer lag (commit timestamp of the last processed event vs. now), not on process liveness. A consumer that is up and 40 minutes behind is the failure mode that hurts.
  5. Use server-side selectors so you are not shipping the whole graph's change volume to a consumer that cares about two labels.

CDC turns a graph from a system you query into a system that tells you when it changed — which is usually the missing piece when a Neo4j deployment has to integrate with everything else in the estate. If you are designing that integration, or your current one is a pile of polling jobs, get in touch and we will review it with you.