Chapter 06 · Graph Writes: CREATE, MERGE, SET, REMOVE, DELETE, and Idempotent Mutation

Design an Idempotent Import/Upsert Workflow and Prove It Remains Correct Under Repeated Execution

Design replay-safe graph mutation as a complete application/database contract: stable keys, constraints, staged MERGE, bounded patches, relationship identity, managed retries, concurrency tests, and reconciliation.

Advanced150–180 minutesIdempotency + concurrency capstone labNeo4j 2026.07.1 Community · Cypher 25Last reviewed: September 2026

Learning outcomes

AtlasMart's ingestion service can deliver the same product/order batch more than once because network retries, queue redelivery, and worker restarts are normal. The production contract is therefore not “the script usually runs once.” It is: one logical input produces one stable graph state even when the database transaction is retried or the same batch is replayed.

01

Define idempotency as repeated execution converging on the same intended committed graph state.

02

Combine constraint-backed node identity, staged MERGE, SET patching, and relationship MERGE into a replay-safe workflow.

03

Prove repeated execution with counts and invariant queries rather than trusting zero-error completion.

04

Distinguish database idempotency from driver retry semantics and external side-effect idempotency.

05

Run a controlled concurrency test and design reconciliation for ambiguous or failed outcomes.

Chapter 06 baseline · reviewed 9 September 2026

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.

Safety and evidence rule

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 · idempotent Chapter 06 fixture
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 · acceptance counts for only the dedicated slice
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. Define the mutation contract before writing Cypher

AtlasMart fact Stable identity Replay behavior
Product productId Create once; mutable attributes converge to payload values.
Order orderId Create once; status/source converge to payload values.
Customer → Order Bound customerId + orderId Exactly one PLACED relationship for this workflow.
Order → Product line Bound order/product pair Exactly one CONTAINS edge whose quantity/unitPrice converge to payload.

Idempotency here does not mean “nothing changes on the second call.” Fields such as updatedAt can intentionally change each time. It means the business graph—identity/cardinality and authoritative payload values—does not accumulate duplicate logical entities or relationships.

2. A staged, parameterized upsert workflow

Cypher · one replay-safe AtlasMart row
CYPHER 25MATCH (c:Customer {customerId:$customerId})MERGE (p:Product {productId:$product.productId})  ON CREATE SET p.createdAt=datetime()SET p.name=$product.name,    p.price=$product.price,    p.status=$product.status,    p.updatedAt=datetime()MERGE (o:Order {orderId:$order.orderId})  ON CREATE SET o.createdAt=datetime()SET o.status=$order.status,    o.source=$order.source,    o.updatedAt=datetime()MERGE (c)-[:PLACED]->(o)MERGE (o)-[line:CONTAINS]->(p)SET line.quantity=$line.quantity,    line.unitPrice=$line.unitPriceRETURN c.customerId AS customerId,       o.orderId AS orderId,       p.productId AS productId;

Each identity is matched separately. Mutable state is applied afterward. Relationship endpoints are already bound. The uniqueness constraints on customer/product/order business keys turn accidental duplicate identity into a database error rather than silent divergence.

3. Extend to a bounded batch with UNWIND

Cypher · replay-safe batch
CYPHER 25UNWIND $rows AS rowMATCH (c:Customer {customerId:row.customerId})MERGE (p:Product {productId:row.product.productId})  ON CREATE SET p.createdAt=datetime()SET p += {  name:row.product.name,  price:row.product.price,  status:row.product.status}MERGE (o:Order {orderId:row.order.orderId})  ON CREATE SET o.createdAt=datetime()SET o += {  status:row.order.status,  source:row.order.source}MERGE (c)-[:PLACED]->(o)MERGE (o)-[line:CONTAINS]->(p)SET line.quantity=row.line.quantity,    line.unitPrice=row.line.unitPriceRETURN count(*) AS inputRowsProcessed;

This assumes one logical line per order/product pair. If AtlasMart needs multiple separate line items for the same product in one order, the relationship requires its own stable line identity or the model should reify OrderLine as a node. Idempotency follows the domain model; it cannot be added by syntax alone.

4. Prove replay invariants

Run the same parameter payload three times. After each run, capture the following query. Stable counts are the acceptance contract.

Cypher · replay acceptance query
CYPHER 25MATCH (p:Product {productId:'P-WRITE-1001'})MATCH (o:Order {orderId:'O-WRITE-2001'})MATCH (c:Customer {customerId:'C-1001'})OPTIONAL MATCH (c)-[placed:PLACED]->(o)OPTIONAL MATCH (o)-[line:CONTAINS]->(p)RETURN count(DISTINCT p) AS productNodes,       count(DISTINCT o) AS orderNodes,       count(DISTINCT placed) AS placedCount,       count(DISTINCT line) AS lineCount,       max(line.quantity) AS quantity,       max(line.unitPrice) AS unitPrice;

The business cardinality target is 1 / 1 / 1 / 1, with line properties equal to the latest authoritative payload. Do not use total database counts as the assertion because previous chapters contain other AtlasMart entities.

5. Managed driver retries change the application design

Neo4j's official drivers can retry managed transaction functions after transient failures. The Python driver's execute_write() contract explicitly warns that the supplied transaction function may be invoked more than once and therefore must be idempotent. Keep payment capture, email sends, file writes, and irreversible HTTP calls out of the retryable callback unless they have independent idempotency keys and reconciliation.

Python · retry-safe database callback
from neo4j import GraphDatabaseURI = 'bolt://localhost:7687'AUTH = ('neo4j', 'atlasmart-course-password')UPSERT = '''CYPHER 25MATCH (c:Customer {customerId:$customerId})MERGE (p:Product {productId:$productId})  ON CREATE SET p.createdAt=datetime()SET p.name=$name, p.price=$price, p.status='active'MERGE (o:Order {orderId:$orderId})  ON CREATE SET o.createdAt=datetime()SET o.status='pending', o.source='chapter06'MERGE (c)-[:PLACED]->(o)MERGE (o)-[line:CONTAINS]->(p)SET line.quantity=$quantity, line.unitPrice=$priceRETURN o.orderId AS orderId'''def upsert_tx(tx, payload):    # Database work only. Do not send email/call payment APIs here.    return tx.run(UPSERT, **payload).single()['orderId']payload = {    'customerId':'C-1001',    'productId':'P-WRITE-1001',    'orderId':'O-WRITE-2001',    'name':'Atlas Travel Camera',    'price':149.90,    'quantity':1,}with GraphDatabase.driver(URI, auth=AUTH) as driver:    driver.verify_connectivity()    with driver.session(database='neo4j') as session:        print(session.execute_write(upsert_tx, payload))

6. Controlled concurrency: many callers, one logical graph

Python · concurrent replay test
from concurrent.futures import ThreadPoolExecutorfrom neo4j import GraphDatabaseURI = 'bolt://localhost:7687'AUTH = ('neo4j', 'atlasmart-course-password')# Reuse UPSERT and payload from the previous example.def one_call(driver, _):    with driver.session(database='neo4j') as session:        return session.execute_write(upsert_tx, payload)with GraphDatabase.driver(URI, auth=AUTH) as driver:    with ThreadPoolExecutor(max_workers=8) as pool:        list(pool.map(lambda i: one_call(driver, i), range(32)))

Then rerun the replay acceptance query. The invariant must still be one Product, one Order, one PLACED, and one CONTAINS relationship for these IDs. If a deadlock/transient failure is retried internally, exact attempts/timing are environment-specific. If retries are exhausted, the application must surface failure and reconcile from business IDs—not generate a new random identity and hope.

7. Deliberately wrong: CREATE plus random identity in a retryable callback

Wrong · every execution describes a different logical entity
CYPHER 25CREATE (o:Order {  orderId:randomUUID(),  externalOrderNumber:$externalOrderNumber,  status:'pending'})RETURN o.orderId;

If the callback is invoked again, it creates another Order because the generated identity changes. Even if the first transaction rolled back, this pattern also makes reconciliation harder after ambiguous client outcomes. Prefer a stable upstream idempotency/business key such as externalOrderNumber backed by an appropriate uniqueness contract, then MERGE on that identity.

8. Reconciliation is part of idempotency

A client can time out after sending a write and before learning whether it committed. The safe response is not automatically “run a different create.” Query by the stable business/request ID, inspect the committed state, and decide whether to retry, repair, or report success. For workflows spanning Neo4j and external systems, maintain an explicit operation/request ID and durable state machine or outbox/inbox pattern appropriate to the integration.

Cypher · reconcile by stable business identity
CYPHER 25MATCH (o:Order {orderId:$orderId})OPTIONAL MATCH (c:Customer)-[:PLACED]->(o)OPTIONAL MATCH (o)-[line:CONTAINS]->(p:Product)RETURN o.orderId,       o.status,       c.customerId,       collect({productId:p.productId, quantity:line.quantity, unitPrice:line.unitPrice}) AS lines;

9. Chapter 06 integration lab

Use one parameter payload and complete this verification sequence: establish constraints; run the upsert once; verify business counts; run the same upsert twice more; verify unchanged business counts; change only product price and line unitPrice, rerun, and prove attributes converge without creating new entities; run the optional concurrent replay test; verify SHOW CONSTRAINTS; finally remove only the dedicated concurrent-test entities you created beyond the shared fixture.

Cypher · final invariant and missing-ID audit
CYPHER 25MATCH (p:Product)WITH count(CASE WHEN p.productId IS NULL THEN 1 END) AS productsMissingIdMATCH (o:Order)WITH productsMissingId,     count(CASE WHEN o.orderId IS NULL THEN 1 END) AS ordersMissingIdMATCH (p:Product {productId:'P-WRITE-1001'})MATCH (o:Order {orderId:'O-WRITE-2001'})MATCH (c:Customer {customerId:'C-1001'})OPTIONAL MATCH (c)-[placed:PLACED]->(o)OPTIONAL MATCH (o)-[line:CONTAINS]->(p)RETURN productsMissingId,       ordersMissingId,       count(DISTINCT p) AS targetProducts,       count(DISTINCT o) AS targetOrders,       count(DISTINCT placed) AS targetPlaced,       count(DISTINCT line) AS targetLines;

Production judgment

Idempotency requires stable identities, constraints, mutation semantics, transaction boundaries, and an application contract for retries and ambiguous outcomes. Hot keys can serialize work; large batches can consume transaction memory; deadlock retries can reduce throughput; and external side effects require independent idempotency. Monitor retries, constraint failures, lock wait/deadlock signals, write latency distributions, transaction timeouts, and reconciliation counts. Migration and rollback plans must preserve the business keys used to recognize already-applied work.

Check your understanding

  1. What does idempotent mean for this AtlasMart upsert?
  2. Why are uniqueness constraints part of the workflow?
  3. Can a managed transaction function run more than once?
  4. Why is randomUUID() a poor primary identity for a replayed external order?
  5. What should the application do after an ambiguous timeout?
Review the answers

1. Replaying the same logical input converges on the same intended business graph/cardinality instead of accumulating duplicate logical entities or relationships.

2. They enforce stable node identity under concurrency rather than leaving uniqueness as an application convention.

3. Yes. Official drivers may retry it after retryable/transient failures, so the callback must be idempotent.

4. Each execution generates a different identifier, making duplicate creation/reconciliation possible unless another stable business key controls identity.

5. Reconcile committed state by stable business/request ID before deciding whether to retry or report success.

Summary and next step

Chapter 06 turns mutation syntax into a correctness discipline: CREATE for truly new facts, MERGE for constraint-backed identity-aware convergence, SET/REMOVE with explicit patch/replacement semantics, deletes with bounded blast radius, and retry-safe workflows verified from committed state. Chapter 07 now returns to modeling and asks when a fact belongs on a node, property, relationship, intermediate entity, hierarchy, or temporal structure.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.