Scraping 2026 FIFA World Cup data with Python and Zenrows
Three sources, three access patterns: a JSON endpoint behind JavaScript, an API behind a per-session JWT, and server-rendered HTML. Plus 15 findings from the result.
Ousmane Dembélé finished with six goals from an expected goals figure of just over two. FIFA gave the Golden Glove to Unai Simón, who ranks outside the top ten goalkeepers in our dataset. Japan recorded the highest shot conversion rate in the tournament despite taking the lowest share of its shots from inside the penalty area.
We found all of that by building a Python pipeline over Sofascore, FIFA, and Wikipedia. Getting the data was the hard part. It sat behind dynamic rendering, access controls, and three different delivery mechanisms that standard Python scraping tools cannot reach consistently.
This article covers how we retrieved each source, where Zenrows handled what plain HTTP requests could not, and the 15 findings that came out of the result. The full pipeline is in the GitHub repository.
Designing the data pipeline
The point of the pipeline was to work through the problems data engineers actually hit on protected sites. We built it on three sources, all retrieved on 21 July 2026: FIFA, Sofascore, and Wikipedia.
No single site exposed every statistic we wanted, so the pipeline combines FIFA for team-level metrics, Sofascore for player-level statistics, and Wikipedia for tournament metadata. Each exposed its data differently, so each needed a different extraction strategy.
Sofascore: a JSON endpoint behind JavaScript
Sofascore gave us individual player stats: ratings, clean sheets, saves, goals, expected goals. Watching the network tab while changing the statistic on the page showed the front end was not rendering HTML server-side. It was calling an API, and JavaScript was painting the result into the browser.
requests alone would have returned the application shell. We used Zenrows Fetch to pull JSON straight from the endpoint, switching group and order to get different statistics. Because the endpoint returns JSON rather than HTML, there was no parsing step at all.
import os
import time
from urllib.parse import urlencode
import requests
ZENROWS_API_KEY = os.getenv("ZENROWS_API_KEY")
class SofascoreClient:
BASE_URL = "https://www.sofascore.com/api/v1"
# generic GET helper
def _get(self, endpoint: str, params: dict | None = None):
base_url = f"{self.BASE_URL}/{endpoint.lstrip('/')}"
target_url = f"{base_url}?{urlencode(params)}" if params else base_url
retries = 3
for attempt in range(retries):
try:
response = requests.get(
"https://api.zenrows.com/v1/",
params={
"apikey": ZENROWS_API_KEY,
"url": target_url,
"mode": "auto",
},
timeout=120,
)
response.raise_for_status()
return response.json()
except (
requests.exceptions.ReadTimeout,
requests.exceptions.ConnectionError,
requests.exceptions.HTTPError,
) as e:
print(f"Attempt {attempt + 1}/{retries} failed: {e}")
if attempt == retries - 1:
raise
wait = 2 ** attempt
print(f"Retrying in {wait} seconds...\n")
time.sleep(wait)
# statistics endpoint
def fetch_statistics(
self,
group,
order,
page=1,
limit=20,
accumulation="total",
filters=None,
fields=None,
):
endpoint = (
f"unique-tournament/{TOURNAMENT_ID}"
f"/season/{SEASON_ID}"
"/statistics"
)
params = {
"group": group,
"order": order,
"page": page,
"limit": limit,
"accumulation": accumulation,
}
if filters:
params["filters"] = filters
if fields:
params["fields"] = fields
return self._get(endpoint, params=params)
mode=auto handled the endpoint's access requirements, JavaScript rendering and premium proxies included, without us specifying which it needed.
FIFA: an API behind a per-session token
On FIFA we wanted team-level insight: attacking, defending, goalkeeping, discipline. The challenge looked similar to Sofascore's but the access pattern was different. The data came from a backend API, and that API was called with authentication.
Every request carried an authorization header with a JWT bearer token issued for the current browsing session. Every visitor gets a temporary token, and the front end attaches it to subsequent calls. The endpoint could not be queried as a public URL, so Fetch alone was not enough.
We used Zenrows Browser Sessions with Playwright to follow the same authenticated flow a normal browser follows, and read the API's JSON off the responses.
import os
from playwright.async_api import async_playwright
class FIFAClient:
def __init__(self):
self.api_key = os.getenv("ZENROWS_API_KEY")
self.connection_url = f"wss://browser.zenrows.com?apikey={self.api_key}"
self.responses = {}
async def _handle_response(self, response):
if "gameday-prod.fifa.mangodev.co.uk" not in response.url:
return
self.responses[response.url] = await response.json()
async def fetch_all(self):
async with async_playwright() as p:
browser = await p.chromium.connect_over_cdp(self.connection_url)
context = browser.contexts[0]
page = await context.new_page()
page.on("response", self._handle_response)
await page.goto(self.URL)
await self._remove_cookie_banner(page)
for tab in self.TABS:
await self._click_tab(page, tab)
self._save_files()
Wikipedia: server-rendered HTML
Wikipedia gave us tournament metadata: host countries, stadiums, venues. Unlike the other two it is server-rendered, so everything we needed was in the HTML the server returned. Fetch retrieved it with no extra capabilities.
import os
import requests
class WikipediaClient:
URL = "https://en.wikipedia.org/wiki/List_of_FIFA_World_Cup_stadiums"
def __init__(self):
self.api_key = os.getenv("ZENROWS_API_KEY")
def fetch(self):
response = requests.get(
"https://api.zenrows.com/v1/",
params={
"apikey": self.api_key,
"url": self.URL,
},
timeout=120,
)
response.raise_for_status()
html = response.text
os.makedirs("data/raw/wikipedia", exist_ok=True)
with open("data/raw/wikipedia/page.html", "w", encoding="utf-8") as f:
f.write(html)
print("Saved page.html")
Normalizing the data
Three sources fetched three ways produce three shapes of data. In one pipeline that has to become one format.

Whichever method fetched it, everything followed the same high-level workflow, with a normalization layer per source turning raw output into JSON. The normalized dataset is in data/normalized in the repository.
Wikipedia needed BeautifulSoup, since its raw data arrived as HTML and the tables had to be located by their headers:
import re
from bs4 import BeautifulSoup
def normalize(self):
with open(self.INPUT_FILE, encoding="utf-8") as f:
soup = BeautifulSoup(f, "html.parser")
target_table = None
# locate the stadium summary table by its headers
for table in soup.find_all("table", class_="wikitable"):
headers = [th.get_text(" ", strip=True) for th in table.find_all("th")]
if (
"Year" in headers
and "Host" in headers
and "Cities" in headers
and "Stadiums" in headers
):
target_table = table
break
if target_table is None:
raise ValueError("Could not locate the World Cup stadium table.")
records = []
tbody = target_table.find("tbody")
if tbody is None:
raise ValueError("Table does not contain a tbody element.")
for row in tbody.find_all("tr")[1:]:
cells = row.find_all(["th", "td"])
if len(cells) < 4:
continue
year_match = re.search(r"\d{4}", cells[0].get_text())
if year_match is None:
continue
records.append(
{
"year": int(year_match.group()),
"hosts": [t.strip() for t in cells[1].stripped_strings if t.strip()],
"cities": int(re.search(r"\d+", cells[2].get_text()).group()),
"stadiums": int(re.search(r"\d+", cells[3].get_text()).group()),
}
)
Sofascore returned duplicate players across statistic groups, so we keyed on the site's own player IDs to deduplicate:
import json
import os
class SofascoreNormalizer:
INPUT_DIR = "data/raw/sofascore"
OUTPUT_DIR = "data/normalized/sofascore"
DATASETS = ["summary", "attack", "defence", "passing", "goalkeeper"]
def normalize_player(self, record):
player = record.get("player", {})
team = record.get("team", {})
stats = {
key: value
for key, value in record.items()
if key not in {"player", "team", "rating"}
}
return {
"player_id": player.get("id"),
"player": player.get("name"),
"player_slug": player.get("slug"),
"team_id": team.get("id"),
"team": team.get("name"),
"team_slug": team.get("slug"),
"rating": record.get("rating"),
"stats": stats,
}
def normalize_dataset(self, dataset):
input_file = os.path.join(self.INPUT_DIR, f"{dataset}.json")
with open(input_file, encoding="utf-8") as f:
data = json.load(f)
# deduplicate players using player_id
players = {}
for record in data:
normalized_player = self.normalize_player(record)
player_id = normalized_player["player_id"]
if player_id is None:
continue
# keep only one record per player
players[player_id] = normalized_player
return list(players.values())
Visualizing the dataset
Rather than passing the normalized JSON straight into plotly or Matplotlib, we loaded each dataset into a Pandas DataFrame, which gave one consistent interface for exploring, filtering, and transforming.

That let us verify types and calculate derived statistics before charting anything. We used Pandas to work out inside- and outside-the-box shot percentages from raw shot counts, expected goal attempts based on possession, and residuals showing teams creating more or fewer chances than expected.
Depending on the dataset we used scatter plots, box plots, bar charts with regression lines, and line charts. The charts came out of exploring the normalized data, engineering extra metrics where needed, and picking the visualization that carried the finding.
15 findings from the 2026 FIFA World Cup
The findings fall into three groups: player performances, team insights, and tournament trends.
Player performances
Finding 1: Ousmane Dembélé was the most efficient of the top strikers

Kylian Mbappé finished with 10 goals, backed by the highest expected goals figure in the tournament. Ousmane Dembélé had considerably fewer expected goals than the other top scorers and still finished with six, close to three times his xG. He converted lower-quality chances more efficiently than anyone above him.
Finding 2: Midfielders dominated the defensive actions

You would expect centre-backs to top the defensive statistics. Instead the highest-rated defensive performers included midfielders: Jude Bellingham, Rodri, Pedro Vite, and Aurélien Tchouaméni took the top four places once tackles, interceptions, and clearances were counted together. That is a measure of how much work they did breaking attacks up before they reached the back line.
Finding 3: Michael Olise's passing accuracy did not cost him creativity

Creative players usually trade passing accuracy for risk, because the passes that break defensive lines are the ones most likely to fail. Olise held one of the tournament's highest passing accuracies and still finished second for key passes.
Finding 4: The Golden Glove winner was not in our top ten goalkeepers

FIFA gave Unai Simón the Golden Glove as the tournament's best goalkeeper. He does not appear among the ten highest-rated goalkeepers in our dataset. Orlando Gill tops our ratings despite Paraguay going out in the Round of 16, and he also led all goalkeepers in total saves.
Finding 5: Michael Olise was the most productive playmaker

Olise created more big chances than any other player and finished with seven assists, a new World Cup record for most assists in a single tournament. Creating chances does not always turn into goals, but his did.
Finding 6: Brian Brobbey made every big chance count

The Netherlands striker was not among the tournament's biggest names, but Brobbey recorded a 75% goal conversion rate without missing a single big chance. In a tournament where most leading forwards needed several clear-cut chances per goal, that stands out.
Team insights
Finding 7: Japan recorded the highest shot conversion rate

Japan was not counted among the tournament's attacking heavyweights and still recorded the highest shot conversion rate, ahead of England, the Netherlands, the USA, and Norway. Those teams took more than 70% of their shots from inside the penalty area. Only 50% of Japan's shots came from inside the box, the lowest share of the top ten.
Finding 8: More pressing did not mean quicker ball recovery

France, England, and Argentina recorded the most direct defensive pressures in the tournament and also some of the longest ball recovery times. Türkiye applied far fewer and recovered the ball among the fastest. Pressing volume on its own did not produce quicker recoveries.
Finding 9: Argentina paired the most aggressive pressing with the highest disciplinary cost

Argentina applied the most defensive pressure and collected the most bookings. More surprising: despite leading the tournament in bookings, they reached the final without a single VAR intervention against them.
Finding 10: Goalkeepers faced sustained pressure throughout

More than eighteen teams recorded 15 or more goalkeeper saves, and more than fifteen conceded eight or more goals. Few combined low save totals with few goals conceded. Goalkeepers were busy for most of the tournament, and teams kept conceding anyway.
Finding 11: England converted runs in behind into receptions most effectively

Spain sat in a class of its own with more than 1,100 runs in behind. England completed more receptions while making 338 fewer runs, which is the sign of a team consistently capitalizing on the runs it does make behind opposition lines.
Finding 12: France and Spain played opposite styles for similar output

Attacking output came from different playing styles. Most teams clustered between 45% and 55% possession, with only about five above 55%, and plenty inside that range still produced more than 50 goal attempts. France and Spain make the point: France generated almost as many goal attempts as Spain while recording below 55% possession.
Finding 13: Spain broke lines without losing passing accuracy

The usual assumption is that attempting more line-breaking passes costs you accuracy. Spain recorded the most defensive line-break attempts while holding around 90% passing accuracy. Brazil, Portugal, and Argentina posted similar accuracy on more than 60 fewer line-break attempts.
Finding 14: Spain's movement created more passing options than any other team

Teams with more speed runs generally created more off-ball passing options, and Spain stood apart on both, with the most speed runs and over 4,000 off-ball passing options. Colombia is the interesting outlier: more off-ball options than Paraguay on fewer speed runs.
Tournament trends
Finding 15: The first three-country World Cup used fewer stadiums than 1982

The 2026 World Cup was the first hosted by three countries. It used 16 stadiums, fewer than the 17 Spain used alone in 1982, and fewer than the 20 Japan and South Korea shared in 2002.
Wrapping up
Building a pipeline for a reliable World Cup dataset took more than sending HTTP requests. The three sources delivered their data three ways: server-rendered HTML, a JavaScript-rendered interface over a JSON endpoint, and an authenticated backend API behind a per-session token.
Fetch covered the first two. Browser Sessions covered the third, because following an authenticated request flow needs a real browser. From there it was normalization into a consistent format and visualizations over the result.
The complete source is in the GitHub repository. From here you could extend it with match-level event data, or feed the normalized datasets into dashboards or an AI application. As collection grows, Zenrows Batch orchestrates large scraping jobs, so you can fetch thousands of URLs per job rather than managing concurrency yourself.