Quickstart: Sink with Pattern
When to use the Pattern strategy
Use the Pattern strategy when your messages map onto graph elements
field-for-field, and you would rather declare that mapping than write a query.
You give a Cypher-like pattern per topic; the connector generates the MERGE for you.
Pick it when:
-
each message describes one node, or one relationship with its two endpoints;
-
the properties you want are already fields on the message, possibly renamed or nested;
-
you want key-based upserts without hand-writing
MERGEclauses.
| One topic carries exactly one pattern. You cannot extract two node types, or a node and a relationship, from the same topic — use a separate topic per pattern. This guide therefore uses two topics. |
What you will build
Two connectors' worth of mapping in a single connector instance: a node pattern on one topic and a relationship pattern on another.
users topic ──> (:User {userId, name, surname})
purchases topic ──> (:User {userId})-[:BOUGHT {price, currency}]->(:Product {productId, name})
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.
| The Pattern strategy does not require Change Data Capture, so there is nothing to enable on the database. |
Step 1: Create key constraints
Patterns upsert on their key properties — the fields marked with !.
Create a matching node key constraint for each, in Neo4j Browser:
CREATE CONSTRAINT user_id IF NOT EXISTS FOR (u:User) REQUIRE u.userId IS NODE KEY;
CREATE CONSTRAINT product_id IF NOT EXISTS FOR (p:Product) REQUIRE p.productId IS NODE KEY;
This step is optional but recommended.
Without it the connector still works, but it logs a warning per pattern —
Label 'User' does not match the key(s) defined by the pattern … — and every upsert falls back
to an unindexed scan.
Node key constraints require Neo4j Enterprise Edition or Aura.
On Community Edition, use REQUIRE u.userId IS UNIQUE instead.
|
Step 2: Create the topics
One topic per pattern:
docker compose exec broker kafka-topics \
--bootstrap-server broker:29092 \
--create --topic users --partitions 1 --replication-factor 1
docker compose exec broker kafka-topics \
--bootstrap-server broker:29092 \
--create --topic purchases --partitions 1 --replication-factor 1
Step 3: Create the sink connector
Save the following as sink.pattern.neo4j.json:
{
"name": "Neo4jSinkPatternQuickstart",
"config": {
"topics": "users,purchases",
"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.pattern.merge-node-properties": true,
"neo4j.pattern.topic.users": "(:User{!userId, name, surname})",
"neo4j.pattern.topic.purchases": "(:User{!userId})-[:BOUGHT{price, currency}]->(:Product{!productId, name: productName})"
}
}
Register it:
curl -X POST http://localhost:8083/connectors \
-H 'Content-Type:application/json' \
-H 'Accept:application/json' \
-d @sink.pattern.neo4j.json
Confirm it is running:
curl -s http://localhost:8083/connectors/Neo4jSinkPatternQuickstart/status
Both the connector and its task should report RUNNING.
Reading the two patterns
The node pattern on the users topic:
(:User{!userId, name, surname})
-
:User— the label to merge on. -
!userId— a key property.MERGEmatches on it, so re-sending the sameuserIdupdates the existing node rather than creating a second one. At least one key property is required. -
name, surname— the only other properties copied onto the node. Any other field in the message is ignored.
The relationship pattern on the purchases topic:
(:User{!userId})-[:BOUGHT{price, currency}]->(:Product{!productId, name: productName})
-
both endpoints are merged on their key properties, then
:BOUGHTis merged between them; -
{price, currency}are copied onto the relationship; -
name: productNamerenames a message field on the way in — theproductNamefield becomes thenameproperty on:Product.
Two shorthands worth knowing: {!userId, *} (or just {!userId}) copies all message fields,
and {!userId, -address} copies all except address. You cannot mix inclusion and exclusion
in one pattern. Full syntax is in Pattern strategy.
|
Note Both topics write to the same Set it to |
Step 4: Produce messages
Create the connector before producing, so nothing is missed. Users first:
docker compose exec -T broker kafka-console-producer \
--bootstrap-server broker:29092 \
--topic users <<'EOF'
{"userId": 1, "name": "John", "surname": "Doe"}
{"userId": 2, "name": "Mary", "surname": "Smith"}
EOF
Then purchases:
docker compose exec -T broker kafka-console-producer \
--bootstrap-server broker:29092 \
--topic purchases <<'EOF'
{"userId": 1, "productId": 100, "productName": "Laptop", "price": 999.99, "currency": "USD"}
{"userId": 2, "productId": 100, "productName": "Laptop", "price": 949.00, "currency": "USD"}
{"userId": 2, "productId": 200, "productName": "Keyboard", "price": 49.99, "currency": "USD"}
EOF
Note that productId: 100 appears twice.
Because productId is a key property, both messages merge onto the same :Product node.
Step 5: Verify the result
In Neo4j Browser at http://localhost:7474/browser/:
MATCH (u:User)-[b:BOUGHT]->(p:Product)
RETURN u.name AS user, p.name AS product, b.price AS price ORDER BY user, product
user product price
"John" "Laptop" 999.99
"Mary" "Keyboard" 49.99
"Mary" "Laptop" 949.0
Confirm the merge behaviour — two users, two products, three relationships:
MATCH (n) RETURN labels(n) AS item, count(*) AS count
UNION ALL
MATCH ()-[r]->() RETURN [type(r)] AS item, count(*) AS count
Now re-send one of the purchase messages:
docker compose exec -T broker kafka-console-producer \
--bootstrap-server broker:29092 \
--topic purchases <<'EOF'
{"userId": 1, "productId": 100, "productName": "Laptop", "price": 899.00, "currency": "USD"}
EOF
Re-run the first query. John’s :BOUGHT price is updated to 899.0 and no new node or
relationship is created — that is the key properties doing their work.
Deleting with tombstones
The Pattern strategy also handles
tombstone records: a message whose key
carries the pattern’s key properties and whose value is null deletes the matching element.
This needs a producer that can emit a null value and a structured key, so it is not shown here —
see Tombstone records.
Using Avro, Protobuf or JSON Schema
Patterns are unchanged across formats; only the converters differ.
Replace the value.converter lines with, for example:
"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.
Schema-carrying formats also preserve Kafka Connect logical types — with plain JSON, a date
arrives as a string, whereas Avro and Protobuf map it onto a Neo4j temporal type.
See Type support.
Troubleshooting
curl -s http://localhost:8083/connectors/Neo4jSinkPatternQuickstart/status
docker compose logs connect
Message value must be convertible to a Map-
The Pattern strategy requires an object-shaped message value. A bare scalar or array will not work — unlike the Cypher strategy, which can bind any value.
- Task fails with a pattern parse error
-
The pattern is validated at startup. Check that every node pattern has at least one
!key property, and that inclusion and exclusion properties are not mixed. Label 'X' does not match the key(s) defined by the pattern-
A warning, not an error. Create the node key constraint from Step 1.
- Nodes created but properties missing
-
The pattern lists properties explicitly. A field absent from the pattern is not copied — use
{!key, *}to copy everything. - A node’s properties vanish after a second topic writes to it
-
neo4j.pattern.merge-node-propertiesdefaults tofalse, which makes the connector replace a node’s whole property map rather than add to it. A relationship endpoint contributes no value properties, so it replaces them with nothing. Set the option totrue. Diagnose it withMATCH (n:User) RETURN n.userId, keys(n)— you will see only the key property left. - Nothing at all happens
-
Confirm each topic in
topicshas a matchingneo4j.pattern.topic.<topic>entry. A topic with no pattern is consumed and silently ignored.
Next steps
-
Pattern strategy — full pattern grammar, tombstones, batching and exactly-once semantics.
-
Sink settings —
neo4j.pattern.bind-*, batching, retries. -
Error handling — dead letter queues and error tolerance.
-
Monitoring — JMX metrics exposed by the sink connector.