Player Performance Metrics and Comparisons
Player Performance Metrics and Comparisons
Raw counting stats — goals, assists, yellow cards — tell only part of the story. A striker who scored 10 goals in 1,800 minutes is more efficient than one who scored 12 in 3,200 minutes. A midfielder who contributed to 25% of their team's goals is more valuable than raw numbers suggest. This lesson shows how to compute the metrics that actually matter: rate stats, percentile rankings, and contribution rates.
Dataset Reminder
-- players: id, name, team_id, position, nationality, age
-- player_stats: player_id, match_id, goals, assists, minutes_played,
-- yellow_cards, red_cards, shots, passes
-- matches: id, home_team_id, away_team_id, match_date, home_goals, away_goals,
-- season, competition
-- teams: id, name, league
Why Per-90 Stats Matter
The standard in modern football analytics is to express attacking stats per 90 minutes played rather than as raw season totals. This eliminates the bias toward players who played more games. A player with 8 goals in 720 minutes (exactly 8 games) has a goals/90 of 1.0 — the same as a player with 15 goals in 1,350 minutes, even though the second player has far more total goals.
The formula is always: (raw_stat / total_minutes_played) * 90.
The query below aggregates each player's season stats, computes per-90 rates, and then ranks players within their position group using RANK() OVER (PARTITION BY position ...). This means strikers compete against strikers, midfielders against midfielders.
WITH player_season AS (
SELECT
ps.player_id,
SUM(ps.goals) AS goals,
SUM(ps.assists) AS assists,
SUM(ps.minutes_played) AS minutes,
SUM(ps.shots) AS shots,
SUM(ps.yellow_cards) AS yellows,
COUNT(DISTINCT ps.match_id) AS games
FROM player_stats ps
JOIN matches m ON m.match_id = ps.match_id
WHERE m.competition = 'league'
GROUP BY ps.player_id
HAVING SUM(ps.minutes_played) >= 450 -- minimum half a season
),
per90 AS (
SELECT
player_id,
goals, assists, minutes, games,
ROUND(goals::numeric / minutes * 90, 2) AS goals_per90,
ROUND(assists::numeric / minutes * 90, 2) AS assists_per90,
ROUND((goals + assists)::numeric / minutes * 90, 2) AS goal_contributions_per90,
ROUND(shots::numeric / minutes * 90, 2) AS shots_per90,
ROUND(goals::numeric / NULLIF(shots, 0) * 100, 1) AS conversion_rate_pct
FROM player_season
)
SELECT
p.name, p.position, t.name AS team,
per90.goals, per90.assists, per90.minutes,
per90.goals_per90,
per90.assists_per90,
per90.goal_contributions_per90,
per90.conversion_rate_pct,
RANK() OVER (PARTITION BY p.position ORDER BY per90.goals_per90 DESC) AS goals_rank_in_position
FROM per90
JOIN players p ON p.player_id = per90.player_id
JOIN teams t ON t.team_id = p.team_id
ORDER BY p.position, goals_rank_in_position;
What This Returns
Each row is one player's season summary. Key columns to focus on:
| Column | What it tells you |
|---|---|
goals_per90 |
Scoring rate — compare across positions and teams |
goal_contributions_per90 |
Combined attacking output (goals + assists per 90) |
conversion_rate_pct |
Shot efficiency — high means clinical, low means wasteful |
goals_rank_in_position |
Where this player ranks among all players at their position |
The HAVING SUM(ps.minutes_played) >= 450 filter removes players with too few minutes for their rates to be meaningful. Without this, a substitute who scored once in 10 minutes would show a goals_per90 of 9.0 — clearly misleading.