Cypher: nodes, relationships, patterns and paths
After this lesson you will write a Cypher pattern as a drawing, create, match and delete with it, count paths and people without mixing them up, and say which algorithm Neo4j runs for a shortest path — and when it can miss one.
Johan wants to learn to surf. On the exercise slides you ask Neo4j for the friends of his friends who surf, and you count them twice. COUNT(surfer) answers 3. COUNT(DISTINCT surfer) answers 1. The graph holds exactly one surfer, Allison, and both numbers are right.
Later in the same session two shortest-path queries differ by two words, WITH p. One is labelled "Always returns a result, if exists", the other "Not guaranteed to return a result, even if it exists" (p. 1373). Keep both puzzles open. Both come from reading a pattern literally: Neo4j looks for every place where your drawing fits the graph, and what you attach to the drawing decides how it searches.
Cypher is ASCII art
The session opens with a definition worth an exam answer: Neo4j "is a graph database management system implemented in Java. It is A.C.I.D. compliant. It has its own query language, named Cypher" (p. 1332). Cypher "focuses on the clarity of expressing what to retrieve from a graph, not on how to retrieve it" (p. 1383): it is declarative, like SQL. The property graph itself is the subject of lesson 10; here you learn to write it down.
The slide that introduces Cypher is a drawing: a circle "You", an arrow ARE_LEARNING, a circle "Cypher". Cypher types that drawing on the keyboard: round brackets for a node, square brackets inside an arrow for a relationship (Fig. 1).
What you draw
What you type
ee and js are the ones of pp. 1334 and 1346). In a query the three lines are written on one line.A node pattern holds a variable, zero or more labels after colons, and properties in curly braces (p. 1336); every part is optional. The variable is a name that lives inside one query; a label is a type tag, and a node may carry several; the property map is a set of equality conditions in MATCH and of values to store in CREATE.
| Pattern | The slide's reading |
|---|---|
() | "Empty node": any node |
(n) | "A node identified as “n”" |
(n:Person) | "The node “n” labeled as “Person”" |
(n:Person:Manager) | "The node “n” labeled as “Person” and “Manager”" |
(n:Person {name: "Mark"}) | "… with the property “name” equal to “Mark”" |
[:KNOWS] | "Relationship labeled as “KNOWS”" |
[k:KNOWS] | "Relationship “KNOWS” identified as “k”" |
[k:KNOWS {since:2007}] | "Relationship “KNOWS” with a parameter “since”" (red pen below) |
[relatedTo] | "Only variable name (any relationship)" |
A relationship pattern has the same three parts, but a relationship has exactly one type. The colon is the whole difference between a name and a filter: [relatedTo] is a variable that matches a relationship of any type, [:ACTED_IN] is a type filter without a name, and [r:ACTED_IN] is both. The slides drop this colon once themselves, on p. 1365; the red pen is in lesson 12.
No colon, no filter: [ACTED_IN] is a variable that matches any relationship; [:ACTED_IN] matches ACTED_IN only.
The arrow head is a filter too. -[:MANAGES]-> and <-[:MANAGES]- fix the direction, and one relationship can be written from either end: (a:Employee)<-[:MANAGES]-(b:Manager) is "a is managed by b" (p. 1344). Leave the head out, -[:KNOWS]-, and the relationship may point either way. Chain them and you have a path pattern: (p)-[:KNOWS]-(friend)-[:KNOWS]-(foaf) is the slide's "Friend-of-a-friend relationship" — in SQL, a Knows table joined with itself.
Writing the graph: CREATE, MERGE, SET, DELETE
CREATE "allows the creation of nodes and relationships" (p. 1334); the slide labels the parts "Variable : Node Label", "Node Properties", "Node - Identified by the parenthesis".
// creates 1 node with the label Person and 3 properties
CREATE (ee:Person { name: "Emil", from: "Sweden", klout: 99 })
One CREATE can write several nodes, separated by commas, and a property may hold a list (p. 1335):
// creates 3 Person nodes; Francis's "from" is a list
CREATE (ee:Person { name: "Emil", from: "Sweden", klout: 99 }),
(a:Person { name: "Richard", from: "UK", klout: 80 }),
(j:Person { name: "Francis", from: ["Sweden","UK"], klout: 75 })
The same slide writes a node with two labels and one with none, and returns what it has just created. Double and single quotes both delimit strings; a list holds values of one type, and a property cannot hold a nested map.
// 2 nodes: one :Person:Fisherman, one with no label
CREATE (ee:Person:Fisherman), (a)
// returns 1 row: "Andy"
CREATE (a {name: 'Andy'}) RETURN a.name
A relationship needs its two end nodes first. Page 1339 creates Mark and Mike, finds them, and draws the arrow:
// creates 2 Person nodes, Mark and Mike
CREATE (e:Person {name: "Mark"}), (a:Person {name: "Mike"})
// returns 1 row: "KNOWS"
MATCH
(a:Person),
(b:Person)
WHERE a.name = 'Mike' AND b.name = 'Mark'
CREATE (a)-[r:KNOWS]->(b)
RETURN type(r)
Neo4j adds two rules the slide leaves implicit: in CREATE a relationship needs a direction ("Only directed relationships are supported in CREATE") and exactly one type ("Exactly one relationship type must be specified for CREATE"). In MATCH both may be left out.
Now run the same MATCH with a second CREATE (p. 1340):
// returns "KNOWS"; Mike now has two KNOWS and one BEFRIEND to Mark
MATCH
(a:Person),
(b:Person)
WHERE a.name = 'Mike' AND b.name = 'Mark'
CREATE (a)-[r:KNOWS {since: 2015}]->(b), (a)-[h:BEFRIEND]->(b)
RETURN type(r)
The slide insists: "N.B. The two KNOWS relationships we created are different even though they have the same name". CREATE never looks before it writes. The create-if-absent clause is MERGE, which lecture 06 shows "ID-based" on p. 270: MERGE (player:Player {id: row.playerUri}) finds the player with that id or creates it, and its ON CREATE SET part runs only on creation.
// creates a KNOWS from Mike to Mark only if none exists;
// a second run finds it and writes nothing
MATCH (a:Person {name: 'Mike'}), (b:Person {name: 'Mark'})
MERGE (a)-[:KNOWS]->(b)
Before: CREATE, run twice
After: MERGE, run twice
MATCH of Mike and Mark, followed by CREATE (a)-[:KNOWS]->(b) or by MERGE (a)-[:KNOWS]->(b), each run twice. CREATE writes a new relationship every time; MERGE first looks for the whole pattern and writes only when it is missing."MERGE checks the key." No: it matches the whole pattern. If Mark has no age, MERGE (p:Person {name: 'Mark', age: 30}) creates a second Mark. Merge on the identifying property, then SET the rest.
SET changes what exists: SET p.age = 30 writes a property, SET p:Manager adds a label (p. 1367 tags actors this way), REMOVE p:Manager takes it off.
// 1 row: Emil, now :Person:Fisherman with klout 100
MATCH (ee:Person {name: "Emil"})
SET ee:Fisherman, ee.klout = 100
RETURN labels(ee), ee.klout
Deleting starts from a MATCH: you delete what a variable points to. Page 1342 deletes Ben's KNOWS relationships, leaving the far end anonymous, (); page 1337 deletes Ben.
// deletes Ben's outgoing KNOWS relationships, not the nodes
MATCH (n {name: 'Ben'})-[r:KNOWS]->() DELETE r
// deletes the node Ben, but only if no relationship touches him
MATCH (n:Person {name: 'Ben'}) DELETE n
If Ben still knows someone, the second statement fails and nothing changes: "Cannot delete node<…>, because it still has relationships." A graph never keeps an arrow with a missing end. DETACH "removes all the relationships before removing the nodes" (p. 1343):
// deletes every node with all its relationships: an empty graph
MATCH (n) DETACH DELETE n
Before: DELETE n
After: DETACH DELETE n
MATCH (n:Person {name: 'Ben'}) followed by DELETE n or by DETACH DELETE n. Plain DELETE refuses to leave a relationship without its end; DETACH removes the relationships first (p. 1343).Reading the graph: the movie database
Reading clauses come in a fixed order (p. 1338):
// 1 row: the node Emil
MATCH (ee:Person) WHERE ee.name = "Emil" RETURN ee;
The slide says "the MATCH clause returns all the nodes that match the conditions". Sharpen it: MATCH finds every way the pattern fits and makes one row per way, WHERE keeps the rows whose condition is true, RETURN picks the columns. "One row per way" is the key to the hook.
The exercises then switch to Neo4j's movie graph (p. 1353), the one :play movies loads into the Neo4j Browser. The slide lists "Actors", "Directors" and "Films – title, release, tagline"; in the data every person is a :Person and every film a :Movie, acting and directing are relationships, not labels, and the year is released.
| Element | Connects | Properties | How many |
|---|---|---|---|
:Person | node label | name, born | 133 |
:Movie | node label | title, released, tagline | 38 |
ACTED_IN | Person → Movie | roles (a list) | 172 |
DIRECTED | Person → Movie | — | 44 |
PRODUCED | Person → Movie | — | 15 |
WROTE | Person → Movie | — | 10 |
REVIEWED | Person → Movie | summary, rating | 9 |
FOLLOWS | Person → Person | — | 3 |
The node queries of pp. 1354–1355 show two ways of filtering. A property map is an equality test, a label narrows the search, LIMIT caps the rows:
// 1 row: Tom Hanks (no label: every node is checked)
MATCH (tom {name: "Tom Hanks"}) RETURN tom
// 1 row: the film Cloud Atlas
MATCH (cloudAtlas {title: "Cloud Atlas"}) RETURN cloudAtlas
// 10 rows: the names of 10 people
MATCH (people:Person) RETURN people.name LIMIT 10
Anything other than equality needs WHERE:
// 20 rows: the films released from 1990 to 1999
MATCH (nineties:Movie)
WHERE nineties.released >= 1990 AND nineties.released < 2000
RETURN nineties.title
// 1 row: 106
MATCH (person:Person)
WHERE person.born < 1970
RETURN COUNT(person.name)
"So 133 − 106 = 27 people were born in 1970 or later." No, 22: five people have no born, so person.born < 1970 is null for them, and WHERE keeps only true rows.
Pattern queries put relationships to work (pp. 1356–1359). Read each aloud: "Tom Hanks, acted in, some movie".
// 12 rows: Tom Hanks and one of his films per row
MATCH (tom:Person {name: "Tom Hanks"})-[:ACTED_IN]->(tomHanksMovies)
RETURN tom,tomHanksMovies
The slide's picture also shows a DIRECTED arrow to That Thing You Do: the Browser draws every relationship between the nodes on screen (Tom Hanks also directed it), but the twelve rows hold only ACTED_IN matches.
// 3 rows: Tom Tykwer, Lilly Wachowski, Lana Wachowski
MATCH (cloudAtlas {title: "Cloud Atlas"})<-[:DIRECTED]-(directors)
RETURN directors.name
The "Inverse Relationship" of p. 1357 is the same arrow read from the film: (directors)-[:DIRECTED]->(cloudAtlas …) gives the same names. Two arrows meeting in one node find co-actors (p. 1358):
// 39 rows but 34 different people: Meg Ryan comes back 3 times
MATCH (tom:Person {name:"Tom Hanks"})-[:ACTED_IN]->(m)
<-[:ACTED_IN]-(coActors)
RETURN coActors.name
The shared film (m) does the work of a join. Tom Hanks is never his own co-actor: inside one pattern each relationship is used at most once, and his ACTED_IN to m is taken by the left arm. Meg Ryan returns three times: three shared films, three rows. Last, a relationship with a variable and no type (p. 1359):
// 10 rows: 4 ACTED_IN, 3 DIRECTED, WROTE, PRODUCED, REVIEWED
MATCH (people:Person)-[relatedTo]-(:Movie {title: "Cloud Atlas"})
RETURN people.name, Type(relatedTo), relatedTo
type() returns the type's name (function names ignore case, so Type works); the film has "No variable name" because nothing is returned from it. Try every mark of these four drawings:
Rows are paths: DISTINCT, COUNT and the plan
Back to the hook. The first dataset of the session is the graph of Neo4j's getting-started guide (the slides show only the queries): Emil, Johan, Ian, Rik and Allison, joined by seven KNOWS relationships. "Let's find all of Emil's friends" (p. 1345):
// 2 rows: Emil with Johan, Emil with Ian
MATCH (ee:Person)-[:KNOWS]-(friends)
WHERE ee.name = "Emil" RETURN ee, friends
Then "a new friend that can teach surf to Johan" (p. 1346): a friend of a friend, with an anonymous middle node () because nobody needs its name.
// 1 row: Allison
MATCH (js:Person)-[:KNOWS]-()-[:KNOWS]-(surfer)
WHERE js.name = "Johan" AND surfer.hobby = "surfing"
RETURN DISTINCT surfer
"What would happen if we remove the DISTINCT clause from the query?" (p. 1347). With RETURN COUNT(surfer) the answer is 3, with RETURN COUNT(DISTINCT surfer) it is 1. Three different paths of two KNOWS relationships lead from Johan to Allison: through Ian over the arrow Johan→Ian, through Ian over the arrow Ian→Johan (a second relationship, accepted by the head-less pattern), and through Rik. MATCH makes one row per path; COUNT(x) counts the rows in which x is not null, COUNT(DISTINCT x) the different values. Two numbers, two questions: how many ways, how many people.
A row is one way the drawing fits: COUNT(x) counts paths, COUNT(DISTINCT x) counts things.
One rule keeps the counting finite: within one pattern each relationship is used at most once per row, while nodes may repeat — relationship uniqueness. Johan can be his own friend-of-a-friend, out to Ian over one arrow and back over the other, but never over the same arrow twice.
At the exam, "the number of different X" means count(DISTINCT x): two waiters named Federico at the same table give two paths to every dish ordered there.
How does Neo4j turn the pattern into rows? PROFILE "executes the query and provides a detailed execution plan" (p. 1348). Fig. 4 redraws the plan of p. 1349: each box is an operator, and the numbers between the boxes are the rows flowing from one to the next.
Distinct leaves 1. Operators, details and numbers as printed on the slide; the slide lays the boxes out in three columns.The filter not anon_0 = anon_2 is relationship uniqueness written out, and the literal "Johan" has become the parameter $autostring_0 ("Use parameters instead of literals", p. 282). "Estimated rows" are a guess from statistics; "rows" and "db hits" are measured. EXPLAIN shows the same tree without running the query, estimates only: "Use PROFILE / EXPLAIN to analyze the performance of your queries" (p. 282).
Variable-length paths and shortest paths
A star inside the brackets repeats the relationship. "Movies and actors up to 4 "hops" away from "Kevin Bacon"" (p. 1360):
// 135 rows: the nodes 1 to 4 relationships away
// (297 paths before DISTINCT)
MATCH (bacon:Person {name:"Kevin Bacon"})-[*1..4]-(hollywood)
RETURN DISTINCT hollywood
The callouts give the rules: "Distance computed w.r.t. relationships", "Any node", and "Distinct clause, as many paths may lead to the same node". "Movies and actors" undersells it: (hollywood) has no label and the brackets no type, so directors, writers, producers and reviewers come too — and Kevin Bacon himself, around a cycle of four different relationships through Apollo 13, Ron Howard and Frost/Nixon.
| On the slide | The slide's reading | In Neo4j |
|---|---|---|
()-[*]->() | "Directed path of any length (1 or more relationships)" | correct |
()-[*0..]->() | "… including zero relationships" | correct: the zero-length path is the start node itself |
()-[*1..3]->() | "Directed path of length between 1 and 3" | correct |
()-[*]-(), ()-[*1..]-() | "Undirected path of any length" / "of length 1 or more" | correct |
()-[*0..2]-() | "Undirected path of length between 0 and 2" | correct |
()-[]+->() | "One or more relationships (same as [*1..])" | syntax error: write ()-[]->+() |
()-[]*->() | "Zero or more relationships (same as [*0..])" | syntax error: write ()-[]->*() |
()-[]?-() | "Optional relationship (zero or one)" | syntax error: write ()-[*0..1]-() |
MATCH p = (a)-[:FRIEND*1..3]->(b) | "p is the variable holding the path from a to b through 1 to 3 FRIEND relationships" | correct |
The defaults matter: [*] means one or more relationships, never zero; [*0..] also matches the zero-length path, whose ends are one node; the type comes before the star, [:FRIEND*1..3]. And the rule to keep: "Always set an upper limit for your variable length patterns. It's easy to have a query touch all nodes in a graph by mistake" (p. 282).
The Browser underlines risky queries in yellow: "WARNINGS may arise … some operators may be deprecated or the query is performing a lot of operations e.g., cartesian products among the nodes or unbound number of relationships" (p. 1390). A warning is not an error; the query runs. MATCH (a:Person), (b:Person) of p. 1339 is two patterns with no relationship between them: its rows are all pairs of persons, a cartesian product, and only the WHERE narrows them to Mike and Mark (Neo4j filters each side first, so there the product is 1 × 1 and harmless). An unbounded [*] may walk the whole graph.
The shortest path between two people is a built-in function (p. 1361):
// 1 row: one path of length 4
// (six paths tie; Neo4j returns one of them)
MATCH p = shortestPath(
(bacon:Person {name:"Kevin Bacon"})-[*]-
(meg:Person {name:"Meg Ryan"})
)
RETURN p
p = … binds the whole path to a variable; RETURN p gives the "start node, end node and path". shortestPath returns one shortest path, and when several tie you cannot choose which; allShortestPaths returns all of them, and lecture 06 writes both with a bound, [*..6] (p. 280):
// 6 rows: every shortest path, all of length 4
MATCH p = allShortestPaths(
(bacon:Person {name:"Kevin Bacon"})-[*..6]-
(meg:Person {name:"Meg Ryan"})
)
RETURN p
Page 1362 binds both people first and allows one relationship type:
// as printed on p. 1362: no rows on the movie graph
MATCH (KevinB:Person {name: 'Kevin Bacon'} ),
(Al:Person {name: 'Al Pacino'}),
p = shortestPath((KevinB)-[:ACTED_IN*]-(Al))
WHERE all(r IN relationships(p) WHERE r.role IS NOT NULL)
RETURN p
Page 1363 takes the WHERE apart: relationships(p) "returns a list containing all the relationships in a path", IN walks r through it, and all() "returns TRUE if the predicate holds for all elements in the given list". Relatives: nodes(p), length(p) (the number of relationships), any(), none().
How Neo4j plans a shortest path
Pages 1368–1373 ask how the shortest path is searched, following Neo4j's manual: "Depending on the predicates to be evaluated, Neo4j plans the shortest path in different ways."
- "By default, Neo4j uses a Fast Bidirectional Breadth-first Search Algorithm if the conditions can be evaluated whilst searching for the path" — "e.g., all nodes must have the Person label", "e.g., no nodes should have a name property".
- "If the predicates need to inspect the whole path before deciding on whether it is valid or not, Neo4j may have to resort to using a Slower Exhaustive Depth-first Search Algorithm" — "e.g., at least one node contains the property name = "Kevin Bacon"".
- "When the Exhaustive Search is planned, it is still only executed when the Fast Algorithm fails to find any matching paths."
Why two algorithms? Breadth-first search grows one ring of neighbours at a time — here from both ends, until the two searches meet — so the first path it completes is a shortest one. A condition on every node or relationship is checked the moment an element is reached: a Movie node fails "all nodes must have the Person label", and everything behind it is "Excluded a priori" (p. 1369). A condition on at least one node cannot be: a branch through a Movie may still reach Kevin Bacon, so it "Cannot be excluded a priori" (p. 1370). Only whole paths can be judged, enumerated depth-first, one branch to its end before the next.
Fast: "All nodes must have the Person label"
Exhaustive: "At least one node contains the property name = "Kevin Bacon""
Pages 1371–1373 compare three queries (the tabs below). WHERE length(p) > 1 needs the whole path, so the plan includes the exhaustive fallback: if the path the fast search finds fails the predicate, the exhaustive search returns the shortest one that passes — "Predicates used in the WHERE clause that apply to the shortest path pattern are evaluated before choosing the shortest matching path" (p. 1371). Put WITH p in between and the predicate leaves the pattern: "the query plan will not include the fallback to the Slower Exhaustive Search Algorithm" (p. 1372). The fast search returns its one path; if that path is a direct link of length 1, WHERE throws it away and nothing is left.
A predicate inside the shortest-path MATCH steers the search; after WITH p it only filters the one path already found.
// fast search; "Always returns a result, if exists"
MATCH (KevinB:Person {name: 'Kevin Bacon'} ),
(Al:Person {name: 'Al Pacino'}),
p = shortestPath((KevinB)-[:ACTED_IN*]-(Al))
WHERE all(r IN relationships(p) WHERE r.role IS NOT NULL)
RETURN p
A condition on every relationship: checked while searching, no fallback needed (p. 1373; on the movie graph write roles).
// exhaustive fallback planned; "Always returns a result, if exists"
MATCH (KevinB:Person {name: 'Kevin Bacon'}),
(Al:Person {name: 'Al Pacino'}),
p = shortestPath((KevinB)-[*]-(Al))
WHERE length(p) > 1
RETURN p
"Must check that the whole path follows the predicate before validity is evaluated" (p. 1372). Neo4j announces it: "Exhaustive shortest path has been planned for your query".
// fast search only;
// "Not guaranteed to return a result, even if it exists"
MATCH (KevinB:Person {name: 'Kevin Bacon'}),
(Al:Person {name: 'Al Pacino'}),
p = shortestPath((KevinB)-[*]-(Al))
WITH p
WHERE length(p) > 1
RETURN p
The shortest path is chosen first, with no condition; WHERE filters that one path afterwards.
On the movie graph the three cannot be told apart (the first once role is read as roles): Kevin Bacon and Al Pacino are not linked directly, so every path between them is longer than 1, and each query returns a path of length 6. The point is the guarantee, and a toy graph with a direct link shows it. The second puzzle is solved: two words moved the predicate out of the pattern.
Exam-style questions
Four exercises in the wording of the past papers, each on the ER scenario of a real paper with the graph model given (designing the model is lesson 12), and two theory questions. No solutions of the papers are published: these model answers are ours, and each query was run on a small test graph in Neo4j 5.26.
Scenario (the restaurant ER of the exam of 6 Sep 2024): Person (Personal_ID, Name, Surname, …) with the child entity Waiter (Hiring Date); Table (Table_ID, Table Number, Max Seats); Dish (Dish_ID, Name, Price, Recipe, Description). A waiter serves tables (Serve); dishes are ordered at tables (Order). Assume this graph model:
(:Person:Waiter {personal_id: String, name: String, …})
-[:SERVE]->(:Table {table_id: String, max_seats: Integer, …})
(:Table)-[:ORDER]->(:Dish {dish_id: String, name: String, …})
Write a Cypher query to count the total number of different dishes ordered at tables whose max seats is greater than 10, considering only tables served by at least one waiter whose name is "Federico".
Show model answer
MATCH (w:Waiter {name: "Federico"})-[:SERVE]->(t:Table)
-[:ORDER]->(d:Dish)
WHERE t.max_seats > 10
RETURN count(DISTINCT d) AS differentDishes
Adapted from exercise 2 of that paper, which asks "for each table" and so needs grouping (lesson 12). What the answer must get right: one pattern for the chain waiter → table → dish, the equality inside the node, the range in WHERE, and DISTINCT. Test graph: two Federicos serve table 1 (12 seats; pasta and fish), one of them also table 2 (16 seats; fish and salad). The pattern gives 6 rows for 3 different dishes; count(d) would answer 6.
Scenario (the natural-disasters ER of the exam of 6 Jun 2024): Natural Disaster (ID, Start Time, Start Date, End Time, End Date, Disaster Description) with the total, exclusive children Earthquake (Magnitude, Mercalli Scale), Flooding (Water Height), Landslide (Landslide Class) and Tornado (EF Scale); the recursive relationship Cause ("A Natural Disaster may cause another Natural Disaster") with the attribute Causal Description. Assume each disaster is one node carrying both labels, and Cause is a relationship from the causing disaster to the caused one:
(:NaturalDisaster:Earthquake {id: String, magnitude: Float, …})
(:NaturalDisaster)-[:CAUSE {causal_description: String}]
->(:NaturalDisaster)
Write a Cypher query to return the earthquakes with a magnitude greater than 8 that caused at least one flooding with the causal description equal to "very strong earthquake", which in turn caused at least one landslide.
Show model answer
MATCH (e:Earthquake)-[c:CAUSE]->(f:Flooding)
-[:CAUSE]->(:Landslide)
WHERE e.magnitude > 8
AND c.causal_description = "very strong earthquake"
RETURN DISTINCT e
Adapted from exercise 3 of that paper, a MongoDB query there ("which in turn caused two landslides" needs a count: lesson 12). The first relationship needs a variable because the condition is on its property; the landslide stays anonymous because nothing is returned from it. DISTINCT, because a flooding that caused two landslides returns its earthquake twice — exactly what the test graph did without it.
Scenario: the same natural-disasters ER and graph model as the previous question.
Write a Cypher query to return the ID and the description of every natural disaster caused, directly or through a chain of at most three Cause relationships, by the natural disaster with ID "ND-01".
Show model answer
MATCH (d:NaturalDisaster {id: "ND-01"})
-[:CAUSE*1..3]->(x:NaturalDisaster)
RETURN DISTINCT x.id, x.disaster_description
Three things matter here: a bounded variable-length pattern with the type before the star, the arrow, and DISTINCT. On the test chain ND-01 → ND-02 → ND-03 → ND-04 → ND-05, plus ND-01 → ND-06 and ND-07 → ND-01, the answer is ND-02, ND-03, ND-04 and ND-06. Drop the arrow head and ND-07, the disaster that caused ND-01, slips in; drop the bound and ND-05, four steps away, does too. DISTINCT costs nothing and protects you as soon as two chains can reach the same disaster.
Scenario (the hospital-heating ER of the exam of 30 Jan 2026): Building (Building_ID, Name, Year), Floor (Floor_ID, Number, Has_Elevator), Heating System (Heater_Sys_ID, Heating_Power) with the (T, E) children HVAC System and Gas Heating System; Has Floor (Building – Floor) and Heats (Heating System – Floor, with Start_Time and End_Time). Assume this graph model:
(:Building {building_id: String, name: String, year: Integer})
-[:HAS_FLOOR]->(:Floor {floor_id: String, number: Integer,
has_elevator: Boolean})
(:HeatingSystem:GasHeatingSystem {heater_sys_id: String, …})
-[:HEATS {start_time: DateTime, end_time: DateTime}]->(:Floor)
Write a Cypher query that returns the buildings built after 1967 with at least one floor with an elevator that is heated by a Gas Heating System. Return the ID and the name of each building once.
Show model answer
MATCH (b:Building)-[:HAS_FLOOR]->(f:Floor)
<-[:HEATS]-(:GasHeatingSystem)
WHERE b.year > 1967 AND f.has_elevator = true
RETURN DISTINCT b.building_id, b.name
Adapted from exercise 1 of that paper (its query adds up beds, which needs grouping). The pattern is the co-actor V of p. 1358 with the floor in the middle, and the child label :GasHeatingSystem alone selects the gas systems. Without DISTINCT the test building came back three times: two gas systems heat one of its floors and one heats another.
On the dataset of the exercise session, this query returns 3:
MATCH (js:Person)-[:KNOWS]-()-[:KNOWS]-(surfer)
WHERE js.name = "Johan" AND surfer.hobby = "surfing"
RETURN COUNT(surfer)
The same query with RETURN COUNT(DISTINCT surfer) returns 1. Explain why, and say which number answers "how many people can teach Johan to surf".
Show model answer
MATCH produces one row for every way the pattern fits the graph, that is one row per path. The only surfer, Allison, is reached from Johan by three different paths of two KNOWS relationships: through Ian over the relationship Johan→Ian, through Ian over the relationship Ian→Johan (the pattern has no direction, and these are two different relationships), and through Rik. Within one path a relationship is never used twice, but different paths may end at the same node. COUNT(surfer) counts the rows, so 3; COUNT(DISTINCT surfer) counts the different nodes, so 1. The number of people is 1. The PROFILE plan shows it: 3 rows enter the Distinct operator and 1 leaves it.
A complete answer rests on two ideas: rows are paths, not nodes, and the two KNOWS relationships between Johan and Ian count as different paths. Naming the three paths turns a vague answer into a precise one; the PROFILE numbers back it up.
True or false: in Neo4j, inserting WITH p between MATCH …, p = shortestPath((a)-[*]-(b)) and WHERE length(p) > 1 never changes the result of the query.
Show model answer
False. Without WITH, the predicate is evaluated as part of the shortest-path pattern; because it needs the whole path, Neo4j plans the slower exhaustive depth-first search as a fallback, so the query returns the shortest path of length greater than 1 whenever one exists. With WITH p, the fast bidirectional breadth-first search returns one shortest path with no predicate; if its length is 1, the WHERE removes it and the query returns no rows, even though a valid path exists.
A wrong answer may cost points, so tick only what you can justify. The words to remember are the slide's: "Always returns a result, if exists" against "Not guaranteed to return a result, even if it exists" (p. 1373).
Check yourself
Ann knows Bob and Bob knows Ann: two KNOWS relationships. How many rows does MATCH (x:Person)-[:KNOWS]-(y:Person) RETURN x.name, y.name return?
On the movie graph, what does MATCH (p:Person)-[ACTED_IN]->(m:Movie) RETURN p.name match?
Ben knows Zoe. You run MATCH (n:Person {name: 'Ben'}) DELETE n. What happens?
About MATCH (bacon:Person {name:"Kevin Bacon"})-[*1..4]-(hollywood) RETURN DISTINCT hollywood (p. 1360). Select all that are true.
You run the query of p. 1339 (MATCH Mike and Mark, CREATE (a)-[r:KNOWS]->(b)) twice, then the same MATCH with MERGE (a)-[:KNOWS]->(b) once. How many KNOWS relationships go from Mike to Mark?
Which WHERE makes Neo4j plan the exhaustive fallback for p = shortestPath((a)-[*]-(b))?
You want to see how Neo4j would execute a DETACH DELETE statement without deleting anything. Which keyword do you put in front?
Key take-aways
- A Cypher pattern is a drawing:
(variable:Label {property: value})for a node,-[variable:TYPE {property: value}]->for a relationship. Every mark is a filter; without a colon a name is only a variable, and without an arrow head the relationship may point either way. CREATEalways writes (a relationship needs a direction and exactly one type, and running it twice makes two);MERGEwrites only when the whole pattern is missing;SETupdates;DELETErefuses a node that still has relationships,DETACH DELETEremoves them first.MATCHmakes one row per path.DISTINCTandcount(DISTINCT x)turn paths into things: 3 rows, 1 surfer. Within a path each relationship is used once; nodes may repeat.PROFILEruns the query and shows its operators with the rows between them (5 → 1 → 4 → 12 → 3 → 1);EXPLAINshows the plan without running it.- A star repeats relationships:
[*]one or more,[*0..]also the zero-length path,[*1..4]one to four; always set an upper bound. The slide's()-[]+->()is not valid Cypher. shortestPathreturns one shortest path,allShortestPathsall of them. Neo4j searches with a fast bidirectional BFS when the condition can be checked during the search and plans an exhaustive DFS fallback when it needs the whole path;WITH pbefore theWHEREremoves the fallback and may return nothing.
What to watch and read
-
The Oracle of Bacon
oracleofbacon.org, since 1996
Type two actors and get the shortest chain of shared films — the answer
shortestPathgives on p. 1361, over a much larger film graph. One minute of play before section 5. -
Getting started with: PLAY Movies
Lju Lazarevic, Neo4j, 2021
How to load the exact movie graph of pp. 1353–1377 in a free Neo4j Sandbox, so that you can re-run every query of this lesson and check the row counts.
-
Cypher Fundamentals
Neo4j GraphAcademy1 h
MATCH, filters, MERGE, SET and DELETE with a live database in the browser, on the same movie graph; do it right after section 4.
-
Graph traversal: DFS and BFS
VisuAlgo, National University of Singapore
Draw a small graph and run both searches: the rings of BFS are the shortest distances, which is why the fast algorithm of p. 1368 finds a shortest path first.
-
Shortest path planning
Cypher Manual 4.4, Neo4j
The page that pp. 1368–1373 follow almost word for word, with the plans themselves: the fallback is an extra branch of the plan that runs only when the fast search returns nothing.