Cypher Query Examples for LoraDB
A copy-paste tour of LoraDB's Cypher surface, organised by query shape. Each section is a standalone recipe — read top-to-bottom to pick up the language, or jump straight to what you need.
Working through this for the first time? Try the guided version at A Ten-Minute Tour first.
On this page
- Creating data
- Pattern matching
- Filtering with WHERE
- Optional patterns
- Aggregation
- Parameters
- Updating and deleting
- CASE expressions
- Escaping with backticks
- Common patterns
- Realistic shapes — fully worked domain examples
Creating data
Before you can query a graph, you need one. CREATE is the
simplest way to write nodes and
relationships; relationships can only
connect existing nodes, so we bind the endpoints with their labels and
properties first.
// Seed a tiny social graph
CREATE (ada:Person {name: 'Ada', born: 1815})
CREATE (grace:Person {name: 'Grace', born: 1906})
CREATE (alan:Person {name: 'Alan', born: 1912})
CREATE (ada)-[:INFLUENCED {decade: 1840}]->(alan)
CREATE (grace)-[:KNOWS]->(alan)
Three Person nodes and two relationships. The INFLUENCED edge
carries its own property (decade). Person, INFLUENCED, and KNOWS
are created implicitly on first use — LoraDB has no separate
CREATE TABLE step.
Pattern matching
MATCH finds every way to satisfy a pattern. One row per
match. The pattern (p:Person)-[:KNOWS]->(other:Person) reads as "a
Person p with an outgoing KNOWS edge to another Person other."
MATCH (p:Person)-[:KNOWS]->(other:Person)
RETURN p.name AS from, other.name AS to
Returns one row (Grace → Alan) in our seed graph. AS from and
AS to rename the projected columns — useful when a consumer expects
specific field names.
Multi-hop
MATCH (a:Person)-[:INFLUENCED]->(b)-[:KNOWS]->(c)
RETURN a.name, b.name, c.name
Either direction
MATCH (a:Person)-[:KNOWS]-(b)
RETURN a.name, b.name
The undirected dash matches both a -> b and a <- b — see
Match → Relationship patterns.
Filtering with WHERE
WHERE runs after MATCH and can reference anything the
match bound. String operators
like STARTS WITH and CONTAINS are case-sensitive — pass through
toLower / toUpper for
case-insensitive checks.
MATCH (p:Person)
WHERE p.born < 1900 AND p.name STARTS WITH 'A'
RETURN p
ORDER BY p.born ASC
LIMIT 10
In our seed graph this finds Ada (born 1815), filters by name prefix,
and returns up to 10 matches sorted oldest-first.
ORDER BY and LIMIT always run after projection.
IN list membership
MATCH (p:Person)
WHERE p.born IN [1815, 1906, 1912]
RETURN p.name, p.born
NOT EXISTS (anti-join)
MATCH (p:Person)
WHERE NOT EXISTS { (p)-[:INFLUENCED]->() }
RETURN p.name AS uninfluential_person
Optional patterns
OPTIONAL MATCH is the graph equivalent of a
left-join — if the pattern doesn't match, bound variables are set to
null rather than dropping the row.
MATCH (p:Person {name: 'Ada'})
OPTIONAL MATCH (p)-[:INFLUENCED]->(target)
RETURN p.name, target.name
Useful when you want "every person, plus what they influenced if
anything": people without INFLUENCED edges still appear, with
target.name as null.
Aggregation
Any non-aggregated column becomes an
implicit group key. Here, p.name groups rows
per person; count(friend) is the
group size.
MATCH (p:Person)-[r:KNOWS]->(friend)
RETURN p.name AS person, count(friend) AS friends
ORDER BY friends DESC
Why
count(friend)notcount(*)?count(*)counts rows. Ifpnever matched, there'd be no row to count.count(friend)counts non-null bindings — the distinction matters once you start mixingOPTIONAL MATCHwith aggregation.
Collect into a list
MATCH (p:Person)-[:INFLUENCED]->(target:Person)
RETURN p.name AS influencer,
collect(target.name) AS influenced
Multiple aggregates
MATCH (p:Person)
RETURN count(*) AS people,
min(p.born) AS earliest,
max(p.born) AS latest,
avg(p.born) AS mean_year
Parameters
Parameters are the only way to safely mix untrusted
input into a query. Unbound parameters resolve to null, which usually
filters everything out — worth validating on the host side before you
call execute.
MATCH (p:Person {name: $name})
WHERE p.born >= $minYear
RETURN p
A variable that happens to be the same name as a parameter doesn't
collide — $name always refers to the bound value, p.name always to
the property.
List parameters
MATCH (p:Person)
WHERE p.born IN $years
RETURN p
Map parameters (patch update)
MATCH (p:Person {name: $name})
SET p += $patch
RETURN p
Updating and deleting
SET updates properties. SET n.a = null
effectively removes the property. SET n = {...} replaces the full
property map, which is almost never
what you want — use SET n += {...} to merge.
MATCH (p:Person {name: 'Ada'})
SET p.born = 1815, p.field = 'Mathematics'
RETURN p
Deleting a node with relationships fails unless you use
DETACH DELETE, which removes the edges
first:
MATCH (p:Person {name: 'Alan'})
DETACH DELETE p
Once Alan is gone, so is the Grace -> Alan KNOWS edge.
CASE expressions
CASE is LoraDB's conditional
expression — the ternary / switch of Cypher. Two forms.
Simple form — match on a value
MATCH (o:Order)
RETURN o.id,
CASE o.status
WHEN 'paid' THEN 'counted'
WHEN 'cancelled' THEN 'refunded'
WHEN 'pending' THEN 'waiting'
ELSE 'unknown'
END AS state
Generic form — boolean per branch
MATCH (p:Product)
RETURN p.name,
CASE
WHEN p.stock = 0 THEN 'out'
WHEN p.stock < 10 THEN 'low'
ELSE 'ok'
END AS availability
Conditional count (CASE inside count)
count(expr) skips null, so a CASE with no ELSE is a clean way
to express "count the rows that match this condition":
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,
count(*) AS total
Custom sort order
MATCH (t:Task)
RETURN t.title
ORDER BY CASE t.priority
WHEN 'p0' THEN 0
WHEN 'p1' THEN 1
WHEN 'p2' THEN 2
ELSE 3
END
Natural string order would give you p0, p1, p2 by accident here
— but the moment you introduce urgent or low, CASE is the only
way to keep the order semantically meaningful.
In SET (compute-then-assign)
MATCH (u:User)
SET u.tier = CASE
WHEN u.score >= 1000 THEN 'platinum'
WHEN u.score >= 100 THEN 'gold'
ELSE 'bronze'
END
See RETURN → CASE for the full reference.
Escaping with backticks
Identifiers that clash with keywords or contain special characters can be wrapped in backticks. Useful if you're importing data from a system that doesn't share Cypher's identifier rules.
MATCH (`first person`:Person)
RETURN `first person`.name
Common patterns
Count nodes by label
MATCH (n:Person)
RETURN count(*) AS people
Group by a property
MATCH (p:Person)
RETURN p.born / 100 * 100 AS century, count(*) AS n
ORDER BY century
Divide-then-multiply truncates to the century. One row per century.
Distinct values
MATCH (p:Person)
RETURN DISTINCT p.born
ORDER BY p.born
Filter before aggregating
MATCH (p:Person)
WHERE p.born >= 1900
RETURN count(*) AS modern_people
Filter after aggregating (HAVING-style)
Cypher has no HAVING. Pipe through WITH, then
filter:
MATCH (p:Person)-[:KNOWS]->(friend)
WITH p.name AS person, count(friend) AS friends
WHERE friends >= 2
RETURN person, friends
Top-N
MATCH (p:Person)-[:KNOWS]->(friend)
RETURN p.name AS person, count(friend) AS friends
ORDER BY friends DESC
LIMIT 5
Upsert (create-or-match)
MERGE finds the pattern or creates it —
useful to avoid accidental duplicates.
MERGE (u:User {id: $id})
ON MATCH SET u.last_seen = timestamp()
ON CREATE SET u.created = timestamp()
RETURN u
Bulk load via UNWIND
UNWIND $rows AS row
CREATE (:Event {id: row.id, at: datetime(row.at), kind: row.kind})
One row per element of the $rows parameter list — see
UNWIND. The idiomatic way to import hundreds
or thousands of records in a single query.