One of my favorite things about F1 racing is the data behind it. F1 cars are the most complex and advanced in any racing series. They collect huge amounts of telemetry data. The tracks also gather data during events, and race engineers analyze it week after week. They study everything from weather, tire temperatures to corner exit speeds. Data drives the sport forward in a major way.
While learning about graph databases and Neo4j I realized it was the perfect tool for answering a question I was curious about. We've all seen or heard of "Six Degrees of Kevin Bacon" where nearly any actor can be traced back to the Footloose star. I wondered: could this work for F1 drivers? By comparison it's a much smaller dataset than famous actors.

Photos via Wikimedia Commons, licensed under CC BY‑SA 4.0.
Is Max Verstappen connected to Juan Manuel Fangio? Could a driver who retired in 1958, decades before Max was born, connect to him through a chain of teammates? And if so, how many links does it take?
This post is how I answered that, and it doubles as a gentle introduction to Neo4j and graph databases. By the end you'll have built a real graph of every F1 driver since 1950 on your own machine, and you'll run a query that answers my Verstappen-to-Fangio question in a single line. No prior graph experience needed.
Let's get into it.
Why this is a graph problem
Before we start: My question isn't really about drivers. It's about the connections between drivers.
If all I wanted was a list of drivers, or each driver's win count, or how many races happened at Monza, a plain old relational table handles that beautifully. Even a spreadsheet can do it. Spreadsheets are wonderful at facts about things. Where they start to sweat is questions about relationships and chains of relationships. Specifically long relationship chains.
Think about what "is Verstappen connected to Fangio?" actually requires. You don't know in advance whether the answer is three hops or nine. So in SQL you'd be writing a recursive common table expression that joins a results table to itself, over and over, to a depth you can't predict, while trying not to drown in duplicate paths.
I tried do this very thing and locked up the application trying. It's possible to do queries like this, but they rarely run fast, if they run at all. Relational databases weren't designed for things like this.
A graph database flips the whole thing around. Instead of storing drivers in one table and hoping to reconstruct their connections later with joins, it stores the connections themselves as useful entities. That's the one idea underneath everything else in this post:
In a graph database, the relationships are first class data. They're not something you compute at query time. They're something you store, traverse, and count directly.
That single design choice is what turns this tough question into a one liner. Let me show you the model before we build it.
The property graph model, in four pieces
Neo4j uses the Labeled Property Graph model. It sounds fancy; it's just four building blocks. I'll introduce each one using our F1 data.
Nodes are the things in your domain. The entities. For us, that's drivers and teams. In a diagram you draw them as circles. Ayrton Senna is a node. McLaren is a node.
Labels are the type of a node, written with a colon: :Driver, :Constructor. (Constructor is just F1's official word for "team".) Labels are how Neo4j knows a Senna node is a driver and a McLaren node is a team. By convention they're written in PascalCase.
Relationships are the connections between nodes, and this is where graphs shine. Every relationship has a type in SCREAMING_SNAKE_CASE, a direction, and a start and end node. Senna DROVE_FOR McLaren is a relationship. Crucially, that connection is stored in the database. Neo4j keeps a pointer from one node to the next. This is why hopping across relationships stays fast even when your graph gets huge. The cost of following one relationship remains small, whether your database has a thousand nodes or a billion.
Properties are key-value pairs you can hang on either a node or a relationship. A :Driver node has forename: 'Ayrton', surname: 'Senna', nationality: 'Brazilian'. Here's something that might be surprising if you come from a table world: a relationship can carry properties too. Our DROVE_FOR relationship will carry season: 1988, because which season someone drove for a team is a fact about the connection, not about the driver or the team on their own.
That last point is worth thinking about, because it was an "aha" moment for relational-to-graph thinking. Senna drove for McLaren, but when he did it doesn't belong to Senna and doesn't belong to McLaren. It belongs to the link between them. Put it on the relationship and a whole category of modeling headaches evaporates.
Here's our entire starting model:
(:Driver {forename: 'Ayrton', surname: 'Senna'})
-[:DROVE_FOR {season: 1988}]->
(:Constructor {name: 'McLaren'})
Two circles, one labeled arrow between them. If you want to make that concrete right now, open Arrows (a free browser diagramming tool) and draw it. Click to make a node, give it a label and some properties, drag from its edge to a second node to create the relationship. It's helpful to sketch your schema there before writing any code.
Design for the question you want to ask
Before building anything, I did the single most useful thing you can do when modeling a graph: I wrote down the question I actually wanted to answer, first, and let it drive every decision afterward.
My question — "how are two drivers connected through shared teammates?" This tells me exactly what my graph needs. It needs drivers. It needs some notion of two drivers being teammates. Everything else is optional scaffolding.
But notice the dataset doesn't hand me "teammate" directly. It gives me who drove for which team in which season. Two drivers are teammates when they drove for the same team in the same season.
So my plan has a nice shape to it:
- Load
DriverandConstructornodes. - Connect them with
DROVE_FORrelationships (one per driver, per team, per season). -
Derive a brand-new
TEAMMATE_OFrelationship between any two drivers who share a team and season. - Walk the
TEAMMATE_OFweb to answer my question.
In step three we are creating relationships that weren't in the raw data, by reasoning about the graph you already have. This is one of my favorite things about working in Neo4j, and you'll see why shortly. Let's build.
What you'll need
- Neo4j Desktop — free, from neo4j.com/download. I'm on the current version (Desktop 2.x) on a Mac; Windows and Linux are the same journey.
- The dataset — the "Formula 1 World Championship (1950–2024)" dataset by Rohan Rao on Kaggle. Free Kaggle account, one download, clean CSVs.
- An afternoon. Realistically a couple of hours, most of it spent going "oh that's cool" at the results.
- No prior Cypher. I'll explain every query as we go. Cypher is Neo4j's query language and it's genuinely readable. If you already know SQL you'll be nodding along within minutes.
A quick note on the data: For about a decade, the go-to source for F1 data was the Ergast API. It shut down at the end of 2024. The Kaggle dataset we're using preserves Ergast's exact structure as downloadable CSVs, and if you later want live current-season data, the community-run Jolpica-F1 API (
api.jolpi.ca/ergast/f1/) serves the same schema. Build against the CSVs today; top up from Jolpica whenever you like. Nothing in this tutorial changes.
Once Neo4j Desktop is installed: under local instances, click create instance to create a new local instance.
Give it a name and a password you'll remember, and start it. Then open the Query tool (in Desktop 2.x this is where you run Cypher — it's the modern replacement for what older tutorials call "Neo4j Browser"). That's our workbench.
We need four files from the Kaggle download:
drivers.csvconstructors.csvraces.csv-
results.csv
We can stage these files for import by placing them in our imports folder. Select your local instance, and look for the ... button.
Select Open then Instance folder:

This is the folder for your Neo4j instance. Next select the import folder. This is where you want to place the files.
macOS:
/Users/[your username]/neo4j-community-2026.x/import
Windows:
C:\Neo4j\import
Linux:
/var/lib/neo4j/import
Now that the files are in the import folder, we can access them with Cypher later.
Step 1: Constraints first
Before loading a single row, I created uniqueness constraints. A constraint guarantees you'll never accidentally create two copies of the same driver. Neo4j automatically builds an index behind each one, which makes all the lookups during import dramatically faster.
In Neo4j Desktop you can run queries by selecting query from the left hand panel and entering your queries in the window in the upper right.
Here's the query to create the constraints:
CREATE CONSTRAINT driver_id IF NOT EXISTS
FOR (d:Driver) REQUIRE d.driverId IS UNIQUE;
CREATE CONSTRAINT constructor_id IF NOT EXISTS
FOR (c:Constructor) REQUIRE c.constructorId IS UNIQUE;
CREATE CONSTRAINT race_id IF NOT EXISTS
FOR (r:Race) REQUIRE r.raceId IS UNIQUE;
Run SHOW CONSTRAINTS to confirm all three landed. That's your data-integrity seatbelt fastened.
Step 2: Load the nodes
Now we bring in the entities. LOAD CSV reads a file row by row; MERGE is Cypher's "create this if it doesn't already exist, otherwise match the existing one" command.
Drivers:
LOAD CSV WITH HEADERS FROM 'https://raw.githubusercontent.com/JeremyMorgan/Six-Degrees-Senna/main/import/drivers.csv' AS row
MERGE (d:Driver {driverId: toInteger(row.driverId)})
SET d.forename = row.forename,
d.surname = row.surname,
d.fullName = row.forename + ' ' + row.surname,
d.nationality = row.nationality,
d.dob = CASE WHEN row.dob <> '\\N' THEN date(row.dob) END;
That CASE WHEN row.dob <> '\\N' is guarding against a quirk you'll hit constantly with this dataset: missing values are stored as the literal text \N. If you don't filter them out, you'll end up with drivers whose birthday is the string "backslash-N", which is exactly as useful as it sounds. Consider that your first real-world data-cleaning lesson, delivered by Formula 1.
Constructors:
LOAD CSV WITH HEADERS FROM 'https://raw.githubusercontent.com/JeremyMorgan/Six-Degrees-Senna/main/import/constructors.csv' AS row
MERGE (c:Constructor {constructorId: toInteger(row.constructorId)})
SET c.name = row.name,
c.nationality = row.nationality;
Races (we mostly need these to know which season a result belongs to, but full Race nodes cost nothing and set you up for future projects):
LOAD CSV WITH HEADERS FROM 'https://raw.githubusercontent.com/JeremyMorgan/Six-Degrees-Senna/main/import/races.csv' AS row
MERGE (r:Race {raceId: toInteger(row.raceId)})
SET r.year = toInteger(row.year),
r.round = toInteger(row.round),
r.name = row.name,
r.date = date(row.date);
Quick check: you should see something in the ballpark of 861 drivers, 212 constructors, and 1,100-plus races:
MATCH (n) RETURN labels(n)[0] AS label, count(*) ORDER BY label;
If those numbers look right, you've just loaded three-quarters of a century of motorsport into a graph. We haven't done anything clever yet, but we're about to.
Step 3: Connect drivers to teams
The results.csv file has one row per driver, per race — around 26,000 rows. I don't want 26,000 relationships cluttering my graph. I want one clean fact per driver, per team, per season: this person drove for this team that year. So I aggregate as I load.
:auto
LOAD CSV WITH HEADERS FROM 'https://raw.githubusercontent.com/JeremyMorgan/Six-Degrees-Senna/main/import/results.csv' AS row
CALL (row) {
MATCH (d:Driver {driverId: toInteger(row.driverId)})
MATCH (c:Constructor {constructorId: toInteger(row.constructorId)})
MATCH (r:Race {raceId: toInteger(row.raceId)})
MERGE (d)-[s:DROVE_FOR {season: r.year, constructorId: c.constructorId}]->(c)
ON CREATE SET s.entries = 1,
s.wins = CASE WHEN row.positionOrder = '1' THEN 1 ELSE 0 END
ON MATCH SET s.entries = s.entries + 1,
s.wins = s.wins + CASE WHEN row.positionOrder = '1' THEN 1 ELSE 0 END
} IN TRANSACTIONS OF 2000 ROWS;
There's a lot of learning packed into that one statement, so let's unpack it:
MERGE (d)-[:DROVE_FOR {season, constructorId}]->(c)is the key move. The first time we see Senna-at-McLaren-in-1988, this creates the relationship. Every subsequent race that season just finds the existing one and bumps its counters. Twenty-six thousand rows collapse into roughly 3,600 clean driver-season-team facts. This is the graph-modeling principle in action: store relationships at the granularity you plan to query.ON CREATE/ON MATCHlet you do one thing when the relationship is brand new and a different thing when it already exists — here, initialize the counters versus increment them.:autoandIN TRANSACTIONS OF 2000 ROWStell Neo4j to commit the import in batches rather than one giant transaction, which keeps memory happy on a big file.
Version note:
CALL (row) { … }is the modern syntax (Neo4j 5.23 and up) for passing a variable into a subquery. If your Neo4j is older, you'll get a syntax error on that line — use the legacy formCALL { WITH row … }instead. And don't copy an abbreviated snippet with...in the middle into the Query editor; Cypher will try to parse the dots. Use the full block above.
Let's make sure it worked by looking at a career I know:
MATCH (d:Driver {surname:'Senna', forename:'Ayrton'})-[s:DROVE_FOR]->(c:Constructor)
RETURN c.name AS team, s.season AS season, s.wins AS wins
ORDER BY season;
Toleman in '84, Lotus '85 to '87, McLaren '88 to '93, Williams in '94. If that's what you see, your graph is alive and correct.
Step 4: Derive the teammate network.
Everything so far was setup. This is the payoff of graph thinking.
Nowhere in the data does it say "Senna and Prost were teammates." But we can derive that: two drivers are teammates if they each have a DROVE_FOR relationship to the same constructor with the same season. And in Cypher, describing that pattern is close to describing it in English:
MATCH (d1:Driver)-[r1:DROVE_FOR]->(c:Constructor)<-[r2:DROVE_FOR]-(d2:Driver)
WHERE r1.season = r2.season
AND d1.driverId < d2.driverId
MERGE (d1)-[t:TEAMMATE_OF {season: r1.season, team: c.name}]->(d2);
Read that MATCH line like a picture: driver one points to a constructor, and driver two points to the same constructor from the other side. The WHERE says "same season." And then we MERGE a shiny new TEAMMATE_OF relationship between them.
We just created around 10,000 relationships that didn't exist in the source data, purely by reasoning about the shape of the graph.
Two small things worth understanding:
d1.driverId < d2.driverIdstops us creating each pairing twice (Senna→Prost and Prost→Senna). By only linking the lower ID to the higher one, each pair gets a single relationship. When we query it, we'll just ignore direction — because "teammate" goes both ways, and Cypher happily traverses a relationship in either direction when you leave the arrowhead off.A deliberately imperfect definition, and why I'm keeping it. "Same team, same season" isn't exactly "raced side by side." Midseason driver swaps mean, for example, that Senna and David Coulthard both count as 1994 Williams drivers. Coulthard was Senna's replacement, and they never actually raced as teammates. I could tighten this up by deriving teammate links per-race instead of per-season.
I'm keeping the looser version on purpose, for two reasons. First, it makes the network richer and more connected across eras, which is the whole point. Second, every graph model is an argument about what a relationship means. There's no universally correct answer; there's only the definition that serves your question. Naming that trade-off out loud is important.
Step 5: Answer the question
Here it is. The reason I built the whole thing. Two drivers separated by half a century, and one line of Cypher to connect them:
MATCH (max:Driver {surname:'Verstappen', forename:'Max'}),
(fangio:Driver {surname:'Fangio'})
MATCH p = shortestPath((max)-[:TEAMMATE_OF*]-(fangio))
RETURN p;
That * after TEAMMATE_OF is the star of the show. It means "follow this relationship any number of times" — a variable-length path. shortestPath then finds the tightest chain of teammate links between the two drivers. This is the exact query that would've been a page of recursive SQL. In Cypher it fits on a napkin.
When you run it, the Query tool draws the answer as a chain of driver nodes, each link labeled with the team and season that connects them. This is how close Max really is to Fangio.
Once that lands, you'll want to push further. Here are the three queries I couldn't stop running.
Every driver's "Senna number" — like the Bacon number, but for F1. How many teammate-hops is each driver from Ayrton Senna?
MATCH (senna:Driver {surname:'Senna', forename:'Ayrton'})
MATCH (d:Driver) WHERE d <> senna
MATCH p = shortestPath((senna)-[:TEAMMATE_OF*..25]-(d))
RETURN length(p) AS sennaNumber, count(d) AS drivers
ORDER BY sennaNumber;
The shape of those results is the real insight: almost the entire history of the sport sits within a handful of hops of Senna. That's a "small-world network," demonstrated with race cars.
The most connected drivers in history — the human bridges holding the whole web together:
MATCH (d:Driver)-[:TEAMMATE_OF]-(other:Driver)
RETURN d.fullName AS driver, count(DISTINCT other) AS teammates
ORDER BY teammates DESC
LIMIT 10;
Watch who tops this list: long career journeymen and team-hoppers, not necessarily the champions. Connectedness rewards longevity and movement, not podiums. I find that interesting. The people stitching F1's social fabric together are often not the ones holding the trophies.
Is it really all one network? Are there isolated islands of drivers?
MATCH (d:Driver)
WHERE NOT (d)-[:TEAMMATE_OF]-()
RETURN count(d) AS unconnectedDrivers;
A small handful of true loners from F1's chaotic early days, and then one enormous connected web containing basically everyone else. Seventy-five years, one family.
What you just learned (it wasn't really about F1)
- The property graph model — nodes, labels, relationships, and properties, including the quietly powerful idea that a relationship can carry properties of its own.
- Designing for questions, not entities — writing the question first and letting it shape the model.
-
LOAD CSV,MERGE, and constraints — the everyday mechanics of getting real data into Neo4j cleanly. - Deriving new relationships — creating structure that wasn't in your source data by reasoning about the graph you already have.
-
Variable-length paths and
shortestPath— the thing graphs do effortlessly and relational databases do through gritted teeth.
And here's the part that matters beyond motorsport: swap the dataset and every one of these skills transfers directly. The teammate network is structurally identical to a fraud ring, a supply chain, a social graph, an org chart, or the knowledge graph behind an AI application. "Who is connected to whom, and how?" is one of the most valuable questions in software, and you now know how to ask it.
Where to go next
If this clicked for you the best next move is to get the fundamentals properly, in order. That's exactly what GraphAcademy is for. It's Neo4j's free, hands-on learning platform.
A path I'd suggest, roughly in this order:
- Neo4j Fundamentals — the property graph model, cemented, with exercises.
- Cypher Fundamentals — everything we hand-waved past today, done properly.
-
Importing data with Cypher —
LOAD CSVand friends, beyond our quick version.
For future articles I'm thinking: turn this same graph into a fair fight, deriving "who-beat-whom" links between teammates and running an algorithm called PageRank to settle the greatest-of-all-time argument without ever touching the points table.







Top comments (0)