Hockey Age Curves and Peak Performance
Beginner
10 min read
20 views
Nov 27, 2025
When Do Hockey Players Peak?
Understanding age curves—how player performance changes with age—is crucial for contract negotiations, roster planning, and long-term team building. Different positions peak at different ages, and modern analytics can quantify these patterns precisely.
Typical Peak Ages by Position
- Forwards: Age 24-27 (offensive peak around 25)
- Defensemen: Age 26-29 (longer prime window)
- Goalies: Age 28-31 (latest peak)
- Decline Phase: Begins around age 30-32 for most players
Python: Calculate Age Curves
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from scipy.interpolate import UnivariateSpline
# Load historical player data
player_careers = pd.read_csv('nhl_player_career_stats.csv')
# Calculate age curves by position
positions = ['C', 'LW', 'RW', 'D']
def calculate_age_curve(position_data):
"""Calculate average performance by age"""
age_performance = position_data.groupby('age').agg({
'points_per_game': 'mean',
'goals_per_game': 'mean',
'assists_per_game': 'mean',
'plus_minus_per_game': 'mean'
}).reset_index()
return age_performance
age_curves = {}
for position in positions:
pos_data = player_careers[player_careers['position'] == position]
age_curves[position] = calculate_age_curve(pos_data)
# Identify peak age for each position
print("=== Peak Performance Age by Position ===")
for position, curve_data in age_curves.items():
peak_age = curve_data.loc[
curve_data['points_per_game'].idxmax(), 'age'
]
peak_ppg = curve_data['points_per_game'].max()
print(f"{position}: Age {peak_age:.0f} ({peak_ppg:.3f} PPG)")
# Calculate aging factors (year-over-year decline)
def calculate_aging_factor(position_data):
"""Calculate expected performance change by age"""
aging_factors = []
for age in range(20, 38):
current_age = position_data[position_data['age'] == age]
next_age = position_data[position_data['age'] == age + 1]
if len(current_age) > 0 and len(next_age) > 0:
current_ppg = current_age['points_per_game'].mean()
next_ppg = next_age['points_per_game'].mean()
factor = (next_ppg / current_ppg) - 1
aging_factors.append({
'age': age,
'aging_factor': factor
})
return pd.DataFrame(aging_factors)
print("\n=== Aging Factors (Year-over-Year Change) ===")
for position in positions:
pos_data = player_careers[player_careers['position'] == position]
factors = calculate_aging_factor(pos_data)
print(f"\n{position}:")
print(factors)
# Project future performance for a player
def project_performance(current_age, current_ppg, position, years_ahead=5):
"""Project future performance based on age curve"""
pos_data = player_careers[player_careers['position'] == position]
factors = calculate_aging_factor(pos_data)
projections = [current_ppg]
for year in range(1, years_ahead + 1):
age = current_age + year
factor_row = factors[factors['age'] == age]
if len(factor_row) > 0:
aging_factor = factor_row['aging_factor'].values[0]
next_ppg = projections[-1] * (1 + aging_factor)
projections.append(max(0, next_ppg)) # Can't be negative
else:
# Default decline for ages beyond data
projections.append(projections[-1] * 0.95)
return projections
# Example: Project a 27-year-old center's performance
player_projection = project_performance(
current_age=27,
current_ppg=0.95,
position='C',
years_ahead=5
)
print("\n=== Player Projection (27-year-old Center, 0.95 PPG) ===")
for i, ppg in enumerate(player_projection):
age = 27 + i
print(f"Age {age}: {ppg:.3f} PPG")
# Contract value analysis based on age
def calculate_expected_war(age, current_ppg, position, contract_years):
"""Calculate expected Wins Above Replacement over contract"""
projections = project_performance(age, current_ppg, position, contract_years)
# Rough WAR estimation: 1 PPG ≈ 3 WAR (simplified)
war_values = [ppg * 3 for ppg in projections[1:]] # Exclude current year
total_war = sum(war_values)
return total_war, war_values
# Example contract analysis
age, ppg, position, years = 28, 0.85, 'D', 6
total_war, war_by_year = calculate_expected_war(age, ppg, position, years)
print(f"\n=== Contract Analysis: {years}-year deal for {age}-year-old {position} ===")
print(f"Current Production: {ppg:.3f} PPG")
print(f"Expected Total WAR: {total_war:.1f}")
print("WAR by Contract Year:", [f"{w:.2f}" for w in war_by_year])
R: Age Curve Visualization
library(tidyverse)
library(splines)
library(scales)
# Load career statistics
player_careers <- read_csv("nhl_player_career_stats.csv")
# Calculate age curves by position
positions <- c("C", "LW", "RW", "D")
age_curves <- positions %>%
map_dfr(function(pos) {
player_careers %>%
filter(position == pos) %>%
group_by(age) %>%
summarise(
avg_ppg = mean(points_per_game, na.rm = TRUE),
avg_goals = mean(goals_per_game, na.rm = TRUE),
avg_assists = mean(assists_per_game, na.rm = TRUE),
n_seasons = n(),
.groups = "drop"
) %>%
mutate(position = pos)
})
# Identify peak age for each position
peak_ages <- age_curves %>%
group_by(position) %>%
slice_max(avg_ppg, n = 1) %>%
select(position, peak_age = age, peak_ppg = avg_ppg)
cat("=== Peak Performance Age by Position ===\n")
print(peak_ages)
# Calculate aging factors
calculate_aging_factors <- function(position_data) {
position_data %>%
arrange(age) %>%
mutate(
next_ppg = lead(avg_ppg),
aging_factor = (next_ppg / avg_ppg) - 1
) %>%
filter(!is.na(aging_factor))
}
aging_factors <- age_curves %>%
group_by(position) %>%
group_modify(~calculate_aging_factors(.x)) %>%
ungroup()
cat("\n=== Aging Factors by Position ===\n")
print(aging_factors %>%
filter(age >= 20, age <= 35) %>%
select(position, age, avg_ppg, aging_factor))
# Function to project future performance
project_performance <- function(current_age, current_ppg, position,
years_ahead = 5, factors_df) {
projections <- numeric(years_ahead + 1)
projections[1] <- current_ppg
for (i in 1:years_ahead) {
age <- current_age + i
factor <- factors_df %>%
filter(position == !!position, age == !!age) %>%
pull(aging_factor)
if (length(factor) > 0) {
projections[i + 1] <- projections[i] * (1 + factor)
} else {
# Default decline
projections[i + 1] <- projections[i] * 0.95
}
projections[i + 1] <- max(0, projections[i + 1])
}
return(projections)
}
# Example projection
player_projection <- tibble(
age = 27:32,
projected_ppg = project_performance(27, 0.95, "C", 5, aging_factors)
)
cat("\n=== Player Projection (27-year-old Center, 0.95 PPG) ===\n")
print(player_projection)
# Visualize age curves
ggplot(age_curves %>% filter(age >= 18, age <= 40),
aes(x = age, y = avg_ppg, color = position)) +
geom_line(size = 1.2) +
geom_point(size = 2) +
geom_vline(data = peak_ages, aes(xintercept = peak_age, color = position),
linetype = "dashed", alpha = 0.5) +
scale_y_continuous(labels = number_format(accuracy = 0.01)) +
labs(title = "Hockey Age Curves by Position",
subtitle = "Average points per game across career ages",
x = "Age", y = "Points Per Game",
color = "Position") +
theme_minimal() +
theme(legend.position = "bottom")
# Contract value analysis
calculate_expected_war <- function(age, current_ppg, position,
contract_years, factors_df) {
projections <- project_performance(age, current_ppg, position,
contract_years, factors_df)
# PPG to WAR conversion (simplified: 1 PPG ≈ 3 WAR)
war_values <- projections[-1] * 3
tibble(
contract_year = 1:contract_years,
age = (age + 1):(age + contract_years),
projected_ppg = projections[-1],
projected_war = war_values
)
}
# Example contract analysis
contract_analysis <- calculate_expected_war(28, 0.85, "D", 6, aging_factors)
cat("\n=== Contract Analysis: 6-year deal for 28-year-old D ===\n")
print(contract_analysis)
cat(sprintf("\nTotal Expected WAR: %.1f\n", sum(contract_analysis$projected_war)))
Application to Contract Decisions
Age curves are critical for long-term contract negotiations. A 6-year deal for a 28-year-old means paying for ages 29-34, which typically includes both prime years and decline years. Teams must weigh current performance against projected aging when determining contract length and value.
Contract Considerations
- Long-term deals for players 30+ carry significant risk of decline
- Defensemen age more gracefully than forwards
- Power play specialists may see sharper declines than even-strength players
- Individual variation exists—some players defy typical age curves
Discussion
Have questions or feedback? Join our community discussion on
Discord or
GitHub Discussions.
Table of Contents
Related Topics
Quick Actions