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.

Intermediate → Advanced115–140 minutesUNWIND/batching labNeo4j 2026.07.1 Community · Cypher 25Last reviewed: September 2026

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.

01

Explain UNWIND as a list-to-row cardinality operator.

02

Predict behavior for duplicate elements, empty lists, null, nested lists and scalar inputs.

03

Preserve deterministic output by applying ORDER BY after row creation rather than trusting list order.

04

Batch application input deliberately without making one enormous parameter or one driver round-trip per element.

05

Deduplicate at the stage that matches the business contract, not reflexively at the end.

Chapter 05 baseline · reviewed 9 September 2026

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.

Evidence, not invented output

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 · idempotent Chapter 05 analytical 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;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 · fixture acceptance counts
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 · parameter list becomes lookup rows
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 · empty list proves row elimination
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 · compare multiplicity with set semantics
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 · one bounded batch per driver execution
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”

Wrong assumption · LIMIT does not define which first two input elements survive
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.

Correct · carry and sort an ordinal
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 · explicit row-count checkpoints
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

  1. Does UNWIND guarantee the order of list elements in output rows?
  2. What happens when UNWIND receives an empty list?
  3. Why might WITH DISTINCT immediately after UNWIND be wrong?
  4. Why include an ordinal in input maps?
  5. 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

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.