Expected goals (xG)
xG scores the quality of a chance rather than its outcome. This page explains what the number means, the six variants this API returns, how to read a player against their own xG, and the one place our player-identity guarantee does not hold. Figures measured against production on 2026-09-04.
What xG is
Every shot gets a probability of becoming a goal, estimated from the circumstances of the attempt — where it was taken from, the angle, which body part, what kind of pass created it, how the defence was set. A tap-in might score 0.76; a speculative effort from thirty yards, 0.02.
Add those probabilities up over a season and you get that player’s expected goals. It is the answer to how many goals would an average finisher have scored from these exact chances — a measure of the chances themselves, deliberately separated from whether the ball went in.
That separation is the entire point. A goal total tells you what happened once. xG tells you what the process was worth, and the process is the part that carries into next month.
How to read a player against their xG
The useful comparison is goals against xg, which this API returns precomputed as goals_above_xg. Real 2025 Premier League rows:
| Player | Goals | xG | goals_above_xg |
|---|---|---|---|
| Erling Haaland | 27 | 28.80 | −1.80 |
| Thiago | 22 | 24.69 | −2.69 |
| Ollie Watkins | 16 | 18.83 | −2.83 |
| Dominic Calvert-Lewin | 14 | 18.67 | −4.67 |
Note what the top of the table is not saying. Haaland led the league on xG with 28.80 and scored 27 — he did not overperform, he generated more good chances than anyone else. Volume is the signal there. Calvert-Lewin at −4.67 is the interesting one: the chances were worth nearly nineteen goals and returned fourteen.
A large gap is a flag, not a verdict. It can mean finishing that will regress toward the model, or a genuinely unusual finisher, or a model that cannot see something about how those chances arrived. xG is evidence about chance quality; it does not claim to be a complete account of a player.
The variants, and which are ours
Six xG-family numbers come back on every season row. One of them we compute; the rest are passed through from the model that produced them, unchanged.
| Field | What it means | Origin |
|---|---|---|
| xg | Expected goals. The summed scoring probability of every shot the player took. | passed through |
| npxg | Non-penalty expected goals. The same total with penalties removed — the honest measure of open-play threat. | passed through |
| xa | Expected assists. The summed scoring probability of the shots this player’s passes created. | passed through |
| xg_chain | Possession-level credit: the xG of possessions this player was involved in at any point. | passed through |
| xg_buildup | The same, excluding the shot and the final pass — build-up contribution with the finishers filtered out. | passed through |
| goals_above_xg | Actual goals minus xg. Positive means outscoring the chances taken; negative means the opposite. | computed here |
| goals, assists, shots, key_passes | Counting stats for the same season row, so the model output and the box score sit side by side. | passed through |
xg minus npxg is penalty xG, and it is worth checking before you rank anyone. Thiago’s 2025 line is xg 24.69 against npxg 17.84 — 6.85 expected goals of that total came from the spot. On non-penalty threat he is a different player from the one the xG column suggests.
Sample size decides whether this means anything
xG is a sum, so it is small and noisy early in a season and nothing that divides it is stable yet. Two matches into 2026, the Premier League xG leader had goals 3, xg 2.25 and a goals_above_xg of +0.75 across 180 minutes. That is not a finishing edge; it is two games.
Use min_minutes to hold the board to players with enough football behind them — around 900 minutes is a reasonable floor for a settled judgement. Early in a season that filter correctly returns an empty list, which is the honest answer rather than a ranking of noise.
xG rows key on player name, not player id
This is the one place in the API where our player-identity guarantee does not hold. Everywhere else, a player has one id across every endpoint. The xG leaderboard returns player_name and no player_id, so joining it to the rest of the API means matching on a string.
The reason is coverage, and the number is worth publishing: of the 34,340 league-attributed season rows, 3,183 — 9.3% — carry a bridged player id. Returning the field would mean serving null to nine callers in ten while implying a join that does not work, so we return the name and document the limit instead.
Bridge coverage is very uneven by league, which matters if you are planning around it:
| League | league id | Season rows | Carrying a player id |
|---|---|---|---|
| Premier League | epl | 6,788 | 18.1% |
| La Liga | laliga | 7,182 | 12.4% |
| Serie A | serie-a | 7,257 | 6.7% |
| Ligue 1 | ligue-1 | 7,015 | 4.5% |
| Bundesliga | bundesliga | 6,098 | 4.3% |
When you join on name, expect spelling to differ. The xG board writes “Erling Haaland” where the top-scorers endpoint writes “E. Haaland”. Normalise both sides, and accept that some rows will not match rather than forcing them. The entity bridge that would close this is scoped work, not a shipped feature — until it lands, name matching is the supported path and this page will say so.
What is covered
The big five European leagues, seasons 2014 through 2026 — 34,340 league-attributed player-seasons over 11,398 distinct players, refreshed daily. That is the same span our big-five match data covers, so an xG row and the fixtures around it reach equally far back; you are not joining a deep dataset to a shallow one.
It is not all 63 soccer leagues we carry matches for. xG exists for these five competitions and the endpoint answers for these five league ids only.
A further 5,486 rows in the underlying table carry no league id at all — every one of them Russian Premier League, frozen since 2026-05-30 and no longer refreshing. Rows that cannot be attributed to a league are excluded from league leaderboards rather than silently bucketed into one, so the totals above are the servable set rather than the table count.
Getting an xG leaderboard
curl -H "X-Api-Key: $BBS_API_KEY" \
"https://api.bigballsdata.com/v1/leagues/{league_id}/xg-leaders?stat=xg&season=2025&min_minutes=900"Substitute one of the five league_id values from the table above — for example epl. stat accepts xg, npxg, xa, goals, assists, shots and key_passes, and orders the board by that column — every row still carries all of them. season defaults to the current one, min_minutes to 0 and limit to 25. An unrecognised stat is rejected rather than silently ignored, so a typo returns a 400 instead of a leaderboard sorted by something you did not ask for.
The response carries an updated_at for the underlying data — read it rather than assuming freshness. Full parameters and response shape are in the endpoint reference, and there is a worked build in the soccer xG tutorial.
Limits worth knowing
- Season aggregates, not per-shot data. One row per player per season. There is no shot-level xG endpoint, so you cannot rebuild these totals or filter them by match, opponent or date.
- No player id on the row — 9.3% bridged, as above. Join on name.
- A transferred player’s team is comma-joined. The season row spans both clubs, so
teamcan read"Bournemouth,Manchester City". We pass that through rather than picking a side we cannot verify — split on the comma before rendering a badge. - Big five only, and the Russian Premier League rows are frozen and excluded.
- xG models differ. These numbers come from one model; another provider's xG for the same shot will not match exactly. Compare players within this dataset rather than across sources.