Part 4, the finale, of a four-part series on learning Neo4j and Cypher with the 2026 World Cup. New here? Start with Thinking in Graphs: Modelling the 2026 World Cup as a Property Graph then move forward to Writing and Reading the 2026 World Cup Data with Cypher next read [Loading the 2026 World Cup Data with Cypher] (https://graphacademy.neo4j.com/blog/loading-the-2026-world-cup-data-with-cypher)for the constraints and idempotent loading this post builds on.
Three posts in, we've modelled a schema, learned to read and write Cypher, and made loading safe enough to trust. Along the way the queries stayed close to home: find a team, filter by a property, count a total. None of that leaned on relationships. This post is about what a graph does best, which is following relationships from one thing to the next, and doing it well.
In this post I'll answer the questions fans care about most: who topped each group, who won the Golden Boot, and how the champions reached the final. Every one of them is a traversal, and every one is short. And because the whole tournament is open data, you can load it into your own free database and run all of this yourself. Let's do that first.
Load the whole tournament in one query
You don't need to hand-type 104 matches. The complete 2026 results are published as open data by openfootball under Creative Commons Zero, which is a full public-domain dedication: use it freely, no attribution required. It lives on GitHub, AuraDB reads from public URLs, and Aura ships with APOC. apoc.load.json pulls it straight in.
You'll run this in a Neo4j database. If you don't already have one from the earlier posts, create a free AuraDB instance in your browser (no card, nothing to install) and open its Query editor.
Create your free AuraDB instanceThen run the companion file post04_relationships_traversals.cypher. It loads the tournament and holds every query in this post, and it builds on the Post 2 backbone, whose teams carry the rankings and confederations we come back to below. The load is two short passes, one for matches and one for goals:
CALL apoc.load.json('https://raw.githubusercontent.com/openfootball/worldcup.json/master/2026/worldcup.json')
YIELD value
UNWIND value.matches AS match
WITH match, match.date + '|' + match.team1 + '|' + match.team2 AS matchId
MERGE (mt:Match {matchId: matchId})
SET mt.round = match.round, mt.group = match.group, mt.stage =
CASE WHEN match.group IS NOT NULL THEN 'group' ELSE 'knockout' END,
mt.homeTeam = match.team1, mt.awayTeam = match.team2,
mt.homeScore = match.score.ft[0], mt.awayScore = match.score.ft[1]
MERGE (homeSide:Team {name: match.team1})
MERGE (awaySide:Team {name: match.team2})
MERGE (homeSide)-[:PLAYED_IN {side: 'home'}]->(mt)
MERGE (awaySide)-[:PLAYED_IN {side: 'away'}]->(mt);The second pass adds the goals. Each goal becomes its own Goal node, linked to the match it was scored in, the Player who scored it, and the Team it counted for. Post 1 explained why a goal earns its own node: a relationship can only connect two nodes, and a goal connects three. Load both passes and you have 104 matches, 308 goals, and 190 scorers to explore.
A traversal reads like a sentence
The Golden Boot race is a two-hop traversal, and the Cypher reads almost like an English sentence:
MATCH (pl:Player)-[:SCORED]->(:Goal)
RETURN pl.name AS player, count(*) AS goals
ORDER BY goals DESC
LIMIT 5;"Match a player who scored a goal, count the goals per player, take the top five." (count(*) counts matched rows, the same idea as the count(tm) from Post 2 but not tied to a variable.) Only the shape of the relationship, written out.
| player | goals |
|---|---|
| Kylian Mbappé | 10 |
| Lionel Messi | 8 |
| Erling Haaland | 7 |
| Jude Bellingham | 7 |
| Ousmane Dembélé | 6 |
Mbappé takes the Golden Boot with ten. The traversal did the work; the aggregation tallied what it found. That two-hop is the reified goal from Post 1, now real: a Goal node sitting between the scorer, the match, and the team it counted for.
WITH is a pipeline
The keyword that turns a query into a pipeline is WITH. It carries results from one part of a query into the next: you work a value out early, then use it further down. Think of it as a RETURN in the middle of a query that hands a chosen set of values forward instead of ending it.
The clearest place to see it is a group table, which no single clause can produce. You have to work out each team's goals for and against in every match, then total those into points and goal difference, then sort. That's three stages, and WITH chains them:
MATCH (tm:Team)-[r:PLAYED_IN]->(mt:Match {group: 'Group L'})
WITH tm,
CASE WHEN r.side = 'home' THEN mt.homeScore ELSE mt.awayScore END AS gf,
CASE WHEN r.side = 'home' THEN mt.awayScore ELSE mt.homeScore END AS ga
WITH tm,
count(*) AS played,
sum(CASE WHEN gf > ga THEN 1 ELSE 0 END) AS won,
sum(CASE WHEN gf = ga THEN 1 ELSE 0 END) AS drawn,
sum(CASE WHEN gf < ga THEN 1 ELSE 0 END) AS lost,
sum(gf) AS goalsFor,
sum(ga) AS goalsAgainst
RETURN tm.name AS team, played, won, drawn, lost,
goalsFor, goalsAgainst,
goalsFor - goalsAgainst AS goalDiff,
won * 3 + drawn AS points
ORDER BY points DESC, goalDiff DESC, goalsFor DESC;The first WITH looks at every match a team played and works out two numbers: goals scored and goals conceded. CASE reads these from the correct side of the scoreline, since a team's own goals are the home score in its home games and the away score away from home. The second WITH totals those numbers across the team's three matches, leaving one row per team. RETURN then derives goal difference and points, and ORDER BY applies the tie-breakers in the order the tournament uses: points, then goal difference, then goals scored.
| team | played | won | drawn | lost | goalsFor | goalsAgainst | goalDiff | points |
|---|---|---|---|---|---|---|---|---|
| England | 3 | 2 | 1 | 0 | 6 | 2 | +4 | 7 |
| Croatia | 3 | 2 | 0 | 1 | 5 | 5 | 0 | 6 |
| Ghana | 3 | 1 | 1 | 1 | 2 | 2 | 0 | 4 |
| Panama | 3 | 0 | 0 | 3 | 0 | 4 | -4 | 0 |
England top the group. Change one string, 'Group L' to 'Group H', and you have Spain's group instead. Twelve group tables from one query.
The confederation, finally a node
In Post 2, each team stored its confederation as a string, which was enough to tally how many teams each one brought. A string can't do more than that, though: nothing in the graph can point to it, and it can't hold details of its own, like a region or a full name. Before promoting it, one piece of housekeeping: these are two new node types, and each should get the same identity guard every other node has had since Post 3, a uniqueness constraint on its name.
CREATE CONSTRAINT confederation_name IF NOT EXISTS
FOR (cn:Confederation) REQUIRE cn.name IS UNIQUE;
CREATE CONSTRAINT group_name IF NOT EXISTS
FOR (gp:Group) REQUIRE gp.name IS UNIQUE;Then promote the string into a Confederation node:
MATCH (tm:Team) WHERE tm.confederation IS NOT NULL
MERGE (cn:Confederation {name: tm.confederation})
MERGE (tm)-[:REPRESENTS]->(cn);Every team now connects to its confederation through a REPRESENTS edge, and a matching statement links each team to its group with an IN_GROUP edge. Now that the confederation is a node, you can traverse to it and aggregate the teams behind it:
MATCH (tm:Team)-[:REPRESENTS]->(cn:Confederation)
RETURN cn.name AS confederation,
count(tm) AS teams,
round(avg(tm.fifaRanking), 1) AS avgRanking,
min(tm.fifaRanking) AS bestRanking
ORDER BY avgRanking;| confederation | teams | avgRanking | bestRanking |
|---|---|---|---|
| UEFA | 16 | 19.4 | 1 |
| CONMEBOL | 6 | 21.3 | 2 |
| CAF | 10 | 41.8 | 11 |
| AFC | 9 | 44.3 | 17 |
| CONCACAF | 6 | 51.0 | 9 |
| OFC | 1 | 95.0 | 95 |
Sixteen European teams, the strongest average on paper and the top-ranked side in the field. At the other end, New Zealand carries Oceania on its own as the confederation's single qualifier. That is the answer Post 2 could not give, because a string has no relationships to follow and a node does.
OPTIONAL MATCH keeps the rows that don't match
A plain MATCH returns results only where the whole pattern is present, which is usually what you want. But it can hide the one thing you're looking for. Ask for every team in Group L alongside its total goals: Panama, who never scored, has no SCORED path to follow, and MATCH leaves it out entirely. The team on zero, the one worth noticing, vanishes from the results. OPTIONAL MATCH keeps it. It tries the extra pattern and returns the team whether or not that pattern is there:
MATCH (tm:Team)-[:PLAYED_IN]->(mt:Match {group: 'Group L'})
OPTIONAL MATCH (gl:Goal)-[:FOR]->(tm), (gl)-[:SCORED_IN]->(mt)
RETURN tm.name AS team, count(gl) AS goals
ORDER BY goals DESC;Panama, with no goals in the group, has nothing to count, and shows up honestly on zero instead of vanishing. Keeping every team in view takes one keyword.
Following a path to the trophy
Knockout football is a chain, and chains are what graphs are best at. Here's the road the champions walked, pulled out by traversing every knockout match Spain played:
MATCH (tm:Team {name: 'Spain'})-[:PLAYED_IN]->(mt:Match)
WHERE mt.stage = 'knockout'
RETURN mt.round AS round,
mt.homeTeam AS home,
mt.awayTeam AS away,
toString(mt.homeScore) + '-' + toString(mt.awayScore) AS score,
mt.decidedBy AS decidedBy
ORDER BY mt.date;Five rounds, ending with a final settled in extra time. And naming the champion at all is itself a small traversal: the team on the winning side of the match whose round is the Final.
MATCH (tm:Team)-[r:PLAYED_IN]->(mt:Match {round: 'Final'})
WITH tm, r, mt,
CASE WHEN r.side = 'home' THEN coalesce(mt.homeScoreET, mt.homeScore)
ELSE coalesce(mt.awayScoreET, mt.awayScore) END AS forGoals,
CASE WHEN r.side = 'home' THEN coalesce(mt.awayScoreET, mt.awayScore)
ELSE coalesce(mt.homeScoreET, mt.homeScore) END AS againstGoals
WHERE forGoals > againstGoals
RETURN tm.name AS champion;Because the final went to extra time, its result is the extra-time score, not the goalless ninety minutes. coalesce reads the right one: the extra-time score when a match has it, and the full-time score otherwise. The query returns a single team, Spain, who beat Argentina one-nil after extra time.
A harder question, one pattern
To see why the relationships matter, ask a question that crosses several of them at once: which players scored against the eventual champions? That's scorer, to goal, to match, back to Spain, while excluding Spain's own goals.
MATCH (pl:Player)-[:SCORED]->(gl:Goal)-[:SCORED_IN]->(mt:Match)<-[:PLAYED_IN]-(tm:Team {name: 'Spain'})
WHERE NOT (gl)-[:FOR]->(tm)
RETURN pl.name AS player, mt.round AS round
ORDER BY mt.date;Here is that pattern as a path through the graph, one hop at a time:
- 1
A scorer scored a goal
(:Player)-[:SCORED]->(:Goal) - 2
...in a match
(:Goal)-[:SCORED_IN]->(:Match) - 3
...that Spain played in
(:Team {name:'Spain'})-[:PLAYED_IN]->(:Match), with the goal not FOR Spain
Read the pattern and it's a single sentence traced through the graph: a scorer scored a goal, in a match, that Spain played in, and the goal was not for Spain. Written once, it reads the way you'd ask the question aloud. That is what people mean when they say relationships are first-class: you navigate them directly, and the query looks like the thought.
See the shape of what you built
One last command, useful whenever you meet an unfamiliar graph. It draws the schema, every label and how the relationships connect them:
CALL db.schema.visualization();Run it on your loaded tournament and you'll see the shape below: Team connected to Match by PLAYED_IN, Goal linking Player, Match, and Team, and the groups and confederations you promoted to nodes a moment ago.
The picture you sketched in post 1 is now a live database you can question.
Going deeper
To see the whole tournament at once, ask for all of it:
MATCH (n) RETURN n;That pulls up the entire graph, teams, matches, goals, players, groups, and confederations, wired together. On a graph this size the visualiser shows a generous sample; add LIMIT 100 if you want a cleaner picture. From there the questions get as sharp as you like. The companion repo has a file of deeper queries: the biggest ranking upsets, the confederations ranked by their head-to-head record, the champion's path traced round by round, and more, each one a traversal across the relationships you loaded.
A graph can only answer what its data holds, and openfootball gives it plenty: every match, score, and scorer of the tournament, released to the public domain for anyone to build on. That is what makes all of these queries possible. It doesn't record assists, which is a reasonable boundary for a free, open dataset, and it means no query here can name an assister. Pair the same Cypher with a fuller feed and the graph will answer more; run it on openfootball, as we have, and it already answers a great deal. A graph is always shaped by its source, and this one is a generous place to start.
Where to go from here
That's the series. You started with a schema drawn on a canvas and finished with the complete 2026 World Cup in a free cloud database, queried with traversals that read like plain English. You can write and read data, protect it with constraints, load it safely from open sources, and follow relationships to answers in a line or two of Cypher.
The dataset is yours to keep exploring. Every result here came from open, public-domain data and a free AuraDB instance. Nothing stops you rebuilding it, extending it with squads and lineups, or pointing the same techniques at a competition you care more about.
If you'd like to go deeper, Intermediate Cypher Queries on GraphAcademy picks up where this leaves off, with more advanced querying patterns, free and on your own database.
You built a graph of a whole World Cup from an empty database. From here, the graphs only get bigger.
Comments (0)
Loading comments...