Quickstart: Sink with CUD
When to use the CUD strategy
Use the CUD strategy when each message states its own operation.
Instead of you declaring a mapping once in the connector configuration, every message carries an
op field telling the connector whether to create, update, merge or delete — and the connector
generates the matching Cypher.
Pick it when:
-
the producer already knows the intent of each change, and deletes are part of the stream;
-
one topic must carry operations on several different labels and relationship types;
-
you want a single stream to drive a full entity lifecycle.
| Unlike Pattern, CUD has no one-mapping-per-topic restriction — a single topic can carry nodes and relationships of any label. This guide uses one topic for everything. |
What you will build
One connector consuming a crm topic, driving a full lifecycle:
crm topic ──> create (:Customer), (:Product)
create (:Customer)-[:PURCHASED]->(:Product)
update (:Customer)
merge (:Customer)
delete (:Customer)
No source connector is involved — messages are produced by hand, the way an external system would produce them.
| This guide uses plain JSON messages, so no Schema Registry is required. See Using Avro, Protobuf or JSON Schema for Avro, Protobuf and JSON Schema. |
Prerequisites
-
Docker Compose v2.20.3 or later.
-
The Neo4j Connector for Kafka archive, unpacked as described in Installation.
Start the environment
Copy the following Docker Compose file into a new directory.
---
services:
neo4j:
image: neo4j:2026-enterprise
hostname: neo4j
container_name: neo4j
# this is to ensure you have the latest 2026.x version of the database
pull_policy: always
ports:
- "7474:7474"
- "7687:7687"
environment:
NEO4J_AUTH: neo4j/password
NEO4J_ACCEPT_LICENSE_AGREEMENT: "yes"
NEO4J_server_memory_heap_max__size: "4G"
healthcheck:
test: [ "CMD", "cypher-shell", "-u", "neo4j", "-p", "password", "RETURN 1" ]
start_period: 2m
start_interval: 10s
interval: 30s
timeout: 10s
retries: 5
broker:
image: confluentinc/cp-server:7.8.0
hostname: broker
container_name: broker
ports:
- "9092:9092"
- "9101:9101"
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: 'broker,controller'
KAFKA_CONTROLLER_QUORUM_VOTERS: '1@broker:29093'
KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER'
KAFKA_LISTENERS: 'PLAINTEXT://broker:29092,CONTROLLER://broker:29093,PLAINTEXT_HOST://0.0.0.0:9092'
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT'
KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://broker:29092,PLAINTEXT_HOST://localhost:9092'
KAFKA_INTER_BROKER_LISTENER_NAME: 'PLAINTEXT'
KAFKA_LOG_DIRS: '/tmp/kraft-combined-logs'
CLUSTER_ID: 'MkU3OEVBNTcwNTJENDM2Qk'
KAFKA_METRIC_REPORTERS: io.confluent.metrics.reporter.ConfluentMetricsReporter
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
KAFKA_CONFLUENT_LICENSE_TOPIC_REPLICATION_FACTOR: 1
KAFKA_CONFLUENT_BALANCER_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_JMX_PORT: 9101
KAFKA_JMX_HOSTNAME: localhost
KAFKA_CONFLUENT_SCHEMA_REGISTRY_URL: http://schema-registry:8081
CONFLUENT_METRICS_REPORTER_BOOTSTRAP_SERVERS: broker:29092
CONFLUENT_METRICS_REPORTER_TOPIC_REPLICAS: 1
CONFLUENT_METRICS_ENABLE: 'true'
CONFLUENT_SUPPORT_CUSTOMER_ID: 'anonymous'
healthcheck:
test: [ "CMD", "nc", "-z", "localhost", "9092" ]
start_period: 5m
start_interval: 10s
interval: 1m
timeout: 10s
retries: 5
schema-registry:
image: confluentinc/cp-schema-registry:7.8.0
hostname: schema-registry
container_name: schema-registry
depends_on:
- broker
ports:
- "8081:8081"
environment:
SCHEMA_REGISTRY_HOST_NAME: schema-registry
SCHEMA_REGISTRY_KAFKASTORE_BOOTSTRAP_SERVERS: 'broker:29092'
SCHEMA_REGISTRY_LISTENERS: http://0.0.0.0:8081
healthcheck:
test: [ "CMD", "nc", "-z", "localhost", "8081" ]
start_period: 5m
start_interval: 10s
interval: 1m
timeout: 10s
retries: 5
connect:
image: confluentinc/cp-server-connect:7.8.0
hostname: connect
container_name: connect
depends_on:
- broker
- schema-registry
ports:
- "8083:8083"
volumes:
- ./plugins:/tmp/connect-plugins
environment:
CONNECT_BOOTSTRAP_SERVERS: 'broker:29092'
CONNECT_REST_ADVERTISED_HOST_NAME: connect
CONNECT_GROUP_ID: compose-connect-group
CONNECT_CONFIG_STORAGE_TOPIC: docker-connect-configs
CONNECT_CONFIG_STORAGE_REPLICATION_FACTOR: 1
CONNECT_OFFSET_FLUSH_INTERVAL_MS: 10000
CONNECT_OFFSET_STORAGE_TOPIC: docker-connect-offsets
CONNECT_OFFSET_STORAGE_REPLICATION_FACTOR: 1
CONNECT_STATUS_STORAGE_TOPIC: docker-connect-status
CONNECT_STATUS_STORAGE_REPLICATION_FACTOR: 1
CONNECT_KEY_CONVERTER: org.apache.kafka.connect.storage.StringConverter
CONNECT_VALUE_CONVERTER: io.confluent.connect.avro.AvroConverter
CONNECT_VALUE_CONVERTER_SCHEMA_REGISTRY_URL: http://schema-registry:8081
# CLASSPATH required due to CC-2422
CLASSPATH: /usr/share/java/monitoring-interceptors/monitoring-interceptors-7.8.0.jar
CONNECT_PRODUCER_INTERCEPTOR_CLASSES: "io.confluent.monitoring.clients.interceptor.MonitoringProducerInterceptor"
CONNECT_CONSUMER_INTERCEPTOR_CLASSES: "io.confluent.monitoring.clients.interceptor.MonitoringConsumerInterceptor"
CONNECT_PLUGIN_PATH: "/usr/share/java,/usr/share/confluent-hub-components,/tmp/connect-plugins"
CONNECT_LOG4J_LOGGERS: org.apache.zookeeper=ERROR,org.I0Itec.zkclient=ERROR,org.reflections=ERROR
healthcheck:
test: [ "CMD", "nc", "-z", "localhost", "8083" ]
start_period: 5m
start_interval: 10s
interval: 1m
timeout: 10s
retries: 5
control-center:
image: confluentinc/cp-enterprise-control-center:7.8.0
hostname: control-center
container_name: control-center
depends_on:
- broker
- schema-registry
- connect
ports:
- "9021:9021"
environment:
CONTROL_CENTER_BOOTSTRAP_SERVERS: 'broker:29092'
CONTROL_CENTER_CONNECT_CONNECT-DEFAULT_CLUSTER: 'connect:8083'
CONTROL_CENTER_SCHEMA_REGISTRY_URL: "http://schema-registry:8081"
CONTROL_CENTER_REPLICATION_FACTOR: 1
CONTROL_CENTER_INTERNAL_TOPICS_PARTITIONS: 1
CONTROL_CENTER_MONITORING_INTERCEPTOR_TOPIC_PARTITIONS: 1
CONFLUENT_METRICS_TOPIC_REPLICATION: 1
PORT: 9021
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:9021" ]
start_period: 5m
start_interval: 10s
interval: 1m
timeout: 10s
retries: 5
Copy the Neo4j Connector for Kafka JAR into a directory named plugins next to your docker-compose.yml,
so that the directory looks like this:
quickstart/
├─ plugins/
│ ├─ neo4j-kafka-connect-5.5.2.jar
├─ docker-compose.yml
Start the stack:
docker compose up -d
Wait until every service reports as healthy — this takes 90-120 seconds on a first run:
docker compose ps
Confirm that the connector plugin was loaded:
curl -s http://localhost:8083/connector-plugins | grep -o 'org.neo4j.connectors.kafka.[a-z]*.Neo4jConnector'
Both …source.Neo4jConnector and …sink.Neo4jConnector should be listed.
Empty output means the JAR is missing from plugins or failed to load — check
docker compose logs connect.
You can now log in to Neo4j Browser at http://localhost:7474 with the username neo4j and the
password password.
Step 1: Create the topic
docker compose exec broker kafka-topics \
--bootstrap-server broker:29092 \
--create --topic crm --partitions 1 --replication-factor 1
Step 2: Create the sink connector
Save the following as sink.cud.neo4j.json:
{
"name": "Neo4jSinkCudQuickstart",
"config": {
"topics": "crm",
"connector.class": "org.neo4j.connectors.kafka.sink.Neo4jConnector",
"key.converter": "org.apache.kafka.connect.storage.StringConverter",
"value.converter": "org.apache.kafka.connect.json.JsonConverter",
"value.converter.schemas.enable": false,
"neo4j.uri": "neo4j://neo4j:7687",
"neo4j.authentication.type": "BASIC",
"neo4j.authentication.basic.username": "neo4j",
"neo4j.authentication.basic.password": "password",
"neo4j.cud.topics": "crm"
}
}
Two settings name the same topic, and both are required:
-
topics— the Kafka Connect subscription, telling the worker which topics to consume. -
neo4j.cud.topics— telling the connector to interpret those topics as CUD messages.
A topic listed in topics but missing from neo4j.cud.topics is consumed and silently ignored.
Register the connector:
curl -X POST http://localhost:8083/connectors \
-H 'Content-Type:application/json' \
-H 'Accept:application/json' \
-d @sink.cud.neo4j.json
curl -s http://localhost:8083/connectors/Neo4jSinkCudQuickstart/status
Both the connector and its task should report RUNNING.
The CUD message format
Every message is a JSON object. type and op are always required; the rest depends on them.
| Field | Required | Meaning |
|---|---|---|
|
yes |
|
|
yes |
|
|
no |
labels to apply, as an array |
|
except for |
the properties to set |
|
except for |
the lookup key, as an object |
|
no |
on |
| Field | Required | Meaning |
|---|---|---|
|
yes |
|
|
yes |
|
|
yes |
the relationship type |
|
yes |
endpoint objects, each with |
|
no |
the properties to set on the relationship |
|
no |
lookup key for the relationship itself |
op and type are case-insensitive. Inside from and to, op accepts only match (the
default) or merge — it decides whether a missing endpoint is created.
|
|
Step 3: Create nodes
docker compose exec -T broker kafka-console-producer \
--bootstrap-server broker:29092 \
--topic crm <<'EOF'
{"type":"node","op":"create","labels":["Customer"],"properties":{"id":1,"name":"John Doe","tier":"standard"}}
{"type":"node","op":"create","labels":["Customer"],"properties":{"id":2,"name":"Mary Smith","tier":"standard"}}
{"type":"node","op":"create","labels":["Product"],"properties":{"id":100,"name":"Laptop"}}
EOF
Each of these becomes CREATE (n:Customer) SET n = $properties.
Verify in Neo4j Browser at http://localhost:7474/browser/:
MATCH (n) RETURN labels(n) AS labels, n.id AS id, n.name AS name ORDER BY id
Step 4: Create a relationship
The endpoints are looked up by their ids, so they must already exist:
docker compose exec -T broker kafka-console-producer \
--bootstrap-server broker:29092 \
--topic crm <<'EOF'
{"type":"relationship","op":"create","rel_type":"PURCHASED","from":{"labels":["Customer"],"ids":{"id":1}},"to":{"labels":["Product"],"ids":{"id":100}},"properties":{"price":999.99,"currency":"USD"}}
EOF
This becomes:
MATCH (start:Customer {id: $from.ids.id}) WITH start
MATCH (end:Product {id: $to.ids.id}) WITH start, end
CREATE (start)-[r:PURCHASED]->(end)
SET r = $properties
Because both endpoints default to match, a missing endpoint means the MATCH finds nothing and
no relationship is created — without an error. To create endpoints on demand, set op on them:
docker compose exec -T broker kafka-console-producer \
--bootstrap-server broker:29092 \
--topic crm <<'EOF'
{"type":"relationship","op":"create","rel_type":"PURCHASED","from":{"labels":["Customer"],"ids":{"id":2},"op":"merge"},"to":{"labels":["Product"],"ids":{"id":200},"op":"merge"},"properties":{"price":49.99,"currency":"USD"}}
EOF
Product 200 did not exist and is created by the merge.
MATCH (c:Customer)-[r:PURCHASED]->(p:Product)
RETURN c.name AS customer, p.id AS product, r.price AS price ORDER BY customer
Step 5: Update and merge
update sets properties on an existing node, leaving others intact (SET n += $properties):
docker compose exec -T broker kafka-console-producer \
--bootstrap-server broker:29092 \
--topic crm <<'EOF'
{"type":"node","op":"update","labels":["Customer"],"ids":{"id":1},"properties":{"tier":"gold"}}
EOF
Customer 1’s tier becomes gold and name is untouched.
merge creates the node if the ids do not match anything, and updates it if they do:
docker compose exec -T broker kafka-console-producer \
--bootstrap-server broker:29092 \
--topic crm <<'EOF'
{"type":"node","op":"merge","labels":["Customer"],"ids":{"id":3},"properties":{"name":"Ann Bolin","tier":"standard"}}
{"type":"node","op":"merge","labels":["Customer"],"ids":{"id":3},"properties":{"tier":"gold"}}
EOF
Customer 3 is created by the first message and updated by the second — one node, not two.
MATCH (c:Customer) RETURN c.id AS id, c.name AS name, c.tier AS tier ORDER BY id
Step 6: Delete
A delete needs only ids. Deleting a node that still has relationships fails unless you ask for
a detach delete:
docker compose exec -T broker kafka-console-producer \
--bootstrap-server broker:29092 \
--topic crm <<'EOF'
{"type":"node","op":"delete","labels":["Customer"],"ids":{"id":3}}
{"type":"node","op":"delete","labels":["Customer"],"ids":{"id":2},"detach":true}
EOF
Customer 3 has no relationships, so a plain delete works. Customer 2 bought product 200, so it
needs "detach": true.
MATCH (c:Customer) RETURN c.id AS id, c.name AS name ORDER BY id
Only customer 1 is left, confirming both deletes applied.
delete is for individual entities identified by ids. It is not a way to run arbitrary
Cypher deletes from JSON.
|
Using Avro, Protobuf or JSON Schema
The CUD format is unchanged across serialization formats; only the converters differ:
"value.converter": "io.confluent.connect.avro.AvroConverter",
"value.converter.schema.registry.url": "http://schema-registry:8081"
You then need a schema-aware producer such as kafka-avro-console-producer.
Note that CUD messages are deeply nested and loosely typed by nature, which makes them awkward to
express as a strict Avro or Protobuf schema — plain JSON is often the more natural fit for this
strategy.
See Type support.
Troubleshooting
curl -s http://localhost:8083/connectors/Neo4jSinkCudQuickstart/status
docker compose logs connect
Message value must be convertible to a Map-
The message was not a JSON object — check for a stray blank line or a pretty-printed object split across several lines, since
kafka-console-producertreats each line as its own message. Failed to transform message-
The object parsed but is not a valid operation. Check that
typeisnodeorrelationship, thatopis one ofcreate,update,merge,delete, and thatidsis present for everything exceptcreate. - Relationship silently not created
-
The endpoints default to
op: match. Either create the endpoint nodes first, or set"op": "merge"insidefromandto. - Delete appears to do nothing
-
idsdid not match any entity. Confirm the property name and the value type —{"id": 1}and{"id": "1"}are different lookups. - Task fails on a node delete
-
The node still has relationships. Add
"detach": true. - Nothing happens at all
-
The topic is in
topicsbut missing fromneo4j.cud.topics.
Next steps
-
CUD File Format Strategy — every field, every operation, and the exact Cypher each one generates, including
_idand_elementIdlookups. -
Sink settings — batching, retries and connection options.
-
Error handling — dead letter queues, useful here since a malformed operation is a per-message failure.
-
Monitoring — JMX metrics exposed by the sink connector.