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_age
Aliases (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 name
Literal expressions
Return constants, function calls, arithmetic:
RETURN 1 + 2 AS three
RETURN timestamp() AS now_ms
RETURN datetime() AS now, date() AS today
RETURN 'hello, ' + $name AS greeting
DISTINCT
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 p
DISTINCT 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 1
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 multiplier
Generic 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 bucket
The 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 100
Branches 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 maybe
In 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 p
MATCH (t:Task)
RETURN t
ORDER BY CASE t.status
WHEN 'urgent' THEN 0
WHEN 'open' THEN 1
ELSE 2
END, t.created_at
That 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' END
MATCH (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 negative
Combining 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, b
That'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, year
HAVING-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, employees
See Aggregation → HAVING-style filtering.
Renaming and shaping
MATCH (n:User)
WITH n.name AS username
RETURN username
Ordering 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.name
Chaining 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, total
Each 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 scope
Either 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 name
MATCH (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 v
ORDER 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 10
Column 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'
Common 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 following
Count-and-rank
MATCH (u:User)-[:WROTE]->(p:Post)
WITH u, count(p) AS posts
ORDER BY posts DESC
LIMIT 10
RETURN u.name, posts
Project the top of a nested list
MATCH (p:Person)
RETURN p.name,
[(p)-[:KNOWS]->(f) | f.name][..5] AS first_five_friends
Keep 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 DESC
Using 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 city
Edge cases
Empty aggregation input
RETURN count(*) with zero matches still emits one row with value 0.
sum, avg, min, max return null on empty input. 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, b
Aggregation 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 total
See 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.