World Cup Analytics is an analytics product I built to follow the 2026 World Cup with real data on matches, teams, and players: scores, xG, shots, rankings, individual profiles, shot maps, and matchup analysis.
This article is about building the project: where the data came from, which architecture decisions made a difference, and which problems showed up along the way. The goal was a complete data product, from ingestion to the final view.
The idea
I wanted to build a real analytics engineering project: something with data arriving continuously, a real need for modeling, automatic updates, performance decisions, and visualizations actually useful to an end user.
The World Cup, happening in real time, was the perfect setting for that. Data arrives in waves, every round raises new questions, and the product has to adapt to the current state of the competition: group stage, best third-placed teams, knockout rounds, player profiles, rankings, match analysis.
It was also a good context to test something I consider essential in data projects: turning raw information into a reading. Having raw numbers isn’t enough. The product needs to help answer questions like:
- who’s playing better right now?
- which teams are creating the most?
- which players are outperforming expectations?
- how was a given match actually decided?
- what kind of data actually helps explain football?
Where the data came from
The first challenge was finding a structured, reliable source of football data. I needed things like lineups, per-player statistics, goals, match events, xG, shot coordinates, and team-level data.
A few sites had part of this available, but rate limits and instability made the process unreliable for a product that needs constant updates. The alternative was to go find a dedicated API.
After evaluating options, I went with TheStatsAPI. It covers the 2026 World Cup match by match, with lineup data, per-player statistics, a shot map with on-pitch coordinates, and an event timeline.
I also looked at StatsBomb, a free and well-known source in the football data space. Their public archive is rich, but the free tier doesn’t cover competitions that are still in progress, so it wasn’t an option for this. Once that data becomes available, I plan to consider it for a future revision of the project.
What I had to curate by hand
Even with a primary API, not everything I needed existed ready to use. Two important parts of the site required manual curation.
The first was each match’s kit colors. FIFA assigns each team’s kit match by match to avoid color clashes on the pitch. In some cases it even forces both teams into alternate kits. That data lives in official FIFA PDFs, with line-player and goalkeeper kit information per match.
Since this information didn’t come structured through the API, I organized the colors by phase by hand and turned them into a format the site can consume. The goal was to have every match reflect the real visual identity of that specific game, instead of always falling back to each team’s default color.
The second part was player shirt numbers. In many cases, the API returned the number a player wears at their club, not the number they wear for their national team at the World Cup. In more than 900 of the edition’s 1,248 players, the displayed number was wrong. In some lineups from the first version, players from the same team even showed up with duplicate numbers.
To fix this, I used public squad lists as a reference and checked it player by player. Not a glamorous process, but a necessary one. This kind of detail looks small until it shows up wrong on screen.
The lesson here is simple: a single, perfect data source rarely exists. In real projects, it’s much more common to have one primary source, a few auxiliary ones, and a curation layer to fix whatever nobody organized exactly the way your product needs.
The architecture: raw data kept, ready data served
The first version of the project read the API and calculated everything at the moment someone loaded a page: percentiles, rankings, comparisons, team summaries, player statistics, aggregated competition data.
It worked at first. But with the World Cup underway and dozens of matches already played, every Home page load ended up scanning hundreds of files and redoing every calculation from scratch. Some pages started taking seconds to load.
The architecture decision that fixed this was splitting the project into two main layers.
Raw layer
The raw layer saves every API response exactly as it came, with no transformation. Each match has its own files for lineups, statistics, events, and shots.
This process is idempotent: if a match has already been fetched, it doesn’t get fetched again. That matters because the API has a rate limit. Reprocessing the whole tournament from scratch would be slow and would waste calls; resuming an interrupted fetch needs to be fast.
The raw layer is never overwritten. It works as the project’s source of truth: if some metric turns out to be wrong, I can fix the calculation and rebuild everything from the original data.
Ready layer
The ready layer reads the raw layer, calculates whatever the screens need, and writes the result to a database already in the format the application consumes.
That includes:
- player rankings;
- team rankings;
- match data;
- radar charts;
- comparisons;
- individual profiles;
- competition summaries;
- data aggregated by phase;
- shooting statistics;
- derived metrics.
With that in place, when someone visits the site, the server doesn’t need to recalculate the entire tournament. It just fetches data that’s already been processed.
That change dropped page load times from a few seconds down to tens of milliseconds.
Memory is architecture too
One of the most concrete constraints showed up when rebuilding the ready layer. The project runs on a small machine, and the rebuild process actually crashed from running out of memory. The problem wasn’t a specific formula. It was the volume of data being inserted all at once. At some points, the database jumped from barely any memory used to over 1 GB during a single load step.
The fix was simple but important: break the inserts into smaller batches and free memory as soon as each part finished. Peak usage dropped dramatically. It was a practical reminder that architecture also means deciding how much data sits in memory at once, when to release objects, when to split up processing, and how to keep a small task from taking down the whole machine.
Changing a calculation shouldn’t require a risky migration
Another important decision was keeping calculation and structure clearly separate.
Since the ready layer is rebuilt from the raw layer, fixing a formula or adjusting a metric almost always just means running the process again. No manual data edits in the database, no risky migration to fix a derived result. Real structural database changes still happen, of course, but they’re rarer, and when they do happen, they’re documented and versioned separately.
This model made the project much easier to fix, especially since several metrics needed revisiting during construction.
How the site updates itself
During the competition, an automated process runs at a regular interval. It checks the schedule, finds finished matches that don’t have their details saved yet, fetches what’s missing while respecting the API’s rate limit, and rebuilds the ready layer.
There’s also a simple lock to prevent two runs from overlapping: if an update is still running, the next one waits or skips. In practice, once a match ends, the site tends to reflect the result, the match highlights, and the impact on the standings shortly after, with no manual work involved.
Real problems in the data
Even using a professional source, the data came with real problems. Most of them don’t show up just by reading the code. They only become clear once you know the domain well enough to compare what the screen shows against what actually happened in the match.
Extra time shown as a penalty shootout
The match between Argentina and Cape Verde, won 3–2 by Argentina with no penalty shootout at all, showed up on the site as “1–1, Argentina won on penalties 3–2.” The source splits the score into separate parts: regular time, extra time, penalties. A rushed read of a misleadingly named field mixed all of that together. Fixing it meant treating extra time and a penalty shootout as genuinely different states, rather than generic extensions of the same score.
Own goals credited to the wrong team
The source’s event timeline credited every goal to the scoring player’s own team, including own goals. That made an own goal look like it had been scored in favor of the wrong side. The correct information was hiding in a different endpoint entirely: the shot map. I had to cross-reference the timeline and the shot map to detect when a goal was an own goal and credit the score to the right side. At least I avoided showing the own-goal scorer on the top-scorers list.
Club shirt numbers, not national team numbers
As mentioned above, more than 900 players came through with the wrong shirt number. The API often returned the club number instead of the number worn for the national team at the World Cup, which produced visually wrong lineups and even players sharing a number within the same team. The fix was a correction layer built from public squad lists.
Mirrored shot map
The shot map showed up vertically mirrored compared to the real broadcast of the match. It only became obvious after comparing the chart against actual match footage. The coordinates themselves looked plausible, but the resulting picture didn’t match reality. Fixing it meant adjusting how the coordinates were interpreted before rendering the pitch.
Inflated xG in penalty shootouts
The source assigned a fixed, high xG value to every kick in a penalty shootout. That made a 0–0 match decided on penalties show up with an absurd total xG, as if it had been a match full of clear chances. I had to separate shootout kicks from in-game shots entirely. Shootout kicks can be analyzed in their own section, but they shouldn’t inflate the match’s regular xG stats.
Stack and infrastructure
The project was designed to be simple to operate, with a clear separation between data, API, and interface.
The current setup includes:
- a public web app for browsing and visualization;
- a read-only API;
- a database on a closed network;
- automated update processes;
- a preserved raw data layer;
- a recalculable ready layer;
- daily database backups;
- a proxy handling HTTPS.
The infrastructure runs on a small cloud machine. The database has no port exposed to the internet. The public surface is read-only. There’s no exposed admin screen.
The goal was to put together a foundation solid enough to host a real product, with security appropriate to its scope, automatic updates, and the ability to rebuild the data from scratch, without adding unnecessary complexity.
How I used AI during development
I used AI assistants as a development tool throughout the build. They helped speed up parts of the process: generating components, suggesting structures, reviewing text, proposing fixes, and turning ideas into a first pass of code. That said, it didn’t replace the core work of the project. My role was defining the product, breaking down the problems, reviewing the implementation, validating the data, adjusting the architecture, and fixing everything that didn’t match the reality of football.
That distinction showed up again and again. AI could produce a screen that technically worked, but it had no way of knowing, on its own, that an own goal was going to the wrong side, that a shot map was mirrored, or that a losing goalkeeper shouldn’t automatically be treated as the match’s best player because of a poorly weighted metric. Tools help a lot. But in a data project, the value still comes from knowing what to ask, what to be suspicious of, and how to validate the result.
What I took away from this
The main lesson from the project was simple: no data source is 100% trustworthy on the first read.
Even with a professional API, the most important bugs only surfaced when I compared the screen against what actually happened in the matches. Data engineering is also about understanding the domain well enough to be suspicious of the data, validate the transformation, and deliver a reading that actually makes sense. Moving data from one place to another isn’t the hard part. Critical judgment is.
Another important lesson was that performance needs to enter the architecture early. Calculating everything in real time seemed fine when there were only a few matches. But as the competition moved forward, it became clear the product needed to serve data that was already prepared.
Finally, the project reinforced something I consider central to analytics: numbers don’t speak for themselves. The interface has to turn statistics into a reading.
The product is live at worldcup.jvmello.dev. Questions, ideas, or want to talk football data? [email protected].