# 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.

Source: https://www.zenrows.com/blog/scrape-2026-fifa-world-cup-data-python

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](https://github.com/ZenRows/scraping-world-cup-data).

## 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](https://www.fifa.com/en/tournaments/mens/worldcup/canadamexicousa2026/statistics/team-statistics), [Sofascore](https://www.sofascore.com/football/tournament/world/world-championship/16#id:58210), and [Wikipedia](https://en.wikipedia.org/wiki/List_of_FIFA_World_Cup_stadiums).

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](https://docs.zenrows.com/fetch/api-reference) 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.

```python
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](https://docs.zenrows.com/browser-sessions/get-started/playwright) to follow the same authenticated flow a normal browser follows, and read the API's JSON off the responses.

```python
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.

```python
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.

![Diagram of the normalization pipeline: raw data from each source passes through its own normalization layer and converges on a single JSON format](/blog/_img/fifa-normalization-pipeline.png)

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](https://github.com/ZenRows/scraping-world-cup-data).

Wikipedia needed BeautifulSoup, since its raw data arrived as HTML and the tables had to be located by their headers:

```python
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:

```python
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.

![Diagram of the visualization pipeline: normalized JSON loads into a Pandas DataFrame, derived metrics are calculated, and a chart type is selected per dataset](/blog/_img/fifa-visualization-pipeline.png)

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

![Scatter plot of expected goals against goals scored for the tournament's leading scorers, with a diagonal line where goals equal expected goals. Kylian Mbappé sits highest on both axes; Ousmane Dembélé sits far above the line on low expected goals](/blog/_img/fifa-xg-vs-goals-top-scorers.png)

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

![Bar chart of the top defensive performers by tackles, interceptions and clearances combined, with midfielders occupying the leading positions](/blog/_img/fifa-top-defensive-performers.png)

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

![Scatter plot of passing accuracy against key passes for the tournament's leading creators, with Michael Olise high on both axes](/blog/_img/fifa-passing-accuracy-vs-key-passes.png)

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

![Bar chart of the ten highest-rated goalkeepers at the 2026 World Cup, led by Orlando Gill](/blog/_img/fifa-top-goalkeepers-by-rating.png)

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

![Scatter plot of big chances created against assists for the tournament's leading playmakers, with Michael Olise furthest out on both](/blog/_img/fifa-big-chances-created-vs-assists.png)

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](https://www.foxsports.com/stories/soccer/michael-olise-ties-world-cup-assist-record-france-advances-semifinals). Creating chances does not always turn into goals, but his did.

#### Finding 6: Brian Brobbey made every big chance count

![Scatter plot of big chances missed against goal conversion percentage for the leading goalscorers, with Brian Brobbey at zero missed and the highest conversion rate](/blog/_img/fifa-big-chances-missed-vs-conversion.png)

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

![Scatter plot of share of shots taken inside the box against shot conversion rate for the most efficient teams, with Japan highest on conversion and lowest on inside-the-box share](/blog/_img/fifa-shot-location-vs-conversion.png)

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

![Scatter plot of direct defensive pressures against average ball recovery time across World Cup teams, with France, England and Argentina high on pressures and slow on recovery, and Türkiye low on pressures and fast on recovery](/blog/_img/fifa-pressures-vs-ball-recovery-time.png)

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

![Scatter plot of defensive pressures applied against total bookings for teams at the 2026 World Cup, with Argentina highest on both axes](/blog/_img/fifa-pressures-vs-bookings.png)

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

![Quadrant chart of goalkeeper saves against goals conceded by team, with most teams clustered in the high-saves, high-conceded quadrants](/blog/_img/fifa-goalkeeper-saves-vs-goals-conceded.png)

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

![Scatter plot of runs in behind against receptions in behind by team, with Spain far out on runs and England highest on receptions](/blog/_img/fifa-runs-vs-receptions-in-behind.png)

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

![Scatter plot of possession percentage against goal attempts by team, with a rising trend line, and France and Spain both high on goal attempts at different possession shares](/blog/_img/fifa-possession-vs-goal-attempts.png)

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

![Scatter plot of passing accuracy against defensive line break attempts by team, with Spain highest on line breaks at around 90% accuracy](/blog/_img/fifa-passing-accuracy-vs-line-breaks.png)

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

![Scatter plot of speed runs against off-ball passing options by team, with Spain highest on both and Colombia creating more options than Paraguay on fewer runs](/blog/_img/fifa-speed-runs-vs-off-ball-options.png)

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

![Grouped bar chart of host countries and stadiums per World Cup tournament, showing 2026 with three hosts and 16 stadiums against 17 in 1982 and 20 in 2002](/blog/_img/fifa-host-countries-and-stadiums.png)

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](https://github.com/ZenRows/scraping-world-cup-data). 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](/products/batch) orchestrates large scraping jobs, so you can fetch thousands of URLs per job rather than managing concurrency yourself.
