RETURN and WITH — Projecting and Piping Results
Both clauses project rows forward. WITH hands the
projected rows to the next clause; RETURN ends the
query and hands them back to the caller. Rows typically come from a
preceding MATCH or UNWIND.
Think of
WITHas a pipe between stages, andRETURNas the output of the final stage.
Overview
| Goal | Clause |
|---|---|
| Shape the final output | RETURN |
| Rename a column | RETURN expr AS name |
| Deduplicate rows | DISTINCT |
| Sort / paginate | ORDER BY, SKIP, LIMIT |
| Build a subset-map per entity | Map projection |
| Conditional per-row value | CASE … WHEN … THEN … END |
| Pipe into the next stage | WITH |
| HAVING-style filtering | WITH … WHERE |
| Combine two result sets | UNION / UNION ALL |
RETURN
Basic projection
Return whole entities, bare properties, or any expression.
MATCH (n) RETURN n;
MATCH (n) RETURN n.name, n.age;
MATCH (n) RETURN n.name AS userName;
MATCH (n) RETURN n.age * 2 AS doubled_ageAliases (AS) set the column name in the host response. Reserve them
for anything the consumer has to look up by key.
Star
RETURN * projects every variable in scope. Handy for exploratory work,
noisy for production queries.
MATCH (a)-[r]->(b) RETURN *;
MATCH (a)-[r]->(b) RETURN *, a.name AS nameLiteral expressions
Return constants, function calls, arithmetic:
RETURN 1 + 2 AS three;
RETURN temporal.timestamp() AS now_ms;
RETURN temporal.now() AS now, temporal.today() AS today;
RETURN 'hello, ' + $name AS greetingDISTINCT
Deduplicate the output rows. Applies to the full row, not per-column.
MATCH (n) RETURN DISTINCT n.city;
MATCH (p:Person)-[:WROTE]->(:Post) RETURN DISTINCT pDISTINCT runs before ORDER BY and is expensive on
large inputs — prefer filtering with WHERE first.
ORDER BY, SKIP, LIMIT
Shape the final result set. Full reference: Ordering & Pagination.
MATCH (n) RETURN n ORDER BY n.name ASC;
MATCH (n) RETURN n ORDER BY n.last ASC, n.first DESC;
MATCH (n) RETURN n ORDER BY n.name DESC SKIP 5 LIMIT 10;
MATCH (n) RETURN n LIMIT 1SKIP and LIMIT take a non-negative integer. null (for example an
unbound $limit), a negative number or a fraction is an error, not
"no limit".
Map projection
Shape a node or relationship into a map with only the keys you want — useful when the consumer doesn't need every property.
// Pick a subset
MATCH (n:User) RETURN n {.name, .age}
;// All properties (equivalent to \`properties(n)\`)
MATCH (n:User) RETURN n {.*}
;// Rename + compute
MATCH (n:User) RETURN n {.name, score: n.age * 2}
;// Include related data
MATCH (u:User)
RETURN u {.name, posts: [(u)-[:WROTE]->(p) | p.title]}See also Lists & Maps → Map projection.
CASE expressions
CASE is LoraDB's conditional expression — the Cypher equivalent of
SQL's CASE or a ternary. It's a plain expression, so it works
anywhere a value is allowed: RETURN, WITH, SET, ORDER BY, and
inside predicates.
Two forms.
Simple form — match an input against successive values:
MATCH (p:Product)
RETURN p.name,
CASE p.tier
WHEN 'gold' THEN 1.2
WHEN 'silver' THEN 1.1
WHEN 'bronze' THEN 1.0
ELSE 0.9
END AS multiplierGeneric form — each branch is its own boolean expression:
MATCH (o:Order)
RETURN o.id,
CASE
WHEN o.amount >= 1000 THEN 'large'
WHEN o.amount >= 100 THEN 'medium'
WHEN o.amount >= 10 THEN 'small'
ELSE 'tiny'
END AS bucketThe generic form is the one you'll reach for most often — it allows arbitrary predicates per branch, including null-safe checks and pattern-based predicates.
ELSE is optional
Omitting ELSE implicitly falls through to null:
MATCH (u:User)
RETURN u.name,
CASE WHEN u.score > 100 THEN 'pro' END AS tier
// tier is null for users at or below 100Branches are short-circuit
Branches evaluate top-to-bottom; the first matching WHEN wins. Place
the narrowest condition first if branches overlap.
Type coercion across branches
Every branch — including the implicit null from a missing ELSE —
can return any type. Nothing forces uniformity. Most callers prefer
one type per CASE for predictable downstream shape:
RETURN CASE WHEN $has_value THEN $value ELSE null END AS maybeIn predicates and filters
CASE is an expression, so it composes inside
WHERE and ORDER BY:
MATCH (p:Product)
WHERE CASE
WHEN p.on_sale THEN p.sale_price
ELSE p.price
END < $max
RETURN pMATCH (t:Task)
RETURN t
ORDER BY CASE t.status
WHEN 'urgent' THEN 0
WHEN 'open' THEN 1
ELSE 2
END, t.created_atThat ordering pattern is how you express "custom priority order" —
ASCII/byte order on the status string would give you open, urgent,
not what you want.
In SET and aggregates
MATCH (u:User)
SET u.tier = CASE WHEN u.score >= 100 THEN 'pro' ELSE 'free' ENDMATCH (r:Review)
RETURN r.product,
count(CASE WHEN r.stars >= 4 THEN 1 END) AS positive,
count(CASE WHEN r.stars <= 2 THEN 1 END) AS negativeCombining CASE with count(expr)
is the idiomatic way to express "count rows that satisfy X" inside a
larger aggregation — count skips null, so the missing ELSE
branch is exactly what you want.
See also
coalesce— a compact shorthand when you only need "first non-null".- WHERE → boolean operators — three-valued
logic rules that
CASEpredicates follow. - Ordering by computed expression.
WITH
WITH is the pipe of Cypher. Use it to split a query into stages. The
projected rows of one stage become the input rows of the next.
Piping variables
The simplest WITH — pass the bindings through untouched:
MATCH (a)-[r]->(b)
WITH a, r, b
RETURN a, r, bThat's pedagogical; a real query uses WITH to change something.
Transforming between stages
WITH can rename, compute, filter, aggregate — anything RETURN does
at the end of the pipeline.
MATCH (u:User)
WITH u, u.born AS year
WHERE year < 1900
RETURN u.name, yearHAVING-style filtering (WITH)
Aggregates are not allowed in WHERE. Aggregate into a
WITH, then filter:
MATCH (p:Person)-[:WORKS_AT]->(c:Company)
WITH c.name AS company, count(p) AS employees
WHERE employees > 5
RETURN company, employeesSee Aggregation → HAVING-style filtering.
Renaming and shaping
MATCH (n:User)
WITH n.name AS username
RETURN usernameOrdering inside a pipeline
ORDER BY and LIMIT attach to a WITH stage just like they do to a
final RETURN. Only surviving rows move forward.
MATCH (n:User)
WITH n
ORDER BY n.age DESC
LIMIT 3
MATCH (n)-[:FOLLOWS]->(other)
RETURN n.name, other.nameChaining multiple WITH stages
MATCH (o:Order)-[:CONTAINS]->(i:Item)
WITH o, sum(i.price) AS total
WHERE total > 100
WITH o, total
ORDER BY total DESC
LIMIT 20
RETURN o.id, totalEach stage's output columns become the next stage's bindings — any variable not projected is dropped.
Losing variables through WITH
A variable must be explicitly projected into WITH to survive. This is
a common source of Unknown variable errors:
MATCH (a:User)-[r:KNOWS]->(b)
WITH a // r and b drop out of scope here
RETURN a, r // error: r is not in scopeEither pipe them through (WITH a, r, b) or don't bind them in the
first place.
UNION / UNION ALL
Combine two result sets that share a column shape. UNION
deduplicates; UNION ALL doesn't.
MATCH (n:User) RETURN n.name AS name
UNION
MATCH (n:Product) RETURN n.name AS nameMATCH (a:A) RETURN a.v AS v
UNION ALL
MATCH (b:B) RETURN b.v AS v
UNION ALL
MATCH (c:C) RETURN c.v AS vORDER BY / LIMIT across UNION
Apply at the very end — they shape the combined result:
MATCH (n:User) RETURN n.name AS name
UNION ALL
MATCH (n:Product) RETURN n.name AS name
ORDER BY name
LIMIT 10Column shape must match
Both sides must expose the same column names in the same order:
// Valid
MATCH (n:User) RETURN n.name AS name, 'user' AS kind
UNION ALL
MATCH (n:Product) RETURN n.name AS name, 'product' AS kind
;// Invalid — column shape mismatch
MATCH (n:User) RETURN n.name, 'user'
UNION
MATCH (n:Product) RETURN n.name, n.price, 'product'UNION inside CALL
A UNION inside a CALL { } body combines the branches once per outer
row. The query after the CALL can then sort, page and count the combined
rows:
CALL {
MATCH (f:ImageFile) RETURN f
UNION ALL
MATCH (f:VideoFile) RETURN f
}
WITH f ORDER BY f.size DESC LIMIT 20
RETURN f.key AS key, f.size AS sizeEach branch sees the outer row's variables, but not the variables of the
branch before it. Every branch ends in RETURN with the same column
names in the same order. Plain UNION drops duplicate rows within each
outer row, so the same row can still appear once for each outer row:
MATCH (p:Person)
CALL {
WITH p MATCH (p)-[:LIKES]->(t) RETURN t.name AS thing
UNION
WITH p MATCH (p)-[:OWNS]->(t) RETURN t.name AS thing
}
RETURN p.name AS person, thingCommon patterns
Pick N, then follow
Top-3 users by age, then project their friends:
MATCH (u:User)
WITH u ORDER BY u.age DESC LIMIT 3
MATCH (u)-[:FOLLOWS]->(f)
RETURN u.name, collect(f.name) AS followingCount-and-rank
MATCH (u:User)-[:WROTE]->(p:Post)
WITH u, count(p) AS posts
ORDER BY posts DESC
LIMIT 10
RETURN u.name, postsProject the top of a nested list
MATCH (p:Person)
RETURN p.name,
[(p)-[:KNOWS]->(f) | f.name][..5] AS first_five_friendsKeep only rows that meet an aggregate
MATCH (r:Review)
WITH r.product AS product, avg(r.stars) AS mean
WHERE mean >= 4.5
RETURN product, mean
ORDER BY mean DESCUsing both RETURN DISTINCT and ORDER BY
DISTINCT runs first — you can only order by projected columns.
MATCH (p:Person)
RETURN DISTINCT p.city AS city
ORDER BY cityEdge cases
Empty aggregation input
RETURN count(*) with zero matches still emits one row with value 0.
sum returns 0 on empty input; avg, min and max return null. See
Aggregation → count.
WITH without projection
Every WITH must project at least one thing — there's no "pass
everything" shorthand. WITH * works and projects every in-scope
variable:
MATCH (a)-[r]->(b)
WITH *
RETURN a, r, bAggregation in WITH without a group key
Aggregating with no non-aggregated column folds everything into one row:
MATCH (o:Order)
WITH sum(o.amount) AS total
RETURN totalSee also
- MATCH — source of rows.
- WHERE — predicate filtering; also used after
WITH. - Aggregation — group-and-collapse semantics.
- Ordering & Pagination —
ORDER BY,SKIP,LIMIT. - Lists & Maps → Map projection.
- List Functions → Pattern comprehension — inline nested projections.