OPS+ Explained
Intermediate
10 min read
1 views
Nov 26, 2025
OPS+ Explained: Normalized Offensive Performance
OPS+ (On-base Plus Slugging Plus) adjusts a player's OPS for league and park factors, making it one of the most effective tools for comparing hitters across different eras, ballparks, and league environments. A player with an OPS+ of 150 is 50% better than league average, while OPS+ of 80 is 20% below average.
The OPS+ Formula
OPS+ = 100 × (OBP/lgOBP + SLG/lgSLG - 1) × Park Factor
Where:
- OBP = Player's on-base percentage
- lgOBP = League average OBP
- SLG = Player's slugging percentage
- lgSLG = League average SLG
- Park Factor = Adjustment for home ballpark
Why 100 is League Average
- Intuitive interpretation: Above 100 = above average, below 100 = below average
- Percentage-based: OPS+ of 130 means 30% better than average
- Era-independent: Compare players from any era on equal footing
- League-adjusted: Accounts for AL/NL differences
OPS+ Benchmarks
| OPS+ Range | Classification | Description |
|---|---|---|
| 160+ | Elite | MVP-caliber, generational talent |
| 140-159 | Excellent | Perennial All-Star, franchise cornerstone |
| 120-139 | Above Average | Quality regular, All-Star potential |
| 90-119 | Average | Solid contributor |
| 80-89 | Below Average | Backup quality, defensive specialist |
| <80 | Poor | Significant offensive liability |
Historical OPS+ Leaders
| Player | Career OPS+ |
|---|---|
| Babe Ruth | 206 |
| Ted Williams | 190 |
| Barry Bonds | 182 |
| Lou Gehrig | 179 |
| Mike Trout | 176 |
| Rogers Hornsby | 175 |
Python Implementation
from pybaseball import batting_stats
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
# Get batting statistics
batting = batting_stats(2024, qual=502)
# Top 15 by OPS+
print("2024 OPS+ Leaders (Qualified):")
leaders = batting.nlargest(15, 'OPS+')[['Name', 'Team', 'PA', 'OBP', 'SLG', 'OPS', 'OPS+']]
print(leaders.to_string(index=False))
# Summary statistics
print(f"\nOPS+ Summary:")
print(f"Mean: {batting['OPS+'].mean():.1f}")
print(f"Median: {batting['OPS+'].median():.1f}")
print(f"Std Dev: {batting['OPS+'].std():.1f}")
# Categorize players
def categorize_ops(ops):
if ops >= 160: return 'Elite (160+)'
elif ops >= 140: return 'Excellent (140-159)'
elif ops >= 120: return 'Above Avg (120-139)'
elif ops >= 90: return 'Average (90-119)'
else: return 'Below Avg (<90)'
batting['OPS+_Category'] = batting['OPS+'].apply(categorize_ops)
print("\nPlayer Distribution:")
print(batting['OPS+_Category'].value_counts())
# Visualization
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
# Distribution
axes[0].hist(batting['OPS+'], bins=25, edgecolor='black', alpha=0.7)
axes[0].axvline(100, color='red', linestyle='--', label='League Avg')
axes[0].set_xlabel('OPS+')
axes[0].set_ylabel('Count')
axes[0].set_title('OPS+ Distribution (2024)')
axes[0].legend()
# OPS+ vs AVG comparison
axes[1].scatter(batting['AVG'], batting['OPS+'], alpha=0.6)
axes[1].axhline(100, color='red', linestyle='--', alpha=0.5)
axes[1].set_xlabel('Batting Average')
axes[1].set_ylabel('OPS+')
axes[1].set_title('OPS+ vs Batting Average')
plt.tight_layout()
plt.savefig('ops_plus_analysis.png', dpi=300)
plt.show()
R Implementation
library(baseballr)
library(dplyr)
library(ggplot2)
# Get batting data
batting <- fg_batter_leaders(2024, 2024, qual = 502)
# Top performers
cat("2024 OPS+ Leaders:\n")
leaders <- batting %>%
arrange(desc(wRC_plus)) %>%
select(Name, Team, PA, OBP, SLG, OPS, wRC_plus) %>%
head(15)
print(leaders)
# Summary
cat(sprintf("\nMean OPS+: %.1f\n", mean(batting$wRC_plus, na.rm = TRUE)))
cat(sprintf("Median OPS+: %.1f\n", median(batting$wRC_plus, na.rm = TRUE)))
# Categorize
batting <- batting %>%
mutate(
OPS_category = case_when(
wRC_plus >= 160 ~ "Elite (160+)",
wRC_plus >= 140 ~ "Excellent (140-159)",
wRC_plus >= 120 ~ "Above Avg (120-139)",
wRC_plus >= 90 ~ "Average (90-119)",
TRUE ~ "Below Avg (<90)"
)
)
# Distribution visualization
ggplot(batting, aes(x = wRC_plus)) +
geom_histogram(binwidth = 5, fill = "steelblue", color = "black", alpha = 0.7) +
geom_vline(xintercept = 100, color = "red", linetype = "dashed", size = 1) +
labs(
title = "OPS+ Distribution (2024 Season)",
x = "OPS+",
y = "Count"
) +
theme_minimal()
ggsave("ops_plus_distribution.png", width = 10, height = 6, dpi = 300)
OPS+ vs wRC+ Comparison
| Aspect | OPS+ | wRC+ |
|---|---|---|
| Calculation | Normalizes OBP + SLG | Uses linear weights |
| Weighting | OBP and SLG equal | Based on run values |
| Accuracy | Very good (~0.93 corr) | Slightly better (~0.95) |
| Use Case | Simple, historical data | Maximum precision |
Key Takeaways
- 100 = league average: Instant interpretation of offensive value
- Park-adjusted: Fair comparisons across all ballparks
- Era-independent: Compare players from any time period
- Career 130+ = Hall of Fame caliber
- Correlates highly with wRC+: Usually within 5-10 points
Discussion
Have questions or feedback? Join our community discussion on
Discord or
GitHub Discussions.
Table of Contents
Related Topics
Quick Actions