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.

Purchase this course to unlock the full lesson.

Sign up