Chapter 06 · Graph Writes: CREATE, MERGE, SET, REMOVE, DELETE, and Idempotent Mutation
MERGE Match-or-Create Semantics, ON CREATE/ON MATCH, Uniqueness Constraints, and Concurrency Hazards
Use MERGE as precise match-or-create, not magic upsert: constrain stable identity, separate mutable state, test concurrent execution, and make retry behavior safe.
Learning outcomes
AtlasMart receives the same product catalog update from two
workers at nearly the same time. The desired outcome is one
Product, one business identity, and predictable
ON CREATE/ON MATCH behavior—not two
duplicates and not a hidden last-writer surprise.
Explain MERGE as exact pattern match-or-create
semantics rather than generic SQL UPSERT.
Anchor MERGE on stable constraint-backed identity and apply mutable attributes separately.
Use ON CREATE SET and
ON MATCH SET to distinguish lifecycle events.
Explain why MERGE alone does not guarantee node uniqueness under concurrent loads.
Test repeated and concurrent writes and interpret constraint violations, locks, and transaction retries.
The mandatory lab continues the accepted course baseline:
Neo4j Community 2026.07.1, database
neo4j, explicit CYPHER 25 on
version-sensitive examples, authentication enabled, local Bolt
at bolt://localhost:7687, no mandatory APOC/GDS
plugin, and the AtlasMart Customer/Product/Order model from
Chapters 01–05. Neo4j 5.26.30 remains the current
LTS comparison line. Optional concurrency examples use Neo4j
Python Driver 6.3.x; the mandatory mutation logic
itself is plain Community Cypher.
This chapter changes graph state. Every destructive example
targets dedicated IDs prefixed P-WRITE-,
O-WRITE-, or O-DELETE-, and every
cleanup query repeats those predicates. Never broaden them to
MATCH (n) DETACH DELETE n in a database
containing unrelated work. This generation environment does
not run Neo4j or Docker, so expected results are stated as
deterministic invariants, not fabricated captured output.
Re-establish the isolated Chapter 06 write fixture
The fixture deliberately reuses AtlasMart's stable business
identifiers and Community-supported uniqueness constraints. It
creates or normalizes one customer, one dedicated product, one
dedicated order, and their PLACED/CONTAINS
relationships. Because every identity is deterministic and the
write pattern uses MERGE, rerunning the fixture
should leave the target counts unchanged.
CYPHER 25CREATE CONSTRAINT customer_id IF NOT EXISTS FOR (c:Customer) REQUIRE c.customerId IS UNIQUE;CREATE CONSTRAINT product_id IF NOT EXISTS FOR (p:Product) REQUIRE p.productId IS UNIQUE;CREATE CONSTRAINT order_id IF NOT EXISTS FOR (o:Order) REQUIRE o.orderId IS UNIQUE;MERGE (c:Customer {customerId:'C-1001'}) ON CREATE SET c.name='Ava Chen', c.region='eu', c.createdAt=datetime('2026-09-09T00:00:00Z') ON MATCH SET c.lastSeenAt=datetime('2026-09-09T00:00:00Z');MERGE (p:Product {productId:'P-WRITE-1001'}) ON CREATE SET p.name='Atlas Travel Camera', p.price=149.90, p.status='active', p.createdAt=datetime('2026-09-09T00:00:00Z') ON MATCH SET p.name='Atlas Travel Camera', p.price=149.90, p.status='active';MERGE (o:Order {orderId:'O-WRITE-2001'}) ON CREATE SET o.status='pending', o.source='chapter06', o.createdAt=datetime('2026-09-09T00:00:00Z') ON MATCH SET o.source='chapter06';MERGE (c)-[:PLACED]->(o)MERGE (o)-[line:CONTAINS]->(p) ON CREATE SET line.quantity=1, line.unitPrice=149.90 ON MATCH SET line.quantity=1, line.unitPrice=149.90;
CYPHER 25MATCH (p:Product {productId:'P-WRITE-1001'})OPTIONAL MATCH (c:Customer {customerId:'C-1001'})-[placed:PLACED]->(o:Order {orderId:'O-WRITE-2001'})OPTIONAL MATCH (o)-[line:CONTAINS]->(p)RETURN count(DISTINCT p) AS products, count(DISTINCT o) AS orders, count(DISTINCT placed) AS placedRelationships, count(DISTINCT line) AS containsRelationships;
The deterministic target for the dedicated slice is
1 / 1 / 1 / 1. Those counts do not imply the entire
database contains only one Product or Order; previous chapters
intentionally contain additional AtlasMart data.
1. MERGE is MATCH-or-CREATE for the pattern you wrote
MERGE first looks for the exact pattern. If it
finds the pattern, it binds it. If it does not, it creates it.
This is narrower than the vague phrase “upsert.” The property
map inside the MERGE pattern participates in identity matching;
putting mutable attributes there can unintentionally change what
counts as the same entity.
CYPHER 25MERGE (p:Product {productId:$productId, name:$name})SET p.price=$priceRETURN p.productId, p.name;
If productId identifies the product but
name changes, the pattern no longer means “find
this product by ID.” With a uniqueness constraint on
productId, Neo4j can reject the attempted
conflicting creation. Without the constraint, a concurrent or
sequential model error can create multiple logical products.
CYPHER 25MERGE (p:Product {productId:$productId}) ON CREATE SET p.createdAt=datetime()SET p.name=$name, p.price=$price, p.status=$status, p.updatedAt=datetime()RETURN p.productId AS productId;
2. Constraints convert a convention into an integrity contract
A Community property uniqueness constraint prevents two
Product nodes carrying the same
productId. Neo4j's MERGE documentation explicitly
recommends constraints for identifying properties because MERGE
alone guarantees pattern existence, not uniqueness under
concurrent loads.
CYPHER 25CREATE CONSTRAINT product_id IF NOT EXISTSFOR (p:Product) REQUIRE p.productId IS UNIQUE;SHOW CONSTRAINTS;
A uniqueness constraint does not require every Product
to have productId; missing-value existence/key
enforcement has different edition boundaries. In this Community
lab the application/model contract says every AtlasMart Product
must have a stable ID, and the verification suite explicitly
searches for missing IDs.
CYPHER 25MATCH (p:Product)WHERE p.productId IS NULLRETURN count(p) AS productsMissingId;
3. ON CREATE and ON MATCH describe lifecycle-specific mutation
CYPHER 25MERGE (p:Product {productId:'P-WRITE-1001'}) ON CREATE SET p.createdAt=datetime(), p.importRevision=1 ON MATCH SET p.lastMatchedAt=datetime(), p.importRevision=coalesce(p.importRevision,0)+1SET p.name='Atlas Travel Camera', p.price=149.90RETURN p.productId, p.importRevision;
This is intentionally not idempotent because
importRevision increments on every match. The
lesson uses it to prove that “MERGE matched” does not mean
“repeated execution has the same effect.” Idempotency is a
property of the entire mutation, including the SET expressions.
4. Merge relationships only after binding constrained endpoints
CYPHER 25MATCH (o:Order {orderId:'O-WRITE-2001'})MATCH (p:Product {productId:'P-WRITE-1001'})MERGE (o)-[line:CONTAINS]->(p) ON CREATE SET line.createdAt=datetime()SET line.quantity=1, line.unitPrice=p.priceRETURN count(line) AS lineCount;
For a relationship between two previously bound nodes, current Neo4j MERGE behavior acquires exclusive locks on the endpoints when needed and re-checks after locking to prevent duplicate creation by concurrent copies of the same relationship MERGE. That is different from node identity: Cypher has no general “one relationship of this type between these nodes” constraint, so the guarantee here comes from MERGE's locking behavior for this exact relationship pattern.
5. Controlled concurrency test with the official driver
The optional test below uses a single shared Driver object and
one session per worker. The transaction function is deliberately
side-effect free outside Neo4j because
execute_write() may invoke it more than once on
retryable failures.
from concurrent.futures import ThreadPoolExecutorfrom neo4j import GraphDatabaseURI = 'bolt://localhost:7687'AUTH = ('neo4j', 'atlasmart-course-password')QUERY = '''CYPHER 25MERGE (p:Product {productId:$productId}) ON CREATE SET p.createdAt=datetime()SET p.name=$name, p.price=$price, p.status='active'RETURN p.productId AS id'''def work(driver, i): with driver.session(database='neo4j') as session: return session.execute_write( lambda tx: tx.run( QUERY, productId='P-WRITE-CONCURRENT', name='Concurrent Camera', price=199.0 ).single()['id'] )with GraphDatabase.driver(URI, auth=AUTH) as driver: driver.verify_connectivity() with ThreadPoolExecutor(max_workers=8) as pool: results = list(pool.map(lambda i: work(driver, i), range(32))) print(results)
After the workers finish, the acceptance test is not “all futures returned.” Query committed state:
CYPHER 25MATCH (p:Product {productId:'P-WRITE-CONCURRENT'})RETURN count(p) AS nodes, collect(p.name) AS names;
The invariant is one Product node. Exact lock waits, retries, and timings depend on local scheduling and resources and must be measured rather than invented.
6. Deliberately wrong: MERGE the whole mutable graph pattern
CYPHER 25MERGE (c:Customer {customerId:$customerId})-[:PLACED]-> (o:Order {orderId:$orderId, status:$status})-[:CONTAINS]-> (p:Product {productId:$productId, price:$price});
This pattern asks Neo4j to find or create the entire shape as written. A status or price change can make the exact pattern fail to match; missing endpoint constraints can create look-alike entities; and debugging which part caused a create is harder. The repair is staged: MERGE each identity node separately using constrained keys, SET mutable state, MATCH/MERGE relationships between bound nodes, and verify cardinality.
Lab: repeat and race the same upsert
Reset P-WRITE-CONCURRENT with a targeted delete,
run the single-thread upsert five times, verify one node, then
run the optional 32-call concurrency test and verify one node
again. Finally query SHOW CONSTRAINTS and record
the constraint name/type that protects the identity.
CYPHER 25MATCH (p:Product {productId:'P-WRITE-CONCURRENT'})DETACH DELETE p;
Production judgment
Constraint-backed identity improves correctness and lookup performance but can also concentrate contention on hot keys. Retry behavior must be bounded and observable. Keep transaction functions database-focused and idempotent because drivers can retry them. For multi-entity business invariants, do not assume several independent MERGEs are equivalent to a single business transaction; choose the transaction boundary deliberately and test deadlock/retry behavior under realistic concurrency.
Check your understanding
- Why is MERGE not a generic SQL UPSERT?
- What does the uniqueness constraint add under concurrency?
- Can ON MATCH SET make a MERGE operation non-idempotent?
- Why bind relationship endpoints before MERGEing the relationship?
- Why must a managed transaction callback avoid non-idempotent external side effects?
Review the answers
1. It matches or creates the exact graph pattern supplied; identity and mutable attributes must be modeled explicitly rather than assumed.
2. It turns productId uniqueness into an enforced database invariant and prevents concurrent duplicate node creation that MERGE alone does not guarantee.
3. Yes. An increment, timestamp mutation, append-like update, or other cumulative effect can change state on every replay.
4. It isolates endpoint identity from relationship existence and makes concurrency/semantics easier to reason about.
5. The driver may execute the callback more than once when retrying transient failures.
Summary and next step
Reliable MERGE starts with constrained identity, not with a giant pattern. Next, mutable attributes get their own semantics: patching, full-map replacement, property removal, label migration, and schema evolution.
Authoritative references
- Current Neo4j versions — Release/LTS snapshot used to pin the course baseline.
- Cypher Manual — CREATE — CREATE semantics for nodes, relationships, parameters, and dynamic labels/types.
- Cypher Manual — MERGE — Match-or-create semantics, ON CREATE/ON MATCH, constraints, and concurrent relationship merges.
- Cypher Manual — SET — Property/label updates and map replacement versus mutation.
- Cypher Manual — REMOVE — Property and label removal semantics.
- Cypher Manual — DELETE — DELETE, NODETACH DELETE, DETACH DELETE, and large-delete guidance.
- Operations Manual — transactional behavior — ACID, read-committed isolation, locking, transaction logs, and deadlock behavior.
- Create constraints — Property uniqueness constraints, backing indexes, IF NOT EXISTS, and edition-sensitive constraint types.
- Concurrent data access — Locks, lost updates, deadlocks, and retry guidance.
- Python Driver — transactions — Managed write transactions and session lifecycle.
- Python Driver API — execute_write retry/idempotency contract.