WITH as GROUP BY: the ER-to-graph recipe and the exam query

After this lesson you will turn a past paper's ER diagram into a Neo4j model in the exam's textual notation, and write its "for each X, considering only X with at least N Y" query as MATCH → WITH → WHERE → RETURN without counting anything twice.

On a small test graph built on the session's AirBnB model, the host Ann has two listings with four reviews between them. The session's solution to its last exercise — for each host, the reviews of the listings with a First Aid Kit and the number of listings owned (p. 1413) — answers exactly that: 2 listings, 4 reviews.

Now drop the WITH in the middle and count both things in the RETURN. Neo4j answers, without an error or a warning: 8 listings, 8 reviews. Where do the extra four of each come from? Section 5 answers, and the same trap hides in many past-paper queries.

From ER diagram to graph model: four rules

Every past paper opens its Neo4j exercise with a graph model of a few ER entities: until 2025 a 1-point "graph model/example describing the nodes, main attributes, and edges"; on 30 Jan 2026, for 2 points, "a written textual representation (no graphical sketches) … using the typical Neo4J notation for nodes ( ), edges -[ ]->, and attributes { }. Include data types for attributes." The session's recipe, "quite simple" (p. 1350), applied to that hospital:

  1. An entity becomes a node. "Entities -> Neo4j nodes, maintaining their attributes and using the name of the entity as label." Building becomes (:Building {building_id: String, name: String, year: Integer}).
  2. A relationship becomes a relationship. "Relationships -> their equivalent in Neo4j, maintaining their attributes and using the name of the relationship as label." Heats becomes -[:HEATS {start_time: DateTime, end_time: DateTime}]->.
  3. An ISA child becomes one node with several labels — below.
  4. N:N needs nothing. "N.B. N:N relationship do not need any special treatment" (p. 1351).

Rule 4 saves work: lesson 8 needed a table for every N:N relationship; here many edges are many pairs. Background, not on the slide: an edge has two ends, so a relationship among three entities becomes a node.

ISA: one node, several labels

The ISA rule of p. 1351: "In ISA Hierarchies, parent entities -> nodes, maintaining their attributes, name as label, and relationships (in partial relationships), while children entities -> nodes, maintaining their attributes and relationships and inheriting the ones from the parent entity as well as their label." A child instance is one node wearing the parent's label and its own, with both sets of attributes: the slide's CREATE (:Person:Student {name: "Alice", major: "Physics"}). One person, one node: MATCH (p:Person) finds the students too, and WHERE p:Student picks the subtype without a join.

The coverage letters decide which label sets exist. Partial (P): a person who is neither paramedic nor firefighter is a plain (:Person), the quote's "(in partial relationships)". Total (T): no node wears :HeatingSystem alone. Overlapping (O): "A paramedic can be a firefighter" (6 Jun 2024), so :Person:Paramedic:Firefighter is one node — the slide's "child entities might have multiple labels! (e.g., O - ISA)". Exclusive (E): never two sibling labels on one node.

"Each child node gets only its own label." Then MATCH (p:Person) misses every paramedic. The slide's children inherit the parent's attributes "as well as their label".

ISA in labels: which nodes may exist?

Pick an ISA hierarchy from a past paper (only the entities its exercise asks for are drawn). Each chip is a kind of node you might write in your model: tap the ones that may exist — each tap is checked at once — and read your model, in the exam's notation, under the chips.

  1. Hierarchy of 6 Jun 2024, (P, O). Before tapping: may a node wear :Person:Paramedic:Firefighter? May a node wear :Person alone?
    What you should see

    Both turn green. Overlapping lets one person be paramedic and firefighter at once — one node, three labels; partial lets a person be neither — a plain :Person. Only :Paramedic without its parent label and the IS_A construction turn red.

  2. Switch to the heating systems of 30 Jan 2026, (T, E). Predict which two kinds of node of the first hierarchy have no counterpart now, then tap to check.
    What you should see

    The plain parent :HeatingSystem (total: every heating system is one of the children) and the double child :HeatingSystem:HVACSystem:GasHeatingSystem (exclusive). The model has exactly two node lines, each with both labels.

  3. Last hierarchy, 11 Feb 2025, two levels. How many labels does a pilot wear? Can a node wear :Person alone? :Person:TeamMember?
    What you should see

    A pilot wears three labels, :Person:TeamMember:Pilot: a child inherits the labels of every ancestor. :Person alone is red, because the top level is total; :Person:TeamMember is green, because the lower level is partial. The insight: a child is one node that wears its parents' labels too; T removes the plain parent, E forbids two sibling labels on one node.

The model the drills run on

Fig. 1 is the model of the session's eleven AirBnB exercises (p. 1393) — drills 1–11 here, to keep "exercise" for the past papers — and what the papers call "a graph with types". Worth copying: a review is a node with its own identity, author and subject; an amenity is one shared node with a HAS edge from every listing that offers it.

:Country :State :City :Review :Listing :Neighborhood :User :Host :Amenity WROTE REVIEWS HOSTS HAS IN_NEIGHBORHOOD LOCATED_IN IN_STATE IN_COUNTRY
Fig. 1. The data model of the session's exercises, redrawn from p. 1393: every relationship has one type and one direction, and the geography points towards the larger area, listing → neighbourhood → city → state → country. Review sits between User and Listing as a node of its own.

The same model in the 2026 notation, with the attributes the drills use (the slide draws none):

// the AirBnB model of p. 1393: nodes
(:User {name: String})
(:Review)
(:Listing {name: String, accommodates: Integer})
(:Host {name: String})
(:Amenity {name: String})
(:Neighborhood {name: String})
(:City)
(:State {code: String})
(:Country)
// edges
(:User)-[:WROTE]->(:Review)
(:Review)-[:REVIEWS]->(:Listing)
(:Host)-[:HOSTS]->(:Listing)
(:Listing)-[:HAS]->(:Amenity)
(:Listing)-[:IN_NEIGHBORHOOD]->(:Neighborhood)
(:Neighborhood)-[:LOCATED_IN]->(:City)
(:City)-[:IN_STATE]->(:State)
(:State)-[:IN_COUNTRY]->(:Country)

Labels in CamelCase, types in capitals, properties in snake_case with Neo4j's types (String, Integer, Float, Boolean, Date, DateTime, Duration). Fix each direction once: a reversed arrow silently matches nothing. The first two drills read the model one label or type at a time, LIMIT 10 (pp. 1395, 1397):

// ten Review nodes, to inspect their properties (p. 1395)
MATCH (re:Review) RETURN re LIMIT 10
// ten HOSTS relationships with their two end nodes (p. 1397)
MATCH (h:Host)-[r:HOSTS]->(l:Listing) RETURN h, r, l LIMIT 10

"A typo gives an error." Not always. The slides' own versions: p. 1395 binds re and returns r, "Variable `r` not defined"; p. 1397 types HOST for HOSTS, and gets 0 rows with only a warning, "The provided relationship type is not in the database". And type ASCII ->, <-, -, not the slides' typographic arrows.

WITH is GROUP BY: one row per key

WITH — "Using WITH, you can manipulate the output before it is passed on to the following query parts" (p. 1364): sort it (ORDER BY), cut it (LIMIT), "Introduce aggregates which can then be used in predicates in WHERE", alias expressions with AS and "Separate reading from updating of the graph".

The addendum to the session (pp. 1379–1380) says the grouping part in one sentence: "Cypher doesn't have an explicit GROUP BY keyword; instead, grouping is implied by the non-aggregated variables passed through WITH." Its example counts "the number of times each product has been purchased", beside the SQL it calls equivalent:

-- SQL (p. 1380)
SELECT p.name AS product,
  COUNT(*) AS total
FROM Customer c
JOIN BOUGHT b
  ON c.id = b.customer_id
JOIN Product p
  ON b.product_id = p.id
GROUP BY p.name
ORDER BY total DESC;
// Cypher (p. 1379)
MATCH (c:Customer)-[:BUYS]->
      (p:Product)
WITH p.name AS product,
     count(*) AS purchases
RETURN product, purchases
ORDER BY purchases DESC

MATCH finds all purchases, one row per BUYS edge; WITH turns the rows of each p.name into one row; ORDER BY sorts "by popularity" (Table 1 of p. 1380 starts with Wireless Mouse 45).

Grouping key — every expression of a WITH or a RETURN that is not inside an aggregate function. Rows with equal keys form a group, and each group leaves the clause as one row: its keys and its aggregates. With no key, the whole input is one group.

RETURN groups the same way: drills 4 and 5 (pp. 1401, 1403) count with no WITH, which you need only to go on after the aggregate — to filter on it (section 3) or to match again (section 5). The two drills differ in the key:

// one row per user node
MATCH (u:User)-[:WROTE]->
      (re:Review)
RETURN u, count(re)

Test graph: Uma (user 1) 2 · Vic 1 · Uma (user 4) 2

// one row per distinct name
MATCH (u:User)-[:WROTE]->
      (re:Review)
RETURN u.name, count(re)

Test graph: Uma 4 · Vic 1

Two users called Uma merge when the key is the name. Group by the node and return the name beside it: "each artistic director whose name is "Chiara"" means two rows for two Chiaras.

The aggregate functions an exam query needs. The slides use count and collect; the papers also ask for "the total heating power" (30 Jan 2026) and "their average age" (13 Jun 2025).
FunctionPer group · asked as
count(*)the number of rows — "the number of …" when one row is one thing
count(x)the rows where x is not null — zero counts after an OPTIONAL MATCH
count(DISTINCT x)the number of different values of x — "different", "at least N different"
collect(x)the list of the values of x — "return the collection of …"
sum(x), avg(x)the total and the mean of x — "total heating power", "average age"
min(x), max(x)the smallest and the largest x — "the first", "the most recent"

Every item of a WITH or a RETURN that is not aggregated is a grouping key: its presence matters, its position does not.

The exam template: MATCH, WITH, WHERE, RETURN

The first WITH exercise (p. 1365), on the Movies graph of lesson 11, has the shape of every past-paper query: count per entity, keep the entities over a threshold.

// persons with more than one movie, and their count
MATCH (person:Person)-[:ACTED_IN]->(movie)
WITH person, count(*) AS movieCount
WHERE movieCount > 1
RETURN person, movieCount

MATCH: one row per (person, movie). WITH: one row per person, the count aliased. WHERE: "Extract the actors who acted in at least 2 movies".

Two rules hide in it. First, a WHERE after MATCH filters rows, as in drill 3 (p. 1399), "Return the list of listings with accommodates less or equal than 3":

// listings for at most 3 guests (p. 1399)
MATCH (l:Listing)
WHERE l.accommodates <= 3
RETURN l, l.accommodates

The slide adds a habit: swap the last line for RETURN COUNT(l) "to be sure of the correctness of the solution" (2517 on the session's data). A WHERE after WITH, instead, filters groups: "To filter aggregates in Cypher, it is possible to use the WITH and WHERE clauses after aggregation. In SQL, this must be done using the HAVING clause instead" (p. 1380). A count in the first WHERE is refused, "Invalid use of aggregating function count(...) in this context": there, nothing is counted yet.

Second, WITH is a wall: "All data that is not passed through to the WITH statement won't be available in the following part of the query" (p. 1364); "If the person variable wouldn't have been included within the clause, I wouldn't have been able to RETURN it" (p. 1365). A forgotten variable gives "Variable `l` not defined".

MATCH WITH WHERE RETURN 9 rows 4 rows 2 rows row per path row per key keeps groups prints FROM, JOIN GROUP BY HAVING SELECT only what WITH lists goes on
Fig. 2. The exam template, clause by clause. Under each clause: what it does to the rows and its SQL counterpart. The row counts are those of the widget below for N = 2: nine paths, four users, two users kept.

Drill 9 (p. 1411), "Return the number of neighborhoods for each state", groups in the RETURN; drill 10 (p. 1412), "Return the name of the states with at least 10 neighborhoods", adds WITH and WHERE. The slides leave both relationships anonymous, [ ], and return s.code (State nodes carry a code); typing the relationships says which one you mean.

// the number of neighbourhoods for each state (p. 1411)
MATCH (s:State)<-[:IN_STATE]-(:City)
      <-[:LOCATED_IN]-(n:Neighborhood)
RETURN s.code, count(n)
// states with at least 10 neighbourhoods (p. 1412)
MATCH (s:State)<-[:IN_STATE]-(:City)
      <-[:LOCATED_IN]-(n:Neighborhood)
WITH s, count(n) AS neighborhood_count
WHERE neighborhood_count >= 10
RETURN s.code, neighborhood_count

The exam query, one clause at a time

Four users, nine reviews, four listings. The question: for each user, the number of different listings they reviewed, keeping only users with at least N. Press Next to run one clause at a time; the table under the query shows the rows that leave it. Move N, and switch DISTINCT off to see what it was doing.

  1. Before the first Next: how many rows will MATCH produce — one per user, one per listing, or one per path?
    What you should see

    Nine, one per path User → Review → Listing. Uma's two reviews of the Cozy loft are two rows: rows are paths, not things.

  2. Set N = 2 and step to the WHERE. Who survives? Now switch DISTINCT off. Who slips in, and why is that wrong for "at least 2 different listings"?
    What you should see

    Uma (3) and Zoe (2). Without DISTINCT, Vic slips in with 2: he reviewed the same studio twice, so count(l) counts his two rows. "Different" in a question means count(DISTINCT …).

  3. With DISTINCT on, go to the last step and move N from 1 to 4. When does the answer become empty? And why could this filter not be written in a WHERE right after MATCH?
    What you should see

    At N = 4: Uma, the best, has 3. After MATCH each row is one review and the count does not exist yet; only after WITH is there one row per user to test. That is the insight: a WHERE after WITH filters groups, not rows — SQL's HAVING.

"The collection of …": collect, ORDER BY, LIMIT

For "return the collection of", the aggregate is collect(): it packs the values of a group into one list. Drill 8 (p. 1411), "the collection of all the listings' names in the same neighborhood":

// one row per neighbourhood name, with its listings' names
MATCH (l:Listing)-[:IN_NEIGHBORHOOD]->(n:Neighborhood)
RETURN n.name, collect(l.name)

With no key, collect() makes a single list. p. 1366 sorts, cuts and collects — on the Movies graph, one row: ["Zach Grenier", "Wil Wheaton", "Werner Herzog"]. ORDER BY and LIMIT can follow a WITH, not only a RETURN.

// the three names that come last alphabetically, as one list
MATCH (n:Person)
WITH n AS person
ORDER BY person.name DESC
LIMIT 3
RETURN collect(person.name)

p. 1367 shows the last use, "Separate reading from updating"; "Inception" is not in the Movies graph, so read it as a template:

// tag every actor of "Inception" with a second label (p. 1367)
MATCH (p:Person)-[:ACTED_IN]->(m:Movie)
WHERE m.title = "Inception"
WITH p
SET p:InceptionActor
RETURN p.name

A WITH also takes a WHERE out of a pattern: shortest paths

Lesson 11 showed how Neo4j plans a shortestPath (pp. 1368–1373): a fast bidirectional breadth-first search when the condition can be checked while the path grows, and a slower exhaustive depth-first search as a fallback when the condition needs the whole path. A WITH changes that plan. "Predicates used in the WHERE clause that apply to the shortest path pattern are evaluated before choosing the shortest matching path" (p. 1371), but "The inclusion of the WITH clause means that the query plan will not include the fallback to the Slower Exhaustive Search Algorithm" (p. 1372). After WITH the WHERE is a plain filter on the one path already chosen, so the slide labels that query "Not guaranteed to return a result, even if it exists" (p. 1373). The slides' search pictures, their three queries and a widget that runs both searches are in lesson 11.

On the Movies graph (Neo4j 5.26) Tom Hanks acted in Cast Away, one hop. Asked for a longer path, the first query below plans the exhaustive search and finds three hops; the second finds nothing. Keep the bound *..3: with [*] the search ran for over a minute.

// guaranteed: the condition belongs to the pattern
MATCH (a:Person {name: "Tom Hanks"}),
      (b:Movie {title: "Cast Away"}),
      p = shortestPath((a)-[*..3]-(b))
WHERE length(p) > 1
RETURN p
// not guaranteed: finds the one-hop path, then filters it out
MATCH (a:Person {name: "Tom Hanks"}),
      (b:Movie {title: "Cast Away"}),
      p = shortestPath((a)-[*..3]-(b))
WITH p
WHERE length(p) > 1
RETURN p

Conditions on several hops, and zero counts

Exam conditions often sit on a neighbour of the counted node. The recommendation slide shows the tools (p. 1376): "Find actors that "Tom Hanks" hasn't yet worked with, but his co-actors have."

// actors Tom Hanks never worked with, ranked (p. 1376)
MATCH (tom:Person {name:"Tom Hanks"})-[:ACTED_IN]->(m)
        <-[:ACTED_IN]-(coActors),
      (coActors)-[:ACTED_IN]->(m2)<-[:ACTED_IN]-(cocoActors)
WHERE NOT (tom)-[:ACTED_IN]->()<-[:ACTED_IN]-(cocoActors)
  AND tom <> cocoActors
RETURN cocoActors.name AS Recommended, count(*) AS Strength
ORDER BY Strength DESC

The comma joins two patterns on coActors into one four-hop chain. NOT (pattern) is true when no such path exists, and tom <> cocoActors removes Tom himself. RETURN groups by name and count(*) counts routes — Tom Cruise and Zach Grenier lead with 5 on the Movies graph. No WITH: nothing filters the count.

Rows are paths, not people: "Find someone to introduce Tom Hanks to Tom Cruise" (p. 1377) returns five rows, three through Meg Ryan, who made three films with Tom Hanks.

// every route Tom Hanks to Tom Cruise via a co-actor (p. 1377)
MATCH (tom:Person {name:"Tom Hanks"})-[:ACTED_IN]->(m)
        <-[:ACTED_IN]-(coActors),
      (coActors)-[:ACTED_IN]->(m2)
        <-[:ACTED_IN]-(cruise:Person {name:"Tom Cruise"})
RETURN tom, m, coActors, m2, cruise

Two conditions on two neighbours need two variables, as in drill 7 (p. 1407), "listings whose amenities include "First Aid Kit" and "Wireless Internet"":

// listings that offer both amenities (p. 1407)
MATCH (l:Listing)-[r1:HAS]->(a1:Amenity),
      (l)-[r2:HAS]->(a2:Amenity)
WHERE a1.name = "First Aid Kit" AND a2.name = "Wireless Internet"
RETURN l, a1, a2

One amenity variable with a.name = "First Aid Kit" AND a.name = "Wireless Internet" asks one node for two names: 0 rows. "One of which should either be an Ambulance or a Fire Truck" is one node and an OR: WHERE s:Ambulance OR s:FireTruck.

Entities with nothing to count

Drill 6 (p. 1405), "Return the list of users and the number of reviews they wrote", must list users with no review too. MATCH makes no row for them, so the session adds them with a UNION:

// every user and their number of reviews, 0 included (p. 1405)
MATCH (u:User)-[r:WROTE]->(re:Review)
RETURN u, count(re) AS `COUNT`
UNION
MATCH (u:User)
WHERE NOT (u)-[:WROTE]->(:Review)
RETURN u, 0 AS `COUNT`

UNION needs the same column names on both sides, hence AS `COUNT` twice; otherwise "All sub queries in an UNION must have the same return column names".

Not on the slides, but Neo4j's own tool: OPTIONAL MATCH keeps u when the pattern finds nothing, with re null.

// every user and their number of reviews, in one query
MATCH (u:User)
OPTIONAL MATCH (u)-[:WROTE]->(re:Review)
RETURN u, count(re) AS reviews

"After OPTIONAL MATCH, count(*) gives the zeros." It gives 1: the user still has one row, whose re is null. count(re) skips nulls and gives 0.

Two stages: aggregate before you expand again

Drill 11 (p. 1413) asks for two counts over two patterns of the same host: "For each host, return the total number of reviews assigned to their listings with "First Aid Kit" amenity, the total number of listings they own, and their name." The printed solution, in ASCII:

// per host: listings owned, reviews of its kit listings (p. 1413)
MATCH (h:Host)-[:HOSTS]->(l:Listing)<-[:REVIEWS]-(r:Review),
      (l)-[:HAS]->(a:Amenity)
WHERE a.name = "First Aid Kit"
WITH h, count(r) AS reviews_count
MATCH (h)-[:HOSTS]->(li:Listing)
WITH h, reviews_count, count(li) AS all_listings_count
RETURN h.name, all_listings_count, reviews_count

Stage 1 makes one row per review of a kit listing and collapses them into one row per host; stage 2 matches the host's listings from that row, reviews_count riding along, and counts them. On the hook's graph: Ann, 2, 4.

Ann's eight listings

Ann's corner of the test graph: two listings, both with a First Aid Kit, and four reviews. Choose query A, the printed solution, or query B, the same without the WITH in the middle, and step through the clauses. The table shows the rows that leave each clause.

  1. Query B. Before stepping: how many rows reach the RETURN, and what will count(li) say?
    What you should see

    Eight rows: each of the four review rows meets both of Ann's listings, 4 × 2. Both counts count rows, so both say 8. That is the insight: a second MATCH multiplies every row it receives.

  2. Query A, step 3. How many rows does the second MATCH produce, and why is reviews_count the same on all of them?
    What you should see

    Two rows, one per listing: the WITH had already reduced the four review rows to one row per host, so the multiplier is 1 × 2. reviews_count belongs to that single row and is copied to each row that grows out of it.

  3. Switch DISTINCT on for B. Are the numbers right now? Is B now as good as A?
    What you should see

    The counts become 2 and 4, but the table still has 8 rows: the multiplication happens and DISTINCT hides it. That works for counts only; a sum cannot be rescued that way, because sum(DISTINCT x) adds equal values once even when they belong to different things.

Back to the hook. Without the WITH, each of the four review rows meets both of Ann's listings in the second MATCH: 4 × 2 = 8 rows, and count(li) and count(r) both count rows. With the WITH, the four rows first become one: 1 × 2. For R matching reviews and L listings, the version without the WITH gives

\[ \text{rows} = R \times L \quad \text{in both counts.} \]

Aggregate to one row per entity before the next MATCH expands it again; otherwise every count after it is multiplied.

DISTINCT repairs the counts, count(DISTINCT li) 2 and count(DISTINCT r) 4, but not a sum: sum(DISTINCT x) adds equal values once, so two different heaters of 0.3 MW would total 0.3. For sums, use the WITH, or WITH DISTINCT on the pair of nodes (section 6).

And if all three patterns go into one MATCH, separated by commas?

Neo4j answers 4 and 4, another wrong pair. Inside one MATCH a relationship is never used twice, so each review row can meet only the other listing as li. Two MATCH clauses have no such rule — hence the 8 of the hook.

Mind "for each host" too: stage 1 is a plain MATCH, so a host without a reviewed kit listing vanishes — Bob and Cyd on the test graph. When "for each" must include zeros, make the first pattern optional (Ann 2 4, Bob 1 0, Cyd 1 0), and state your reading.

// every host, with 0 when no kit listing has a review
MATCH (h:Host)
OPTIONAL MATCH (h)-[:HOSTS]->(l:Listing)<-[:REVIEWS]-(r:Review),
      (l)-[:HAS]->(:Amenity {name: "First Aid Kit"})
WITH h, count(r) AS reviews_count
MATCH (h)-[:HOSTS]->(li:Listing)
RETURN h.name, count(li) AS all_listings_count, reviews_count

Two past papers, end to end

No official solutions exist: the model answers below are this notebook's own, each run on a test graph built with its traps.

Exam of 30 Jan 2026, exercise 1: hospital heating (6 PT)

The part of the ER diagram the exercise uses (read off the diagram of the paper).
ElementAttributes · cardinality
BuildingBuilding_ID, Name, Year
FloorFloor_ID, Number, Has_Elevator
DepartmentDepartment_ID, Specialty, N_Beds, Head_Physician_ID
Heating SystemHeater_Sys_ID, Heating_Power; children HVAC System (Hourly_KWh_Cons, Cost_Per_KWh) and Gas Heating System (Hourly_SQM_Cons, Cost_Per_SQM), (T, E)
Has FloorBuilding 1:N, Floor 1:1
Placed IntoDepartment 1:N, Floor 1:N
HeatsStart_Time, End_Time; Heating System 1:N, Floor 1:N

Part 1: "Consider the entities Heating System (and its child entities), Floor, Building, and Department … Provide a written textual representation (no graphical sketches) of the graph model … Include data types for attributes. (2 PT)"

// nodes; (T, E): every heating system wears two labels
(:Building {building_id: String, name: String, year: Integer})
(:Floor {floor_id: String, number: Integer,
    has_elevator: Boolean})
(:Department {department_id: String, specialty: String,
    n_beds: Integer, head_physician_id: String})
(:HeatingSystem:HVACSystem {heater_sys_id: String,
    heating_power: Float, hourly_kwh_cons: Float,
    cost_per_kwh: Float})
(:HeatingSystem:GasHeatingSystem {heater_sys_id: String,
    heating_power: Float, hourly_sqm_cons: Float,
    cost_per_sqm: Float})
// edges
(:Building)-[:HAS_FLOOR]->(:Floor)
(:Department)-[:PLACED_INTO]->(:Floor)
(:HeatingSystem)-[:HEATS {start_time: DateTime,
    end_time: DateTime}]->(:Floor)

Part 2: "Write a Cypher query that returns, for every Department occupying more than 2 Floors in the same Building, the total heating power of all their Heaters. Filter the results to show only those Departments whose total power exceeds 0.5 MWh and sort them in descending order by power. (2 PT)" Three stages, two guards: WITH DISTINCT d (a department passing in two buildings) and WITH DISTINCT d, h (a heater on two of its floors). Assumptions to write down: "their Heaters" are all the heaters of the department's floors, in any building; power is in MW, so "0.5 MWh" is read as 0.5 MW.

// departments on more than 2 floors of one building: power > 0.5
MATCH (d:Department)-[:PLACED_INTO]->(f:Floor)
      <-[:HAS_FLOOR]-(b:Building)
WITH d, b, count(DISTINCT f) AS floors
WHERE floors > 2
WITH DISTINCT d
MATCH (d)-[:PLACED_INTO]->(:Floor)<-[:HEATS]-(h:HeatingSystem)
WITH DISTINCT d, h
WITH d, sum(h.heating_power) AS total_power
WHERE total_power > 0.5
RETURN d.department_id AS department, total_power
ORDER BY total_power DESC

Test graph: a 0.3 MW heater heats two floors of one department; 0.75 with the guard, 1.05 without. Part 3: "Write a Cypher query that returns all Buildings built after 1967 with a total number of beds greater than or equal to 20, located on floors with an elevator or located on the ground level. (2 PT)" The beds are those of the departments on qualifying floors; the ground level is assumed to be number 0.

// buildings after 1967 with at least 20 beds on qualifying floors
MATCH (b:Building)-[:HAS_FLOOR]->(f:Floor)
      <-[:PLACED_INTO]-(d:Department)
WHERE b.year > 1967 AND (f.has_elevator = true OR f.number = 0)
WITH DISTINCT b, d
WITH b, sum(d.n_beds) AS beds
WHERE beds >= 20
RETURN b.building_id AS building, b.name AS name, beds

Without WITH DISTINCT b, d, a department on two qualifying floors adds its beds twice: 42 instead of 21 on the test graph.

Exam of 6 Jun 2024, exercise 2: natural disasters (5 PT)

The part of the ER diagram the exercise uses. Note on the paper: "A paramedic can be a firefighter".
ElementAttributes · cardinality
PersonPersonal ID, Name, Surname, Postal Code; children Paramedic (Paramedic ID, Training Level), Firefighter (Firefighter ID), Policeman, (P, O)
VehiclePlate, Number of Seats; children Ambulance (Stretcher, Defibrillator), Fire Truck (Water Capacity, Ladder), (P, E)
DrivePerson 0:N, Vehicle 0:N
Drive (Paramedic – Ambulance), Drive (Firefighter – Fire Truck)Driving License Number; 0:N, 0:N

Part 1: "Consider the entities Person, Paramedic, Firefighter, Vehicle, Ambulance, and Fire Truck … Sketch a graph model/example … Either show an example graph or a graph with types. (1 PT)" Fig. 3 is an example graph; the ISA widget of section 1 builds the typed list.

Ann Mia John V2 A1 T1 :Person :Person :Paramedic :Firefighter :Person :Firefighter :Vehicle :Vehicle :Ambulance :Vehicle :FireTruck DRIVES DRIVES DRIVES + license number + license number one node, three labels
Fig. 3. The 2024 sketch as an example graph. Mia is one node with three labels, because the Person hierarchy is partial and overlapping; Ann is a plain :Person. No vehicle wears both :Ambulance and :FireTruck: that hierarchy is exclusive. The DRIVES edges from an ISA child to its kind of vehicle carry driving_license_number — marked on Mia to A1 and John to T1; Mia to T1 carries it too — and the generic ones do not.

Part 2: "Write a Cypher query to collect the number of people who drive at least 5 different vehicles, one of which should either be an Ambulance or a Fire Truck (2 PT)". Stage 1 counts different vehicles per person, stage 2 looks for a special one; count(DISTINCT p) matters, since a person with an ambulance and a fire truck makes two rows.

// people with at least 5 different vehicles, one of them special
MATCH (p:Person)-[:DRIVES]->(v:Vehicle)
WITH p, count(DISTINCT v) AS vehicles
WHERE vehicles >= 5
MATCH (p)-[:DRIVES]->(s:Vehicle)
WHERE s:Ambulance OR s:FireTruck
RETURN count(DISTINCT p) AS people

On the test graph: 7, and 20 with both DISTINCTs removed. Part 3: "Write a Cypher query to return the collection of Fire Trucks with a water capacity greater than 100 litres that were driven by at least 5 Firefighters named John, who also drove 10 non-Fire Truck vehicles each. (2 PT)" Three stages, from the end of the sentence: Johns with at least 10 other vehicles ("10" read as at least 10), such Johns per fire truck, the collection.

// fire trucks over 100 l, 5+ Johns with 10+ other vehicles
MATCH (f:Firefighter {name: "John"})-[:DRIVES]->(v:Vehicle)
WHERE NOT v:FireTruck
WITH f, count(DISTINCT v) AS others
WHERE others >= 10
MATCH (f)-[:DRIVES]->(t:FireTruck)
WHERE t.water_capacity > 100
WITH t, count(DISTINCT f) AS johns
WHERE johns >= 5
RETURN collect(t) AS fire_trucks

A checklist for answering without a database

Write each answer as if nothing could run it: the session's count check (p. 1399) then happens in your head.

  1. Model first: labels from entity names, ISA labels by T/P and E/O, types from relationship names, one direction per type.
  2. Rewrite the question as "for each key, aggregate of what, considering only …": is the key a node or a name?
  3. Conditions on one row go after MATCH; conditions on counts and sums after the WITH that computes them.
  4. "Different": count(DISTINCT …) — can two paths reach the same thing?
  5. Two counts: two stages, carrying every variable you still need through each WITH.
  6. A sum over things two paths can reach: WITH DISTINCT on the pair, never sum(DISTINCT …).
  7. "For each X" with zeros: OPTIONAL MATCH and count(y), or UNION with equal column names.
  8. Names as in your model, ASCII arrows, dates as date("2024-08-18"), assumptions written down.
  9. Run it on five nodes in your head: what should come out, and what does your query return?

Exam-style questions

Four exercises from past papers, in their printed wording and with their printed points, each after the ER it assumes; then two theory questions. Write your answer before opening the model answer.

Exam of 11 Feb 2025, exercise 1 (Formula 1). ER: Person (Personal_ID, Name, Surname, Birth_Date, Gender) is the parent of Spectator (Favourite_Team), Team Owner and Team Member, ISA (T, E); Team Member is the parent of Engineer, Mechanic and Pilot (Championships_Won, Career), ISA (P, E). Race (Race_ID, Laps_Number, Max_Tickets, Date); Track (Location, Length [Km], Time_Record); Ticket (Ticket_ID, Price, Purchase_Date). Compete: Race (10:N) – Pilot (1:N), with Winner (Yes/No) and Fastest_Lap; On: Race (1:1) – Track (1:N); Attend: Ticket (1:1) – Race (1:N); Buy: Ticket (1:1) – Spectator (1:N).

"Consider the Pilot, Race, Track, Ticket, and Spectator entities from the ER model. Sketch a graph model/example describing the nodes, main attributes, and edges. Use the proper notation for labels, types, etc. (1 PT)"

Show model answer
// nodes
(:Person:TeamMember:Pilot {personal_id: String, name: String,
    surname: String, birth_date: Date, gender: String,
    championships_won: Integer, career: String})
(:Person:Spectator {personal_id: String, name: String,
    surname: String, birth_date: Date, gender: String,
    favourite_team: String})
(:Race {race_id: String, laps_number: Integer,
    max_tickets: Integer, date: Date})
(:Track {location: String, length_km: Float,
    time_record: Duration})
(:Ticket {ticket_id: String, price: Float, purchase_date: Date})
// edges
(:Pilot)-[:COMPETES {winner: Boolean,
    fastest_lap: Duration}]->(:Race)
(:Race)-[:ON]->(:Track)
(:Ticket)-[:ATTENDS]->(:Race)
(:Spectator)-[:BUYS]->(:Ticket)

A pilot is one node with the labels of both ancestors; there is no plain :Person node, because the top hierarchy is total. Compete's attributes are properties of the COMPETES relationship.

What to get right: the two-level ISA (three labels on a pilot, the inherited attributes on the node), the relationship attributes on the edge, labels and types written as such, one direction per type. Ticket stays a node: it has its own key, its attributes and two relationships.

Exam of 11 Feb 2025, exercise 1, continued. Same ER, and the model of the previous answer.

"Write a Cypher query to return the total number of races for each track in which at least 1'000 tickets were bought by female spectators, at least 500 of which have Ferrari as their favourite team. (2 PT)"

Show model answer
// per track: races with 1000+ female, 500+ Ferrari-fan tickets
MATCH (t:Track)<-[:ON]-(r:Race)<-[:ATTENDS]-(k:Ticket)
      <-[:BUYS]-(s:Spectator)
WHERE s.gender = "Female"
WITH t, r, count(DISTINCT k) AS female_tickets
WHERE female_tickets >= 1000
MATCH (r)<-[:ATTENDS]-(k2:Ticket)<-[:BUYS]-(s2:Spectator)
WHERE s2.gender = "Female" AND s2.favourite_team = "Ferrari"
WITH t, r, count(DISTINCT k2) AS ferrari_tickets
WHERE ferrari_tickets >= 500
RETURN t.location AS track, count(r) AS races

Assumption: gender is stored as "Female".

Two thresholds on two sets of tickets of the same race: count, filter, count again, filter again — only then count races per track. Each WITH keeps t and r, or the next clause could not use them. The step that decides the answer is the grouping per race, (t, r), before the count per track.

Exam of 13 Jun 2025, exercise 2 (concerts). ER: Person (Personal ID, Name, Surname, Birth Date) is the parent of Audience (Favourite Music Type), Artistic Director (Biography), Maestro and Musician, ISA (T, E). Concert (ID, Title, Start Time, Duration (minutes)); Location (Name, Address, Type, N Seats). Attend: Audience (1,N) – Concert (0,N); Organize: Artistic Director (1,N) – Concert (1,1); Located: Concert (1,1) – Location (1,N).

"Write a Cypher query to count the total number of concerts managed by each artistic director whose name is "Chiara", that lasted more than 100 minutes, and with more than 1000 attendances(2 PT)"

Show model answer
// each Chiara: concerts over 100 min with 1000+ attendees
MATCH (a:ArtisticDirector {name: "Chiara"})
      -[:ORGANIZES]->(c:Concert)<-[:ATTENDS]-(p:Audience)
WHERE c.duration > 100
WITH a, c, count(DISTINCT p) AS attendances
WHERE attendances > 1000
RETURN a.personal_id AS director, count(c) AS concerts

Model assumed: (:Person:ArtisticDirector)-[:ORGANIZES]->(:Concert)<-[:ATTENDS]-(:Person:Audience); "managed" is the ER's Organize.

The trap: Concert has no attendance attribute, so "attendances" is an aggregate per concert — a first stage with its own WHERE — before the count per director. Counting concerts straight over the attendance rows gives thousands (2502 instead of 2 on a test graph). Group by the director node, not the name: two Chiaras are two rows.

Exam of 6 Sep 2024, exercise 2 (restaurant). ER: Person (Personal_ID, Name, Surname, Birth Date, Phone Number) is the parent of Owner, Customer, Waiter (Hiring Date) and Chef. Table (Table_ID, Table Number, Max Seats); Dish (Dish_ID, Name, Price, Recipe, Description). Serve: Table (1:N) – Waiter (1:N); Order: Table (1:N) – Dish (1:N); Book: Table (1:N) – Customer (1:N), with Number of People and Booking Date. "N.B. … Tables are served by more Waiters overall, but only one at a time."

"Write a Cypher query to collect the total number of different dishes that were served at each table, considering only tables whose max seats is greater than 10 and served by at least one waiter whose name is "Federico" (1.5 PT)"

Show model answer
// tables over 10 seats served by a Federico: different dishes
MATCH (w:Waiter {name: "Federico"})-[:SERVES]->(t:Table)
WHERE t.max_seats > 10
WITH DISTINCT t
MATCH (t)-[:ORDERS]->(d:Dish)
RETURN t.table_id AS table, count(DISTINCT d) AS different_dishes

Model assumed: (:Person:Waiter)-[:SERVES]->(:Table)-[:ORDERS]->(:Dish); the dishes "served at" a table are the ER's Order.

Two conditions on the table, one of them on a neighbour, then a count on another neighbour. WITH DISTINCT t keeps two Federicos serving the same table from doubling its rows, and count(DISTINCT d) answers "different dishes". On a test graph without both DISTINCTs, one table showed 4 dishes instead of 2.

Explain how Cypher groups rows when a query uses an aggregate function, and why a condition such as "at least 5 different vehicles" cannot be written in the WHERE that directly follows MATCH. Compare with SQL.

Show model answer

Cypher has no GROUP BY keyword. In a WITH or RETURN that contains aggregate functions, every expression that is not aggregated is a grouping key: the rows coming from MATCH are partitioned by the values of those keys, and each group becomes one row with its aggregates. So WITH p, count(DISTINCT v) AS n plays the role of SQL's GROUP BY p. The WHERE attached to MATCH filters single rows, before any group exists, so it cannot use count(); Neo4j rejects it as an invalid use of an aggregating function. The condition goes into a WHERE after the WITH that computes the aggregate, WITH p, count(DISTINCT v) AS n WHERE n >= 5, which corresponds to SQL's HAVING. Only the variables listed in that WITH are available afterwards.

A complete answer says three things: the implicit keys, WHERE after WITH as HAVING, and the reason (rows before groups). The addendum (p. 1380) makes the SQL comparison in these terms, so its vocabulary is safe to use. Recent Neo4j versions also accept an optional explicit GROUP BY (see the Cypher Manual in the resources); the course, and this answer, use the implicit rule.

Which statements about WITH in Cypher are true? Select all that apply.

  • Every expression of a WITH that is not inside an aggregate function becomes a grouping key. The addendum: "all non-aggregated variables in WITH become grouping keys" (p. 1380).
  • The grouping keys must be written before the aggregates inside the WITH. Position does not matter: WITH count(*) AS c, person groups by person too. The slide of p. 1364 calls the position "essential"; that is a habit, not a rule.
  • A variable that is not listed in a WITH cannot be used in the clauses after it. p. 1364: "All data that is not passed through to the WITH statement won't be available in the following part of the query."
  • A WHERE written right after a WITH can filter on an aggregate computed by that WITH. That is Cypher's HAVING (p. 1380).
  • An aggregation always needs a WITH. RETURN groups too: RETURN u, count(re) on p. 1401 has no WITH.
Show model answer

True: the first, the third and the fourth. The position of the keys is irrelevant, and RETURN can aggregate without any WITH.

Wrong answers may carry a penalty at the real exam (it is announced just before it), so tick only what you can justify. The second option is the printed wording of p. 1364 turned into a statement: know that the slide says it and that Neo4j does not require it.

Check yourself

Two different users are called Uma and wrote 2 reviews each; Vic wrote 1. What does MATCH (u:User)-[:WROTE]->(re:Review) RETURN u.name, count(re) return?

  • Three rows: Uma 2, Uma 2, Vic 1 That is the result of grouping by the node u, as on p. 1401.
  • Two rows: Uma 4, Vic 1 The key is the value of u.name, so the two Umas fall into one group.
  • One row: 5 That needs a RETURN with no key at all, RETURN count(re).
  • An error, because there is no WITH RETURN groups by itself; WITH is needed only to go on after the aggregate.

After MATCH (h:Host)-[:HOSTS]->(l:Listing) WITH h, count(l) AS n, which names can the next clause use?

  • h and n Only what WITH lists passes the wall.
  • h, l and n l was not listed; asking for it gives "Variable `l` not defined".
  • Only n h is listed too, as the grouping key.
  • Everything bound in the MATCH, plus n That is exactly what WITH prevents.

"For each building built after 1967, the number of different departments, considering only buildings with at least 3 of them." Which conditions can be written in the WHERE right after MATCH, before any WITH? Select all that apply.

  • b.year > 1967 A condition on a single row: it belongs before the WITH, and it shrinks the rows to group.
  • d.n_beds >= 10, if the question also asked for departments with at least 10 beds Also a property of one row.
  • count(DISTINCT d) >= 3 An aggregate: Neo4j refuses it there. It goes after WITH b, count(DISTINCT d) AS n, as WHERE n >= 3.
  • n >= 3, where n is the alias of a later WITH The alias does not exist before the WITH that defines it.

Rule of thumb: if the condition talks about one thing, put it after MATCH; if it talks about how many, put it after WITH.

Host Ben has 3 listings, all with a First Aid Kit, and 5 reviews spread over them. In the query of p. 1413 you remove the WITH in the middle and count both things in the RETURN. What does the RETURN print?

  • 3 listings and 5 reviews That is the printed two-stage query.
  • 15 and 15 Five review rows, each meeting all three listings: 5 × 3 rows, and both counts count rows.
  • 8 and 8 That is Ann's case, 4 × 2.
  • An error about a missing variable Nothing is missing: both MATCH clauses bind h, and the RETURN uses only bound names.

MATCH (u:User) OPTIONAL MATCH (u)-[:WROTE]->(r:Review) RETURN u.name, count(*), count(r). What are the two numbers for a user who wrote nothing?

  • 0 and 0 count(*) counts rows, and the user still has one row.
  • 1 and 0 One row, in which r is null: count(*) says 1, count(r) skips the null.
  • 1 and 1 count(r) ignores nulls.
  • The user does not appear That is what a plain MATCH would do; OPTIONAL MATCH keeps the user.

A department's heaters must be summed, and one heater heats two floors of the department. Which is right?

  • WITH d, sum(DISTINCT h.heating_power) AS total It merges equal values: two different heaters of 0.3 MW would count once.
  • WITH DISTINCT d, h, then WITH d, sum(h.heating_power) AS total First one row per (department, heater), then the sum: each heater once, whatever its power.
  • WITH d, sum(h.heating_power) / 2 AS total Only that heater is counted twice; halving the whole sum is wrong for the others.
  • WITH d, count(DISTINCT h) AS total That counts heaters; the question asks for power.

Heating System has the children HVAC System and Gas Heating System, ISA (T, E). Which label sets can a node of the graph model carry? Select all that apply.

  • :HeatingSystem Total: every heating system is one of the two children, so no node wears the parent label alone.
  • :HeatingSystem:HVACSystem A child node wears the parent's label and its own.
  • :HeatingSystem:GasHeatingSystem The other child, again with both labels.
  • :HeatingSystem:HVACSystem:GasHeatingSystem Exclusive: never two sibling labels on one node.
  • :GasHeatingSystem Without the parent label, MATCH (h:HeatingSystem) would miss it.

Key take-aways

What to watch and read