Thinking in Graphs: Modelling the 2026 World Cup as a Property Graph

Learn how to model the 2026 World Cup as a Neo4j property graph, from first principles to a production ready schema.

The FIFA 2026 World Cup has 48 teams, 12 groups, 104 matches, and somewhere around 1,200 players spread across three host countries. When I decided to build a knowledge graph of the entire tournament, the first question I had to answer wasn't technical. It was conceptual: what kind of database is actually right for this data?

The answer, I found, depends entirely on what you want to do with the data, and what makes tournament data interesting in the first place.

What makes it interesting is not the entities. It's how everything connects.

A player connects to a national team, a club, a manager, and every match they appear in. A goal connects to the player who scored it, the player who assisted, the match it happened in, and the team it counted for. A venue connects to a host country, every match played there, and through those matches, to every team and player who stepped onto the pitch. The moment I started writing those sentences, I knew I was describing a graph, and that Neo4j was the right tool for the job.

This post is the first in a four-part series on learning Neo4j and Cypher. I use the World Cup as the dataset throughout, not as a gimmick, but because it's rich enough to illustrate every concept that matters, and familiar enough that the domain never gets in the way of the learning. By the end of this post you'll understand the property graph model thoroughly enough to design a schema of your own. By the end of the series, you'll be writing production-grade Cypher with confidence.

Let's start at the beginning.


What is the property graph model?

Neo4j uses the Labeled Property Graph model. It has exactly four building blocks. I want to spend real time on each one, because a clear understanding of what each building block is, and what it is not, prevents the majority of modelling errors I see beginners make.

Nodes

Nodes are the entities: the things that exist in your domain. A team, a player, a match, a venue. In a diagram, nodes are drawn as circles.

The most common early mistake is creating too many nodes, treating properties as nodes when they don't need to be. I apply a simple test: if a thing is never queried directly, never connected to anything else, and carries no relationships of its own, it's a property, not a node. The city a venue is located in is a property of the venue. The confederation a team represents, on the other hand, is a node, because we traverse to it, aggregate across it, and connect 48 teams to it. More on that shortly.

Labels

Labels are the type of a node, written with a colon prefix: :Team, :Player, :Venue, :Match. Labels use PascalCase by convention, HostCountry, Confederation, Match, because they appear constantly in code and consistent capitalisation prevents silent lookup failures.

A node can carry multiple labels simultaneously. This is one of the most powerful and underused features of the property graph model, and it becomes essential when modelling people. A manager is a person. A player is a person. A person who was a player and later became a manager is still one person, one node, with multiple labels: :Person:Player:Manager. I'll return to this when we get to the dual label pattern below.

Relationships

Relationships are the connections. Every relationship has a start node, an end node, a type written in SCREAMING_SNAKE_CASE, PLAYS_FOR, IN_GROUP, LOCATED_IN, and a direction. That direction is stored physically on disk.

It's worth being precise about what "stored physically" means, because it's what makes traversal in Neo4j fast. In Neo4j's native storage engine, each node record holds a pointer to the head of its relationship chain. Each relationship record holds pointers to both connected nodes and to the next relationship in each node's chain. Traversing one hop, following a relationship from one node to another, means dereferencing those pointers. It's O(1) per hop, regardless of the total number of nodes in the database. A query that traverses ten relationships performs ten pointer lookups. The cost grows with the number of hops you traverse, not with the size of the graph.

This is the structural property that makes graph databases the right choice for connected data. The more connected your data, the more this matters.

Properties

Properties are key-value pairs attached to either a node or a relationship. A :Team node has name: 'England', fifaRanking: 4, isHost: false. A :PLAYS_FOR relationship has jerseyNumber: 9, isCaptain: true.

That second example is the one I want to dwell on, because it reveals something important about how the property graph model works.

Harry Kane wears number 9 for England. He wears a different number at Bayern Munich. That jersey number doesn't belong to Kane, it belongs to the connection between Kane and England. The moment you put it on the player node, you lose the ability to represent two different numbers at two different teams without duplication or workarounds. Put it on the relationship, and the problem dissolves. Each PLAYS_FOR relationship carries its own jerseyNumber, independently.

This is what properties on relationships unlock: the ability to describe the connection itself, not just the things being connected. It's a capability that most data models don't have, and it's one I found myself reaching for constantly when modelling this tournament.

Here is the simplest version of this, one player, one team, one relationship:

This renders inline as two labelled circles connected by a directed arrow, with properties listed inside each circle and alongside the arrow. If you want to prototype schemas of your own before wiring them into a post like this, arrows.app (arrows.neo4jlabs.com) uses the same visual grammar and is free to use.


Design for questions, not entities

The approach I use when starting any graph schema, and the approach I'd recommend to anyone, is to begin not with the entities but with the questions. Before drawing a single node, before writing a single constraint, I write down every query the graph will need to answer. Every element of the schema must serve at least one of those questions. Elements that serve none get cut.

This might seem like an obvious starting point, but in practice most beginners skip it. They list the things that exist, teams, players, matches, venues, and then start drawing connections. The result is a schema that can store data but may not efficiently answer the questions the data exists to address.

Let me make this concrete with an example. A beginner modelling the tournament might create :Team nodes and store confederation as a string property, because that's the most straightforward representation:

text
(:Team {name: 'England', confederation: 'UEFA', fifaRanking: 4})
(:Team {name: 'France', confederation: 'UEFA', fifaRanking: 3})
(:Team {name: 'Brazil', confederation: 'CONMEBOL', fifaRanking: 6})

This works perfectly well for simple filtering:

cypher
MATCH (tm:Team {confederation: 'UEFA'})
RETURN tm.name

But the moment you ask "how many teams does each confederation have, and what is their average FIFA ranking?", you have no Confederation node to aggregate against. You're forced to scan every Team node, group by a string property, and compute the average. That works, but it means you can never attach properties to the confederation itself (fullName, region, numberOfMemberAssociations), and you can never traverse from the confederation to its teams as a graph operation. The schema has backed itself into a corner by not anticipating the question.

I avoided this by writing the questions first. Here are the six queries that drove the schema design for this project:

  • Which teams are in Group L, and what are their FIFA rankings?
  • Who manages England, and what formation do they prefer?
  • Which teams are making their World Cup debut?
  • How many teams does each confederation have, and what is their average ranking?
  • Who started for England in their opening match, and in what position?
  • Who scored in minute 70, who assisted, and which team did it count for?

Every node label, relationship type, and property in the final schema serves at least one of those questions. Elements that served none got cut, and a few things I initially included got cut when I realised they weren't connected to any query I actually needed to run.


The full schema

With those six questions as the design brief, the schema resolves into eleven node labels and eighteen relationship types. Let me walk through both.

Nodes and their unique identifiers

One of the first decisions I had to make for each node label was: what uniquely identifies this entity? This matters because Neo4j uses uniqueness constraints to protect data integrity, and the property you constrain needs to be the most stable, unambiguous identifier available.

LabelUnique identifierWhy this identifier
:TournamentnameOne tournament per graph; the name is stable and meaningful
:HostCountrycodeISO three-letter codes (USA, CAN, MEX) have no spelling variations
:ConfederationnameOfficial abbreviations, UEFA, CAF, AFC, are fixed and unambiguous
:VenuenameStadium names are unique within this dataset
:Groupname'Group A' through 'Group L', stable and unique
:TeamnameNational team names are unique within any one tournament
:Person:Playername + nationalityTwo players can share a name; composite key required
:PositionnameSmall, fixed vocabulary (Striker, Midfielder, Defender, Goalkeeper); no natural variation
:ClubnameClub names are unique enough within this dataset's scope
:MatchmatchIdNo natural unique name exists; artificial identifier: 'L1', 'L2'
:GoalgoalIdArtificial: 'L1-G1' = Group L, match 1, goal 1

A few of these choices deserve explanation.

HostCountry uses code not name because I discovered early on that "United States", "USA", and "United States of America" are both the same country, but only one string matches a lookup. ISO codes are stable and standardised. They remove an entire class of data quality problem.

Person uses a composite key, name and nationality together, because there have been multiple international footballers with the same name across different nations. The combination is what makes the identity unique. :Manager and :PlayingManager aren't separate rows here because they're additional labels layered onto that same :Person node, not new node types with their own identity — more on that in the dual label section below.

Match and Goal use artificial identifiers because neither has a natural unique name. A match between England and France could happen in multiple tournaments and rounds. A goal has no name at all. Rather than invent a fragile composite key, I created a simple convention: L1 for the first Group L match, L1-G1 for the first goal in that match. Self-documenting, stable, and easy to extend.

Relationships

I spent more time thinking about the relationship types than about anything else in the schema. The naming convention matters: every type should read as a verb phrase in the direction of the arrow, so that a pattern reads like a sentence.

cypher
(:Tournament)-[:HOSTED_BY]->(:HostCountry)
(:Venue)-[:LOCATED_IN]->(:HostCountry)
(:Group)-[:PART_OF]->(:Tournament)
(:Team)-[:IN_GROUP]->(:Group)
(:Team)-[:REPRESENTS]->(:Confederation)
(:Person:Player)-[:PLAYS_FOR]->(:Team)      // jerseyNumber, isCaptain
(:Person:Player)-[:PLAYS_AT]->(:Club)
(:Person:Player)-[:PLAYS_AS]->(:Position)
(:Person:Manager)-[:MANAGES]->(:Team)       // appointedDate
(:Match)-[:HOME_TEAM]->(:Team)
(:Match)-[:AWAY_TEAM]->(:Team)
(:Match)-[:PLAYED_AT]->(:Venue)
(:Match)-[:PART_OF]->(:Group)
(:Person:Player)-[:STARTED]->(:Match)       // position, jerseyNumber
(:Person:Player)-[:SUBSTITUTE]->(:Match)    // jerseyNumber
(:Person:Player)-[:SCORED]->(:Goal)
(:Person:Player)-[:ASSISTED]->(:Goal)
(:Goal)-[:DURING]->(:Match)
(:Goal)-[:FOR]->(:Team)

Notice the direction throughout: the more specific thing points to the more general. Team points to Group, which points to Tournament. Player points to Team. Match points to Team. Goal points to Match. This is a convention I chose deliberately, and I'd encourage you to choose one and stick to it too.

Direction in Neo4j is stored physically, the pointer runs one way. Cypher allows you to query without specifying direction using -[:REL]- (no arrowhead), which matches regardless of which way the pointer runs. But when your direction convention is consistent, your queries become self-documenting. MATCH (tm:Team)-[:IN_GROUP]->(gp:Group) reads exactly like the sentence "team is in group." That readability compounds over time, especially when you return to a schema you haven't touched in a month.

The schema diagram

Building this in arrows.app Click the canvas to create a node, add labels using the + Label button on the right panel, add properties with + Property. Drag from the outer halo of one node to another to create a relationship, name the type in the right panel. Use the theme picker for colour-coded node categories. Export as PNG when done.


Three design decisions worth examining in depth

Why is :Goal a node and not a relationship?

This was the decision I spent the most time on, and I want to explain the reasoning carefully because it illustrates one of the most important patterns in graph modelling.

My first instinct was to model a goal as a relationship: (:Player)-[:SCORED_IN]->(:Match). That's clean, simple, and captures the core fact. But as soon as I asked the next question, who assisted?, the model started to strain. The assist connects a second player to the same goal. If the goal is a relationship, there's nowhere for the assist to live cleanly. I could add an assistedBy property to the SCORED_IN relationship, but then I've lost the ability to traverse from the assisting player to their goals as a graph pattern.

The moment an event needs to connect to more than two entities, the correct pattern is reification, promoting the event from a relationship to a node, so it can hold its own relationships. A goal connects to four things: the scorer, the assister, the match, and the team it counted for. A reified node handles all four:

The Goal node is now a first-class entity in the graph. It can be reached from any of its four connected entities. It carries its own properties: minute, type, isPenalty, isOwnGoal. And critically, it's extensible, if I later want to connect it to a VAR decision node, or an xG value, or a build-up sequence, I don't need to restructure the schema. The node is already there to receive those connections.

The rule I now apply: if an event connects to more than two things, make it a node.

Why does :Person carry multiple labels?

When I started modelling the people in the tournament, players, managers, coaches, my first instinct was to create separate node types: :Player for players, :Manager for managers. That's the natural mapping from a relational mindset, where each "type" of thing gets its own table.

The problem is duplication. A manager is a person. A player is a person. Both have a name, a date of birth, a nationality, a place of birth. If I create separate node types, I duplicate all of that biographical data. And if I later want to find everyone in the tournament regardless of role, I have to query two separate labels and UNION the results.

The dual label pattern solves this elegantly. Every person in the graph carries :Person as the base label, plus whichever role labels apply:

cypher
MATCH (p:Person)                               // all people
MATCH (p:Person:Player)                        // played, ever
MATCH (p:Person:Manager)                       // managed, ever
MATCH (p:Person:Player:Manager:PlayingManager) // concurrently both, right now
MATCH (p:Person:Player:Manager)                // has done both, not necessarily at once
  1. 1

    MATCH (p:Person) — all people

  2. 2

    MATCH (p:Person:Player) — only players

  3. 3

    MATCH (p:Person:Manager) — only managers

  4. 4

    MATCH (p:Person:Player:Manager:PlayingManager) — concurrently both

  5. 5

    MATCH (p:Person:Player:Manager) — has done both

Each label combination queries the same underlying node store but with different filters applied. The biographical data lives once, on the :Person label. Role-specific properties, caps for players, preferredFormation for managers, live on the same node but serve different queries.

Labels aren't computed automatically. PlayingManager has to be maintained by your application (or a scheduled job) that checks whether a person's PLAYS_FOR and MANAGES relationships currently overlap, then runs SET p:PlayingManager or REMOVE p:PlayingManager accordingly.

The extensibility benefit is significant too. Thomas Tuchel was a professional footballer before he became a manager. If I add his playing career data later, his node gains :Player, no duplication, no new node, no merge of records. The label is added to what already exists.

Why does confederation appear as both a property and a relationship?

This is the decision that surprised me most when I reflected on it, because it looks like redundancy, and in most data models, redundancy is a sign something has gone wrong.

Every Team node in the graph carries confederation: 'UEFA' as a string property. And every Team also has a REPRESENTS relationship to a :Confederation node. Both exist simultaneously, and both are used.

The property serves fast filtering. Neo4j automatically creates an index when you define a uniqueness constraint, and the team_name constraint means lookups on name are direct index hits. I added a similar index on confederation, which makes this query fast at any scale:

cypher
MATCH (tm:Team {confederation: 'UEFA'})
RETURN tm.name, tm.fifaRanking
ORDER BY tm.fifaRanking

The relationship serves traversal and aggregation. Once the REPRESENTS relationship exists, the Confederation node becomes a first-class entity, it carries its own properties (fullName, region), and it becomes the natural anchor for queries that need to reason across the graph structure:

cypher
MATCH (tm:Team)-[:REPRESENTS]->(cn:Confederation)
WITH cn,
     count(tm)           AS teamCount,
     avg(tm.fifaRanking) AS avgRanking,
     min(tm.fifaRanking) AS bestRanking
RETURN cn.name    AS confederation,
       cn.region  AS region,
       teamCount,
       avgRanking,
       bestRanking
ORDER BY avgRanking ASC

Run against the full 48-team dataset, this returns:

confederationregionteamCountavgRankingbestRanking
UEFAEurope1618.72
CONMEBOLSouth America621.21
CAFAfrica1040.98
AFCAsia944.619
CONCACAFN/C America652.215
OFCOceania195.095

Four aggregations, 48 nodes, 48 relationship traversals, in under 50ms. The property and the relationship serve different query patterns, and both earn their place in the schema. Once I understood that, what looked like redundancy started to look like precision.


What comes next

Post 2 introduces Cypher, the query language of connected data. I load the full tournament backbone into a live Neo4j database, walk through every keyword from first principles, and write the queries that produce results like the confederation table above.

By the end of it, you'll have a running Neo4j database with the full structural layer of the FIFA 2026 World Cup graph loaded, and the instinct to read a Cypher pattern as naturally as the question it's answering.

Everything you need is free. Neo4j Desktop at neo4j.com/download. The Cypher Query Language extension for VS Code. arrows.neo4jlabs.com for diagrams. The only investment is time, and if you're reading this, you've already made the first part of it.

The football is almost secondary at this point. Almost.


Comments (0)

Loading comments...