What xG actually measures
Every shot happens in a context: distance to goal, angle, body part, pressure, whether it came from a set piece, whether the keeper was set. An expected goals model turns those features into a probability — the chance that an average player scores from that exact situation.
It is not a prediction of the match. It is a measure of chance quality: if a team accumulates 2.3 xG from twenty shots, the model is saying "that pile of chances, repeated forever, is worth about 2.3 goals".
What it is good for
- Separating process from results. Over five matches, goals are noisy; xG stabilises much faster. A team that keeps creating 1.8 xG and scoring 0.6 is either unlucky or has a finishing problem — both worth investigating.
- Comparing shot profiles. Two teams can both take 14 shots: one from inside the box with a central angle, the other from 25 metres. xG makes that difference visible.
- Talking about defence without the ball. Low xG conceded is usually a better signal of a solid structure than "few shots conceded".
Where it breaks
- Different providers, different numbers. StatsBomb, Opta and Understat all publish xG, and they disagree — sometimes by 15–20% on the same shot. Different data collection, different features, different training sets.
- It ignores the player. A model gives the same value to the same shot regardless of who is shooting. Post-shot models (which add where in the goal the ball ended up) correct part of this, but pre-shot xG cannot.
- Single matches prove nothing. A 0.4 xG difference in one game is well inside the noise. xG is a tool for samples, not for headlines.
How to use it responsibly
- Treat it as a baseline, not a verdict. The interesting question is not "who deserved to win" but "what does the gap tell us".
- Always pair it with volume. 0.9 xG from 5 shots and 0.9 xG from 20 shots are different stories.
- Look at the trend, not the value. Rolling five-match averages beat single-match numbers every time.
Build your own in 20 lines
The core of a simple xG model is a logistic regression over a handful of features: distance, angle, and whether the shot was a header or a penalty.
import pandas as pd
from sklearn.linear_model import LogisticRegression
def distance(x, y):
return ((120 - x) ** 2 + (40 - y) ** 2) ** 0.5
def angle(x, y):
left = abs(y - 36)
right = abs(y - 44)
import math
return math.atan2(7.32 * (120 - x), (120 - x) ** 2 + (36 - y) * (44 - y))
shots["distance"] = [distance(x, y) for x, y in zip(shots.x, shots.y)]
shots["angle"] = [angle(x, y) for x, y in zip(shots.x, shots.y)]
model = LogisticRegression()
model.fit(shots[["distance", "angle", "is_header"]], shots["goal"])
shots["xG"] = model.predict_proba(shots[["distance", "angle", "is_header"]])[:, 1]
That model will not beat a provider's, but it will teach you more about xG than any dashboard: every feature you add, every bias you introduce, becomes visible.
TL;DR
xG is a quality-of-chance metric that stabilises fast, compares like with like, and lies confidently in small samples. Use it as a lens, not a judge.