MERGE vs CREATE for idempotent upserts
Two Cypher clauses write nodes, and picking the wrong one is the single most common cause of a graph that drifts on re-run. CREATE always inserts a new node — fast, unconditional, and duplicating on the second execution. MERGE matches-or-creates — idempotent by design, but only race-safe and fast when a backing uniqueness constraint stands behind its match key. This guide draws the line between them: when you need the idempotent upsert semantics of MERGE, when unconditional CREATE is still the correct and faster choice, and exactly why a MERGE without a constraint quietly degrades into a full label scan that can still produce duplicates under concurrency. It is the write-time decision that sits underneath the whole constraint lifecycle.
Prerequisites
The core distinction
CREATE is unconditional. It allocates a new node every time it runs, regardless of what already exists, so it is the fastest possible write — no lookup, no match, just an insert. That speed is exactly why it duplicates: run the same CREATE twice and you get two nodes. MERGE is conditional. It first searches for a node matching its pattern; if one exists it binds to it, otherwise it creates one. That match is what makes re-execution converge on the same graph — the definition of an idempotent upsert — but the search is only cheap when it can seek an index instead of scanning every node of the label.
// CREATE — unconditional insert. Two runs, two :Event nodes. Correct ONLY when
// each row is a guaranteed-new, immutable fact that should never be deduplicated.
CREATE (e:Event {event_id: $id, occurred_at: datetime($ts), kind: $kind});
// MERGE — match-or-create anchored on the STABLE identity key. Two runs, one
// :Customer node. ON CREATE fires only on first insert; ON MATCH on every
// subsequent run. Splitting them keeps immutable fields set-once.
MERGE (c:Customer {customer_id: $id})
ON CREATE SET c.created_at = datetime($ts), c.status = $status
ON MATCH SET c.updated_at = datetime($ts), c.status = $status;
The ON CREATE SET / ON MATCH SET split is the mechanism that makes MERGE a true upsert rather than a blunt overwrite. Properties that must be written once and never changed — a creation timestamp, an origin marker — go under ON CREATE. Properties that reflect the latest state go under ON MATCH, or under a plain SET after the MERGE when they should apply in both cases. Anchor the MERGE pattern on the immutable key only; putting a mutable property inside the MERGE {…} map means a changed value fails to match the existing node and creates a duplicate — the classic drift bug dissected in implementing idempotent migration scripts for Neo4j.
Why MERGE without a constraint duplicates
MERGE guarantees idempotency only when its match is atomic, and atomicity comes from the backing uniqueness constraint’s index-level write lock. Without that constraint, MERGE does a NodeByLabelScan to check existence — O(n) in the label’s size — and, worse, two concurrent transactions can each run that scan, each find no match, and each insert. The result is duplicate identity keys that the constraint would have prevented. The constraint is therefore not an optimization you add later; it is what makes the MERGE correct under concurrency in the first place.
The performance gap widens with scale. A CREATE is constant-cost no matter how large the label grows, because it never looks anything up. An index-backed MERGE is close behind — one logarithmic index seek per row — so with the constraint in place the idempotency you gain is nearly free. A constraint-free MERGE, by contrast, degrades linearly: at ten thousand nodes the scan is unnoticeable, at fifty million it dominates the load and every additional row makes it worse. That is why the constraint must exist and be ONLINE before the first MERGE, not added afterward to a table already full of duplicates it can no longer be created over. When upserting in bulk, keep the same shape and batch it with UNWIND $rows AS row MERGE (…), so a single index-backed transaction handles thousands of rows instead of one round trip each.
Validation & verification
Prove the MERGE is index-backed, not scanning. EXPLAIN over the upsert should show a NodeUniqueIndexSeek at the leaf; a NodeByLabelScan with a Filter is the fingerprint of a missing or not-yet-online constraint:
EXPLAIN
MERGE (c:Customer {customer_id: $id})
ON CREATE SET c.created_at = datetime($ts)
RETURN c.customer_id; // want NodeUniqueIndexSeek, not NodeByLabelScan + Filter
Then prove idempotency behaviourally. Count the label, run the identical MERGE batch a second time, and re-count — a true upsert leaves the total unchanged, whereas a CREATE doubles it:
// Run before and after a second execution of the same input; the count must not move.
MATCH (c:Customer) RETURN count(c) AS total;
Edge cases & gotchas
1. MERGE on a mutable property. Anchoring on a value that changes between runs — MERGE (c:Customer {customer_id: $id, status: $status}) — means a changed status fails to match the existing node and inserts a duplicate. Anchor on the immutable key alone and apply the rest with SET:
// RIGHT — identity is the stable key; mutable state is applied after the match.
MERGE (c:Customer {customer_id: $id}) SET c.status = $status;
2. Using CREATE for reference data that re-loads. Loading lookup or dimension data with CREATE duplicates it on every pipeline re-run. Any record that can be reprocessed needs MERGE; reserve CREATE for append-only, immutable facts — audit events, ledger entries, sensor readings — where each row is genuinely new and should never be deduplicated.
3. Relationship MERGE without bound endpoints. MERGE (a)-[:OWNS]->(b) without first binding a and b on their keys can create phantom endpoint nodes. Merge both endpoints on their stable keys first, then merge the edge between the bound variables:
MERGE (c:Customer {customer_id: $cid})
MERGE (a:Account {account_id: $aid})
MERGE (c)-[:OWNS]->(a); // one edge no matter how many replays
This decision is one thread of Constraint Lifecycle Management; the uniqueness constraint that makes MERGE safe is the same object whose lifecycle that reference governs.
Related
- Up: Constraint Lifecycle Management — how to provision and bring online the constraint that makes
MERGErace-safe. - Implementing Idempotent Migration Scripts for Neo4j — the deeper reference on deterministic merge keys and replay-safe writes.
- Migrating to NODE KEY Constraints Without Downtime — widening the key your
MERGEanchors on without taking the database offline.