Part 3 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 Writing and Reading the 2026 World Cup Data with Cypher.
In the second post I loaded teams and queried them, and it all worked. It worked because I stayed careful the whole way through. The database itself did nothing to enforce that. I merged on the right property, I ran things in the right order, and I didn't accidentally create a duplicate. That's fine for forty-eight teams I typed by hand. It falls apart the moment you load data at scale, or from a source you don't control, or run the same script twice on a tired evening.
This post is about closing that gap. By the end you'll have a database that refuses to hold a duplicate, load scripts you can run ten times with the same result, and enough production discipline to load the entire real tournament, all 104 matches and every goal, from a public dataset, in a way you can trust. That last part is the reward, and it's only safe because of everything before it.
CREATE and the duplication problem
Here is the bug I promised to come back to. In post 2 I warned that CREATE run twice gives you two nodes. Watch it happen on a throwaway :Demo node, which keeps everything you loaded in post 2 untouched:
CREATE (:Demo {name: 'duplicate-me'});
CREATE (:Demo {name: 'duplicate-me'});
MATCH (dm:Demo {name: 'duplicate-me'}) RETURN count(dm) AS copies;The count comes back as two. CREATE doesn't check whether a matching node already exists. It makes a second one anyway. On real data, that's how you end up with two of something that should be unique, and every query that counted on there being one comes back with doubled numbers and nothing to flag the mistake. You can switch to MERGE, and you should, but MERGE is a discipline you apply. The database still lets a stray CREATE, a typo in a name, or a second import create a duplicate without you noticing. (Clear the demo nodes before moving on: MATCH (dm:Demo) DELETE dm;.)
The fix is to make correctness the database's job. That's what a constraint does.
Uniqueness constraints
A uniqueness constraint tells Neo4j that a property, or a combination of properties, identifies a node, and that it must never repeat.
CREATE CONSTRAINT tournament_name IF NOT EXISTS
FOR (tn:Tournament) REQUIRE tn.name IS UNIQUE;With that in place, a second CREATE of a tournament that already exists no longer slips through. It fails, with an error telling you a Tournament with that name already exists. The duplicate can no longer happen, whether it comes from a mistake, a rerun, or a colleague. You've moved the guarantee from your attention span into the schema.
Two parts of that statement earn their keep. IF NOT EXISTS makes it safe to run repeatedly, which matters because your constraints belong at the top of your load script, and that script will run many times. And a uniqueness constraint also builds an index on the property as a side effect. Every lookup by name is fast from the start. You get integrity and speed from one line.
I put the same guard on every node type that has a natural identifier:
CREATE CONSTRAINT team_name IF NOT EXISTS
FOR (tm:Team) REQUIRE tm.name IS UNIQUE;
CREATE CONSTRAINT host_country_code IF NOT EXISTS
FOR (hc:HostCountry) REQUIRE hc.code IS UNIQUE;
CREATE CONSTRAINT venue_name IF NOT EXISTS
FOR (vn:Venue) REQUIRE vn.name IS UNIQUE;Notice HostCountry is guarded on code, not name. That's the decision from post 1 paying off: "United States", "USA", and "United States of America" are three strings for one country, and only the ISO code never wavers. Constrain the property that truly identifies the thing, even when a more readable one is tempting.
When identity takes more than one property
Some things aren't identified by any single property. Two footballers can share a name. A player's identity is the name together with the nationality. Cypher handles this with a composite constraint:
CREATE CONSTRAINT person_name_nationality IF NOT EXISTS
FOR (pn:Person) REQUIRE (pn.name, pn.nationality) IS UNIQUE;Neither property is unique alone. The pair is. Two players called Danny Ward, one Welsh and one from somewhere else, coexist happily, while a second Welsh Danny Ward is rejected.
The rule generalises: identify the entity by whatever combination distinguishes it, even when that's more than one property, and let the constraint enforce exactly that.
The MERGE family, and SET versus ON CREATE SET
MERGE is match-or-create, and with constraints in place the database backs it up instead of relying on your care. But MERGE has three clauses for setting properties, and choosing the wrong one is its own subtle bug.
MERGE (tm:Team {name: 'Spain'})
ON CREATE SET tm.fifaRanking = 2, tm.worldCupTitles = 1
ON MATCH SET tm.lastSeen = datetime()
SET tm.confederation = 'UEFA';ON CREATE SET runs only when MERGE creates the node. Use it for facts that are true at birth and shouldn't be rewritten on every rerun, like the initial ranking. ON MATCH SET runs only when MERGE finds an existing node, which is where you'd stamp a "last updated" time. And a plain SET, after the MERGE, runs in both cases, every time.
That distinction caused a real bug for me. I set a manager's appointment date with ON CREATE SET on the relationship, then reran the load. The relationship already existed. ON CREATE SET never fired, and the date was never set. The load "worked", the property was empty, and nothing complained. The lesson: ON CREATE SET is for values that must never be overwritten. If a property should always reflect the latest load, use plain SET. Reach for ON CREATE SET only when the value must never change.
The small disciplines that prevent large debugging
Three everyday habits catch more bugs than any of the above.
End every statement with a semicolon. Cypher separates statements with a semicolon, and a missing one either merges two statements into a malformed query or skips the second with no warning. When a load block "does nothing", a missing semicolon on the line before it is the first suspect. It cost me twenty minutes once. It has never cost me twenty minutes since.
Name variables consistently. I use short, predictable handles: tn for tournament, tm for team, pn for person, m for match. When every script uses the same names, you read a query without decoding it, and you stop writing MERGE (t)-[:X]->(m) where m was never defined, which is its own afternoon lost.
Fix a run order and write it down. Constraints first, then nodes, then relationships. Relationships need both endpoints to already exist. A relationship script run before its nodes creates nothing useful. A one-line comment at the top of each file stating what must run before it turns a fragile sequence into a repeatable one.
The payoff: loading data you didn't type
Here's what all of this was for. Once identity is guarded by constraints and every write is an idempotent MERGE, you can point Cypher at a dataset you don't control and load it safely. Run it once, run it ten times, the graph is identical.
The full results of the 2026 World Cup are available as open data from openfootball, released under Creative Commons Zero, which is a public-domain dedication: free to use, no attribution required. It's hosted on GitHub, and AuraDB reads from public URLs. The two fit together with no download in between. AuraDB also ships with APOC, whose apoc.load.json reads straight from a URL. If you don't already have a free database open from the earlier posts, create one in your browser and use its Query editor.
The entire tournament, all 104 matches with their scores, loads like this. Three keywords here are new, and you'll meet them properly in Post 4. Don't worry about mastering them now: apoc.load.json fetches the file from the URL, UNWIND runs the rest of the query once per match in the list, and WITH passes values along to the next line. Look past them and the MERGE habit from this post carries the load:
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.date = date(match.date),
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 Team constraint means the teams from the match feed merge into the teams you already loaded, instead of creating shadow duplicates. The matchId, built from the date and the two team names, is the stable key that makes the load idempotent. If the load is interrupted, run it again. Every match in the feed becomes this shape:
The companion file post03_constraints_idempotency.cypher bundles the constraints, this load, and a second pass that loads all 308 goals and their scorers the same way, finishing with checks: 104 matches, 308 goals, and the top scorer landing exactly where the record book says.
That is the difference constraints and idempotency buy you: the confidence to hand the loading to a script and a source, and know the graph will be correct every single time.
A short pre-flight checklist
Before you run any load, and after:
- Constraints are created first, each with
IF NOT EXISTS. - Every node is written with
MERGEon its identifier alone, descriptive fields inON CREATE SET. - Relationship scripts run after both endpoint types exist.
- Every statement ends with a semicolon.
- After loading, a
countper label matches what you expected. If it doesn't, you have a duplicate or a missing relationship, and now you know before it reaches a query.
What comes next
You now have a graph that protects its own integrity and a load you can rerun without a second thought, holding the complete 2026 tournament. The structure is trustworthy. In the final post we make it earn its place as a graph.
Post 4 is where relationships stop being plumbing and become the point. Multi-hop traversals, WITH as a pipeline, OPTIONAL MATCH, and aggregation after traversal, used to build real group tables, a golden-boot ranking, and the path a team took to lift the trophy. These are the questions that a graph answers in a few readable lines.
Cypher Fundamentals on GraphAcademy covers reading and writing data with Cypher, including MERGE, interactively and for free on your own hosted database, if you'd like more practice before the finale.
Your graph now refuses to be wrong. Next we make it tell you something.
Comments (0)
Loading comments...