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.
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 instanceThree things to do once you're in:
- 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.
- 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.
- Type your first statement, and get into the habit of a clean slate:
MATCH (n)
CALL (n) {
DETACH DELETE n
} IN TRANSACTIONS OF 1000 ROWSMATCH (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.
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.
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.
Loading the forty-eight teams follows the exact same pattern, once per team:
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.
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?
RETURN COUNT { (:Team) } AS teamsMATCH 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?
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:
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:
// 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:
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?
MATCH (tm:Team)
RETURN tm.confederation AS confederation,
count(tm) AS teams
ORDER BY teams DESC;| confederation | teams |
|---|---|
| UEFA | 16 |
| CAF | 10 |
| AFC | 9 |
| CONMEBOL | 6 |
| CONCACAF | 6 |
| OFC | 1 |
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:
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 function | What it does | Example |
|---|---|---|
CREATE | Makes a node or relationship, every time | CREATE (tm:Team {name: 'Brazil'}) |
MERGE | Match-or-create: makes it only if it doesn't exist | MERGE (tm:Team {name: 'Brazil'}) |
ON CREATE SET | Sets properties only when MERGE creates the node | ON CREATE SET tm.fifaRanking = 6 |
MATCH | Finds a pattern in the graph | MATCH (tm:Team) |
RETURN | Chooses what to give back | RETURN tm.name |
AS | Renames an output column | RETURN tm.name AS team |
WHERE | Filters matched rows | WHERE tm.fifaRanking <= 10 |
IS NOT NULL | Keeps rows where a property exists | WHERE tm.worldCupTitles IS NOT NULL |
IN | Checks membership of a list | WHERE tm.confederation IN ['UEFA', 'CAF'] |
CONTAINS | Case-sensitive substring match | WHERE tm.name CONTAINS 'South' |
ORDER BY | Sorts the result | ORDER BY tm.fifaRanking |
LIMIT | Caps the number of rows | LIMIT 10 |
collect() | Gathers values into a list | collect(tm.name) |
count() | Counts matched rows | count(tm) |
DETACH DELETE | Deletes nodes and their relationships | MATCH (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...