Writing and Reading the 2026 World Cup Data with Cypher

Load the 48 teams of the 2026 World Cup into a free AuraDB instance and query them back, learning the core of Cypher from first principles.

Part 2 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.

In the first post I designed a schema on a canvas without writing a single line of code. That was deliberate. You cannot query a picture, but you also cannot write good Cypher until you know what shape you're aiming for. Now that the shape is clear, this post is where you start talking to the graph.

By the end you'll have loaded real tournament data into a live database and asked it ten different questions. Every keyword you meet gets taken apart and explained, because the whole point of Cypher is that once you understand a handful of words, you can read and write almost anything. I'll stay on the team, group, and venue data for now, so the focus is the language itself rather than the modelling. Relationships and traversals get their own post at the end of the series.

Everything I run here lives in a single companion file you can follow line for line, post02_first_cypher.cypher: the tournament, the teams, and the venues loaded with MERGE, followed by the read queries from this post.

Get the Cypher for this post

You don't need to install anything

Neo4j AuraDB has a free tier that runs entirely in your browser, with no credit card and nothing to download, and it comes with a built-in query editor. That's where I'll run everything in this post, and it's the same tool Post 1 pointed you to.

Create an instance, and AuraDB hands you a database, a query editor, and a visualiser in about a minute:

Create your free AuraDB instance

Three things to do once you're in:

  1. Click New Instance and choose the Free tier. When it finishes provisioning, AuraDB shows your credentials once. Download that file or copy the password somewhere safe, because you cannot see it again.
  2. Open the Query tab. This is the editor you'll type Cypher into for the rest of the series. It talks to your instance directly, so there's no connection string to configure.
  3. Type your first statement, and get into the habit of a clean slate:
cypher
MATCH (n) 
CALL (n) {
  DETACH DELETE n
} IN TRANSACTIONS OF 1000 ROWS

MATCH (n) finds every node in the database. DETACH DELETE removes each node along with any relationship attached to it, which matters because Neo4j won't let you delete a node that still has relationships hanging off it. You'll run this more than you expect while learning, because the fastest way to understand a load script is to run it, look at the result, wipe, adjust, and run it again.

One quirk of the free tier worth knowing: an idle instance pauses itself after three days. If you come back to a paused database, press resume and your data is exactly as you left it. Just don't leave it paused indefinitely, a Free instance that stays paused for 90 days is deleted permanently.

Writing data: CREATE and MERGE

Cypher has two ways to put a node into the graph, and the difference between them is the most important thing in this post.

CREATE makes a node. Every single time.

cypher
CREATE (tn:Tournament {name: 'FIFA World Cup 2026', year: 2026, totalTeams: 48});

Read it left to right. CREATE is the verb. The parentheses hold a node. tn is a variable, a temporary handle you can refer to later in the same statement. :Tournament is the label, the node's type. The curly braces hold its properties as key-value pairs. Run this and you have one tournament node with three properties.

The trouble starts when you run it twice. CREATE doesn't check whether the node already exists, so a second run gives you a second identical tournament, and every query that assumed one tournament now quietly returns doubled results. I did exactly this early on, ran a block again to "make sure it worked," and spent twenty minutes wondering why my counts were off.

If you ran that CREATE while following along, clear it with the reset query before continuing, so it doesn't collide with the load below.

MERGE is the fix. It means match-or-create: look for a node matching the pattern, use it if it exists, create it only if it doesn't. Run it a hundred times and you still have one node.

cypher
MERGE (tn:Tournament {name: 'FIFA World Cup 2026'})
ON CREATE SET
  tn.year         = 2026,
  tn.edition      = 23,
  tn.startDate    = date('2026-06-11'),
  tn.endDate      = date('2026-07-19'),
  tn.totalTeams   = 48,
  tn.totalMatches = 104;

Look carefully at what goes inside the MERGE and what comes after it. The MERGE pattern holds only name, the one property that decides identity. Everything else is set in the ON CREATE SET block, which runs only when the node is genuinely new. If I'd put year inside the MERGE pattern, then changing the year later would make Cypher think it was a different tournament and create a second one. So the rule I follow every time: merge on the identifier alone, and set the descriptive properties in ON CREATE SET. That single habit makes every load script safe to re-run.

Merge on the identifier alone, and set everything else in ON CREATE SET. That one habit makes every load safe to re-run.

Loading the forty-eight teams follows the exact same pattern, once per team:

cypher
MERGE (tm:Team {name: 'England'})
ON CREATE SET
  tm.confederation  = 'UEFA',
  tm.group          = 'L',
  tm.isHost         = false,
  tm.fifaRanking    = 4,
  tm.worldCupTitles = 1;
 
MERGE (tm:Team {name: 'Curaçao'})
ON CREATE SET
  tm.confederation = 'CONCACAF',
  tm.group         = 'E',
  tm.isHost        = false,
  tm.fifaRanking   = 82,
  tm.isDebutant    = true;

The data is the real 2026 field. Curaçao, a Caribbean island of about 156,000 people, became the smallest nation ever to reach a World Cup, and it's one of four teams making their debut, alongside Cape Verde, Jordan, and Uzbekistan. Notice that I set isDebutant: true on those four and leave the property off everyone else. In a graph you don't write isDebutant: false forty-four times. The absence of a property is itself an answer, and in a moment you'll see how cleanly you can query for it.

In a graph, the absence of a property is itself data. You query for what isn't there with IS NULL, so you never store isDebutant: false forty-four times.

The companion file loads all forty-eight teams this way. Paste it into the Query tab, run it, and you have a database full of teams. Typing a handful by hand is how the MERGE pattern sticks, but you won't hand-type the whole tournament: later in the series you'll load all 104 real matches and every goal in a single query, from a free public dataset, and the same MERGE habit is what makes that safe.

Here is the shape of what you have built: labelled nodes, each a circle with its own properties, and not a single line between them yet. The diagram below shows the tournament and two teams as a representative sample. Those connections are the whole subject of Post 4.

Now let's ask it questions.

Reading data: MATCH, RETURN, and AS

Reading uses the same pattern syntax as writing, with a different verb. Here's the simplest possible question: how many teams did I actually load?

cypher
RETURN COUNT { (:Team) } AS teams

MATCH finds a pattern, here just "every node labelled :Team." RETURN says which part of what you found you want back. count(tm) counts the matches. AS teams renames the output column so the header reads teams instead of count(tm). That last word, AS, costs nothing and makes every result readable, so use it every time. You should get back a single number: 48.

Now something more useful. Which teams are in England's group, and how are they ranked?

cypher
MATCH (tm:Team {group: 'L'})
RETURN tm.name        AS team,
       tm.fifaRanking AS ranking
ORDER BY tm.fifaRanking;

The curly braces filter the match to teams whose group property equals 'L'. ORDER BY sorts the result, ascending by default. You get England, Croatia, Ghana, and Panama, in ranking order. Read the whole thing as a sentence and it barely looks like code: match teams in group L, return their name and ranking, ordered by ranking.

Filtering with WHERE

Putting a property inside the curly braces works for exact matches, but the moment you want anything more expressive, you reach for WHERE. It sits between MATCH and RETURN and filters the rows.

The strongest teams on paper, by FIFA ranking:

cypher
MATCH (tm:Team)
WHERE tm.fifaRanking <= 10
RETURN tm.name        AS team,
       tm.fifaRanking AS ranking,
       tm.group       AS group
ORDER BY tm.fifaRanking
LIMIT 10;

WHERE tm.fifaRanking <= 10 keeps only the top ten. LIMIT 10 caps the number of rows returned, which is a good reflex on any query that could return more than you want to look at.

WHERE understands more than comparisons. Four of my favourite forms, each answering a real question about the tournament:

cypher
// Former world champions: IS NOT NULL checks the property exists at all
MATCH (tm:Team)
WHERE tm.worldCupTitles IS NOT NULL
RETURN tm.name AS team, tm.worldCupTitles AS titles
ORDER BY titles DESC;
 
// The debutants: a simple equality check on the flag we set earlier
MATCH (tm:Team)
WHERE tm.isDebutant = true
RETURN tm.name AS team, tm.group AS group, tm.confederation AS confederation
ORDER BY tm.name;
 
// Teams from two confederations: IN checks membership of a list
MATCH (tm:Team)
WHERE tm.confederation IN ['UEFA', 'CONMEBOL']
RETURN tm.confederation AS confederation, tm.name AS team
ORDER BY tm.confederation, tm.fifaRanking;
 
// Name matching: CONTAINS does a case-sensitive substring search
MATCH (tm:Team)
WHERE tm.name CONTAINS 'South'
RETURN tm.name AS team, tm.group AS group;

The debutants query returns exactly four rows, Cape Verde, Curaçao, Jordan, and Uzbekistan, which is why we never needed to store isDebutant: false anywhere. IS NOT NULL is doing quiet, powerful work: it treats the presence of a property as data. The CONTAINS query returns South Africa and South Korea, and it's worth remembering that it's case-sensitive, so 'south' would match nothing.

Collecting and counting

Two functions turn a list of rows into a summary, and you'll use both constantly.

collect() gathers many values into a single list. Instead of one row per team, get one row per group with its teams gathered together:

cypher
MATCH (tm:Team)
RETURN tm.group          AS group,
       collect(tm.name)  AS teams
ORDER BY group;

Twelve rows, one per group, each with a list of four team names. collect() is the difference between a flat table and a result shaped like the question you asked.

count() does the obvious thing, and pairing it with a grouping key gives you a breakdown. How many teams does each confederation bring?

cypher
MATCH (tm:Team)
RETURN tm.confederation AS confederation,
       count(tm)        AS teams
ORDER BY teams DESC;
confederationteams
UEFA16
CAF10
AFC9
CONMEBOL6
CONCACAF6
OFC1

This works, and it answers the question. But notice what it can't do. It groups on a string property, so there's nowhere to hang information about each confederation, no full name, no region, and no way to ask "what is the average ranking of each confederation's teams" as a graph operation. That limitation is the whole subject of Post 4, where the confederation becomes a node you can traverse to and the same question gets a much richer answer.

FIFA rankings here reflect the standings at tournament kick-off in June 2026. Rankings update monthly, so your numbers will differ if you load more recent values.

One last read, this time against the venues, to show aggregation with numbers:

cypher
MATCH (vn:Venue)
WHERE vn.capacity > 70000
RETURN vn.name     AS venue,
       vn.city     AS city,
       vn.capacity AS capacity
ORDER BY vn.capacity DESC;

Seven stadiums clear 70,000 seats, led by AT&T Stadium in Arlington at 94,000 and the Estadio Azteca in Mexico City (officially Estadio Banorte for the tournament) at 83,000. Drop the WHERE and change the RETURN to sum(vn.capacity), max(vn.capacity), min(vn.capacity) and you get the tournament's total footprint in one row: a little over 1.08 million seats across all sixteen venues.

A Cypher quick reference

Everything you met in this post, in one place to keep beside the query editor:

Keyword or functionWhat it doesExample
CREATEMakes a node or relationship, every timeCREATE (tm:Team {name: 'Brazil'})
MERGEMatch-or-create: makes it only if it doesn't existMERGE (tm:Team {name: 'Brazil'})
ON CREATE SETSets properties only when MERGE creates the nodeON CREATE SET tm.fifaRanking = 6
MATCHFinds a pattern in the graphMATCH (tm:Team)
RETURNChooses what to give backRETURN tm.name
ASRenames an output columnRETURN tm.name AS team
WHEREFilters matched rowsWHERE tm.fifaRanking <= 10
IS NOT NULLKeeps rows where a property existsWHERE tm.worldCupTitles IS NOT NULL
INChecks membership of a listWHERE tm.confederation IN ['UEFA', 'CAF']
CONTAINSCase-sensitive substring matchWHERE tm.name CONTAINS 'South'
ORDER BYSorts the resultORDER BY tm.fifaRanking
LIMITCaps the number of rowsLIMIT 10
collect()Gathers values into a listcollect(tm.name)
count()Counts matched rowscount(tm)
DETACH DELETEDeletes nodes and their relationshipsMATCH (n) DETACH DELETE n

What comes next

You can now write data safely with MERGE, read it back with MATCH and RETURN, filter it with WHERE, and summarise it with collect() and count(). That's most of the Cypher you'll use on any given day, and you did it all in the browser on a free database.

Post 3 closes the gap between code that works once and code that works reliably. I'll add uniqueness constraints and composite keys so the database refuses to let you create a duplicate team in the first place, dig into the full MERGE family and when to use SET instead of ON CREATE SET, and cover the small disciplines, the semicolons, the variable names, the run order, that separate a script you can trust from one you have to babysit.

Neo4j Fundamentals on GraphAcademy covers this same ground interactively and for free, with your own hosted database, if you'd rather learn it hands-on before the next post.

You've taught the graph to remember forty-eight teams. Next we teach it to protect them.

Comments (0)

Loading comments...