Chapter 05 · Cypher Expressions, Aggregation, UNWIND, Collections, Maps, and Subqueries
UNWIND for Turning Collections into Rows, Batching Input, and Preserving Deterministic Semantics
Turn collection-shaped application input into a predictable Cypher row stream: measure multiplication, preserve duplicates deliberately, handle empty inputs, and carry explicit order when the API needs it.
Learning outcomes
AtlasMart receives a parameter containing product IDs from a
recommendation service and needs to resolve those IDs to graph
entities in one request. UNWIND is the bridge from
a collection-shaped parameter to a row pipeline, but duplicates,
empty inputs and missing order guarantees can change both
correctness and cost.
Explain UNWIND as a list-to-row cardinality
operator.
Predict behavior for duplicate elements, empty lists, null, nested lists and scalar inputs.
Preserve deterministic output by applying
ORDER BY after row creation rather than
trusting list order.
Batch application input deliberately without making one enormous parameter or one driver round-trip per element.
Deduplicate at the stage that matches the business contract, not reflexively at the end.
The mandatory lab continues the course baseline: Neo4j
Community 2026.07.1, database neo4j,
explicit CYPHER 25 on version-sensitive queries,
authentication enabled, no mandatory APOC/GDS plugin, and the
reusable AtlasMart Customer/Order/Product/Category fixture
from Chapter 03. Neo4j 5.26.30 remains the LTS
comparison line. Because GROUP BY was introduced
for Cypher 25 in Neo4j 2026.07, examples that use it also show
the implicit-grouping form needed for Cypher 5 compatibility.
This generation environment does not run Neo4j or Docker. Commands and semantics were checked against current Neo4j documentation, but plan operators, estimated rows, memory figures and wall-clock timings must be captured on the learner's own pinned instance. Deterministic fixture counts are stated only where they follow directly from the fixture.
Re-establish the deterministic AtlasMart read fixture
Every lesson can stand alone. The fixture is idempotent, so
rerunning it is the safest reset: it restores four customers,
five orders, four products, two categories, five
PLACED, seven CONTAINS, and four
IN_CATEGORY relationships without deleting the
Chapter 04 operations graph. If your course sandbox already
contains the fixture, this simply normalizes the properties used
here.
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;CREATE CONSTRAINT category_id IF NOT EXISTS FOR (c:Category) REQUIRE c.categoryId IS UNIQUE;MERGE (c1:Customer {customerId:'C-1001'}) SET c1.name='Ava Chen', c1.tier='gold', c1.region='eu'MERGE (c2:Customer {customerId:'C-1002'}) SET c2.name='Noah Smith', c2.tier='silver', c2.region='us'MERGE (c3:Customer {customerId:'C-1003'}) SET c3.name='Mina Rahimi', c3.tier='gold', c3.region='me'MERGE (c4:Customer {customerId:'C-1004'}) SET c4.name='Leo Martin', c4.region='eu'MERGE (cat1:Category {categoryId:'CAT-CAMERAS'}) SET cat1.name='Cameras'MERGE (cat2:Category {categoryId:'CAT-AUDIO'}) SET cat2.name='Audio'MERGE (p1:Product {productId:'P-1001'}) SET p1.name='Trail Camera', p1.price=129.90, p1.rating=4.7MERGE (p2:Product {productId:'P-1002'}) SET p2.name='Studio Headphones', p2.price=89.00, p2.rating=4.7MERGE (p3:Product {productId:'P-1003'}) SET p3.name='Action Camera', p3.price=219.00, p3.rating=4.5MERGE (p4:Product {productId:'P-1004'}) SET p4.name='USB Microphone', p4.price=75.00MERGE (p1)-[:IN_CATEGORY]->(cat1)MERGE (p3)-[:IN_CATEGORY]->(cat1)MERGE (p2)-[:IN_CATEGORY]->(cat2)MERGE (p4)-[:IN_CATEGORY]->(cat2)MERGE (o1:Order {orderId:'O-2001'}) SET o1.placedAt=datetime('2026-08-01T09:00:00Z'), o1.status='paid', o1.total=218.90MERGE (o2:Order {orderId:'O-2002'}) SET o2.placedAt=datetime('2026-08-02T10:30:00Z'), o2.status='paid', o2.total=219.00MERGE (o3:Order {orderId:'O-2003'}) SET o3.placedAt=datetime('2026-08-03T12:00:00Z'), o3.status='shipped', o3.total=129.90MERGE (o4:Order {orderId:'O-2004'}) SET o4.placedAt=datetime('2026-08-04T14:15:00Z'), o4.status='paid', o4.total=164.00MERGE (o5:Order {orderId:'O-2005'}) SET o5.placedAt=datetime('2026-08-04T14:15:00Z'), o5.status='paid', o5.total=75.00MERGE (c1)-[:PLACED]->(o1)MERGE (c2)-[:PLACED]->(o2)MERGE (c1)-[:PLACED]->(o3)MERGE (c3)-[:PLACED]->(o4)MERGE (c4)-[:PLACED]->(o5)MERGE (o1)-[:CONTAINS {quantity:1}]->(p1)MERGE (o1)-[:CONTAINS {quantity:1}]->(p2)MERGE (o2)-[:CONTAINS {quantity:1}]->(p3)MERGE (o3)-[:CONTAINS {quantity:1}]->(p1)MERGE (o4)-[:CONTAINS {quantity:1}]->(p2)MERGE (o4)-[:CONTAINS {quantity:1}]->(p4)MERGE (o5)-[:CONTAINS {quantity:1}]->(p4);
Before analytical work, verify the relevant slice rather than assuming the whole database contains only these entities:
CYPHER 25MATCH (c:Customer) WITH count(c) AS customersMATCH (o:Order) WITH customers, count(o) AS ordersMATCH (p:Product) WITH customers, orders, count(p) AS productsMATCH (:Customer)-[placed:PLACED]->(:Order)WITH customers, orders, products, count(placed) AS placedMATCH (:Order)-[line:CONTAINS]->(:Product)RETURN customers, orders, products, placed, count(line) AS contains;
For the fixture above the invariant is
4 / 5 / 4 / 5 / 7. If Chapter 04 remains loaded,
the total database node/relationship counts will be larger;
label- and type-specific counts are the contract for this
chapter.
1. UNWIND converts one list value into many rows
UNWIND expression AS variable evaluates the
expression and emits rows for its elements. A parameter list of
four product IDs therefore becomes four incoming rows before the
graph lookup.
CYPHER 25UNWIND $productIds AS productIdMATCH (p:Product {productId:productId})RETURN productId, p.name, p.priceORDER BY productId;
Use a parameter such as
['P-1003','P-1001','P-1004']. The final
ORDER BY is intentional: Neo4j does not guarantee
row order from UNWIND, even if the input list has a
familiar order.
| Input shape | Rows after UNWIND | Important consequence |
|---|---|---|
[a,b,c] |
3 | One row per element. |
[a,a] |
2 | Duplicates remain duplicates. |
[] |
0 | The pipeline has no rows after UNWIND. |
null |
0 | Current UNWIND semantics produce no rows. |
| Nested lists | One row per outer element | Use a second UNWIND only if flattening is really intended. |
2. Empty input can intentionally end the pipeline
This is a useful contract for bulk lookup endpoints: an empty requested-ID list naturally returns no result rows. It is dangerous only if the query expected an unrelated result to survive afterward.
CYPHER 25WITH 'request-42' AS requestId, [] AS productIdsUNWIND productIds AS productIdRETURN requestId, productId;
The literal requestId is not returned because there
are no rows left. If the API must return one envelope row even
for empty input, handle that shape in the application or
deliberately transform the empty list into a sentinel list—while
clearly documenting the sentinel semantics.
3. Duplicates are data until you decide otherwise
If a caller requests the same product twice,
UNWIND will emit two rows. That can be correct for
a quantity-like input, or wrong for a set-like lookup. The
database cannot infer which contract you intended.
CYPHER 25UNWIND ['P-1001','P-1001','P-1002'] AS productIdWITH productId, count(*) AS requestedMultiplicityMATCH (p:Product {productId:productId})RETURN p.productId AS productId, requestedMultiplicityORDER BY productId;
If the endpoint means “unique products,” use
WITH DISTINCT productId before the graph lookup. If
duplicates represent requested quantity, preserve and aggregate
them explicitly as above.
4. Batching is an application/database boundary decision
One query per product pays repeated network, parsing/planning and transaction overhead. One enormous list can create a large request, long transaction and bursty memory pressure. The practical pattern is to send bounded parameter batches through the official driver and measure total throughput, per-batch latency and error/retry behavior.
CYPHER 25UNWIND $rows AS rowMATCH (p:Product {productId:row.productId})RETURN row.requestPosition AS requestPosition, p.productId AS productId, p.name AS nameORDER BY requestPosition;
Keep the original request position as data if caller order
matters. Do not rely on UNWIND to preserve it.
Batch size is workload- and driver-dependent; the course
deliberately does not prescribe a universal value.
5. Deliberately wrong: “UNWIND preserves my array order”
CYPHER 25UNWIND $productIds AS productIdMATCH (p:Product {productId:productId})RETURN p.productId AS productIdLIMIT 2;
The repair is to carry an explicit ordinal in each input map and order by it before limiting. This turns an incidental observation into a testable contract.
CYPHER 25UNWIND $rows AS rowMATCH (p:Product {productId:row.productId})RETURN row.ordinal AS ordinal, p.productId AS productIdORDER BY ordinalLIMIT 2;
Lab: prove cardinality for normal, duplicate and empty batches
Run the same query with three fixtures: three distinct IDs, a
list containing one duplicate, and an empty list. Record input
count, rows immediately after UNWIND, rows after
MATCH, and final rows. A missing product ID can
reduce rows at the MATCH stage, which is different
from an empty input reducing rows at UNWIND.
CYPHER 25UNWIND $productIds AS productIdWITH productId, 1 AS emittedOPTIONAL MATCH (p:Product {productId:productId})RETURN count(*) AS unwoundRows, count(p) AS matchedProducts, count(*) - count(p) AS missingIds;
OPTIONAL MATCH is used only for diagnosis so
missing IDs remain visible. For a normal lookup endpoint you may
prefer to reject missing IDs or return a separate missing-ID
collection.
Check your understanding
- Does UNWIND guarantee the order of list elements in output rows?
- What happens when UNWIND receives an empty list?
- Why might WITH DISTINCT immediately after UNWIND be wrong?
- Why include an ordinal in input maps?
- What should determine batch size?
Review the answers
1. No. Only an explicit ORDER BY establishes result order.
2. It emits zero rows, so downstream clauses receive no rows.
3. Duplicates may encode meaningful multiplicity such as quantity; deduplicate only if the contract is set-like.
4. It lets the query reconstruct caller order explicitly and testably.
5. Measured request size, transaction duration, server/driver memory, latency, throughput and failure/retry behavior—not a universal magic number.
Summary and next step
UNWIND is a cardinality operator: it turns one
collection into a row stream and therefore changes the cost and
correctness surface. Next, isolate parts of that row stream with
CALL and EXISTS subqueries while
keeping variable scope explicit.
Authoritative references
- Current Neo4j versions — Release/LTS snapshot used to pin the course baseline.
- Cypher Manual — Introduction — Cypher 25 status and language-version policy.
- Select Cypher version — Database/default and per-query Cypher version behavior.
- UNWIND — Current list-to-row semantics, empty/null behavior and row-order warning.
- ORDER BY — Only explicit ordering guarantees output row order.
- Parameters — Parameterized query values and query-plan reuse considerations.