Skip to main content

UNWIND and MERGE — Iterating and Upserting

Two clauses that don't fit neatly into read or write:

  • UNWIND turns a list into rows, one per element.
  • MERGE finds a pattern in the graph, or creates it if missing (the Cypher upsert).

Overview

GoalClause
Turn a list into rowsUNWIND
Bulk-load from a parameterUNWIND $rows AS row CREATE
Upsert a nodeMERGE (n:L {key: value})
Different side-effect on insert vs updateON CREATE / ON MATCH
Ensure a relationship existsMERGE (a)-[:R]->(b)

UNWIND

UNWIND takes a list and emits one row per element.

UNWIND [1, 2, 3] AS n RETURN n
-- 1, 2, 3

UNWIND range(1, 5) AS n RETURN n
-- 1, 2, 3, 4, 5

UNWIND [] AS n RETURN n
-- zero rows

Unwind a list of maps

Each element becomes a row; the variable holds the map.

UNWIND [
{name: 'Ada', born: 1815},
{name: 'Grace', born: 1906}
] AS row
RETURN row.name, row.born

Bulk load from parameter

The idiomatic large-ingest pattern:

UNWIND $people AS p
CREATE (:Person {name: p.name, born: p.born})

Where $people is a list of maps bound from the host language. One parse, one execution plan, N inserts.

Bulk relationships

Resolve endpoints per row with MATCH:

UNWIND $edges AS e
MATCH (a:User {id: e.from}), (b:User {id: e.to})
CREATE (a)-[:FOLLOWS {since: e.since}]->(b)

Deduplicate a list

Combine with collect(DISTINCT …):

UNWIND [1, 2, 2, 3, 3, 3] AS x
RETURN collect(DISTINCT x)
-- [1, 2, 3]

Chain UNWINDs

UNWIND $groups AS g
UNWIND g.members AS m
CREATE (:Member {group: g.name, name: m})

Unwind after aggregation

MATCH (p:Person)-[:KNOWS]->(f)
WITH p, collect(f.name) AS friends
UNWIND friends AS friend
RETURN p.name, friend

Re-expands an aggregated list back into rows — useful when you want to post-process each element after collecting.

Empty list

UNWIND [] emits zero rows. Any downstream clause runs zero times — an empty list short-circuits the pipeline:

UNWIND [] AS x
CREATE (:Should_Not_Exist)
-- no-op, no nodes created

Null vs empty

UNWIND null emits zero rows (same as empty). This is easy to miss:

UNWIND $maybe_list AS x    -- $maybe_list not bound → null0 rows

Pass an explicit [] rather than null when you want to express "nothing to do" from the host.

MERGE

MERGE finds a pattern in the graph. If a match exists, it's bound to the variables. If not, the pattern is created — exactly as written, labels and properties and all.

MERGE (n:User {id: 1001}) RETURN n

Running that query twice produces one :User {id: 1001}, not two. Running the equivalent CREATE twice produces two distinct nodes.

Basic merge

Shape the pattern around the fields that uniquely identify the entity.

MERGE (u:User {id: $id}) RETURN u

ON MATCH / ON CREATE

Run different side-effects depending on whether the match existed or had to be created.

MERGE (n:User {id: 1002})
ON MATCH SET n.updated = timestamp()
ON CREATE SET n.created = timestamp()
RETURN n

Both clauses are optional. You can have neither, one, or both.

Merge + unconditional SET

A very common upsert pattern: MERGE on the unique key, then SET the fields that always change:

MERGE (u:User {id: $id})
ON CREATE SET u.created = timestamp()
SET u.name = $name, u.updated = timestamp()
RETURN u

The trailing SET runs on both ON MATCH and ON CREATE branches.

Relationship merge

MATCH (a:User {name: 'Alice'}), (b:User {name: 'Bob'})
MERGE (a)-[r:FOLLOWS]->(b)
RETURN r

If an (a)-[:FOLLOWS]->(b) edge between those exact two nodes already exists, it is reused. Otherwise a new one is created.

Merge the full pattern

MERGE matches on the whole pattern. Properties inside the node pattern participate in the match.

-- Matches only the exact pattern
MERGE (u:User {id: 1, status: 'active'})

If a :User {id: 1} exists with no status property, this MERGE won't match — it'll create a second node.

Pattern caveats

  • Keep the identifying properties inside the MERGE pattern.
  • Set everything else with a trailing SET.
-- Bad — 'updated_at' is part of the match key
MERGE (u:User {id: $id, updated_at: datetime()})

-- Good — identity inside, payload via SET
MERGE (u:User {id: $id})
SET u.updated_at = datetime()
RETURN u

Merge missing endpoints

If the relationship endpoints aren't bound yet, MERGE will create them too — usually not what you want.

-- Risky: creates Alice and Bob if either is missing
MERGE (a:User {name: 'Alice'})-[:FOLLOWS]->(b:User {name: 'Bob'})

Prefer a safer two-step: match both nodes, then merge the edge.

MATCH (a:User {name: 'Alice'}), (b:User {name: 'Bob'})
MERGE (a)-[:FOLLOWS]->(b)

Common patterns

Idempotent bulk upsert

UNWIND $rows AS row
MERGE (u:User {id: row.id})
ON CREATE SET u.created = timestamp()
SET u += row.fields, u.updated = timestamp()

Every row becomes a "find-or-create then update" pass.

Tag-or-create relationship

UNWIND $tags AS t
MERGE (tag:Tag {name: t})
WITH tag
MATCH (p:Post {id: $post_id})
MERGE (p)-[:TAGGED]->(tag)

Counter (monotonic increment)

MERGE (c:Counter {name: 'views'})
ON CREATE SET c.value = 1
ON MATCH SET c.value = c.value + 1
RETURN c.value

"Ensure this edge"

MATCH (u:User {id: $user}), (r:Role {name: $role})
MERGE (u)-[:HAS_ROLE]->(r)

Run it twenty times; at most one edge exists between that user and that role.

Histogram from a parameter list

UNWIND $readings AS v
WITH (v / 10) * 10 AS bucket, count(*) AS n
RETURN bucket, n
ORDER BY bucket

No graph data involved — the pipeline starts from a host-supplied list. Useful for analytics queries that use LoraDB as a compute environment.

Unwind a list, filter, aggregate

UNWIND $scores AS s
WITH s WHERE s IS NOT NULL AND s > 0
RETURN avg(s) AS mean, min(s) AS worst, max(s) AS best, count(*) AS n

Upsert relationship with payload on first-sight

UNWIND $edges AS e
MATCH (a:User {id: e.from}), (b:User {id: e.to})
MERGE (a)-[r:FOLLOWS]->(b)
ON CREATE SET r.since = coalesce(e.since, timestamp())
SET r.last_activity = timestamp()

Conditional MERGE via CASE

UNWIND $rows AS row
MERGE (u:User {id: row.id})
SET u.tier = CASE
WHEN row.amount >= 1000 THEN 'platinum'
WHEN row.amount >= 100 THEN 'gold'
ELSE coalesce(u.tier, 'bronze')
END

The trailing SET runs on both match and create. See CASE.

Edge cases

Whitespace in pattern vs data

MERGE (:User {name: 'Alice'}) will not match :User {name: 'Alice '}. String equality is exact — trim on the host if inputs are dirty.

MERGE on multi-label pattern

Labels in the MERGE pattern are also part of the match key:

-- Won't match a plain :User {id: 1}
MERGE (u:User:Admin {id: 1})

UNWIND + MERGE race

There is no concurrent write safety concern because LoraDB runs queries serially — see Queries → Execution model.

Empty parameters

UNWIND $rows AS row MERGE (:User {id: row.id}) is a no-op when $rows = []. The MERGE doesn't run, so no accidental writes from a stray empty list.

See also