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+ RangeClassificationDescription
160+EliteMVP-caliber, generational talent
140-159ExcellentPerennial All-Star, franchise cornerstone
120-139Above AverageQuality regular, All-Star potential
90-119AverageSolid contributor
80-89Below AverageBackup quality, defensive specialist
<80PoorSignificant offensive liability

Historical OPS+ Leaders

PlayerCareer OPS+
Babe Ruth206
Ted Williams190
Barry Bonds182
Lou Gehrig179
Mike Trout176
Rogers Hornsby175

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

AspectOPS+wRC+
CalculationNormalizes OBP + SLGUses linear weights
WeightingOBP and SLG equalBased on run values
AccuracyVery good (~0.93 corr)Slightly better (~0.95)
Use CaseSimple, historical dataMaximum 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.