Money, Minutes, and Merit

Analyzing NBA Salary and Performance (1990–2022)

Author

Cameron Buttafoco

Published

May 14, 2026

Code
library(tidyverse)
library(patchwork)
library(plotly)
library(crosstalk)
library(scales)

theme_nba <- function(base_size = 12) {
  theme_minimal(base_size = base_size) +
    theme(
      text             = element_text(family = "sans", color = "#2c3e50"),
      plot.title       = element_text(face = "bold", size = base_size + 3,
                                      color = "#1a252f", margin = margin(b = 6)),
      plot.subtitle    = element_text(size = base_size - 1, color = "#5d6d7e",
                                      margin = margin(b = 10)),
      plot.caption     = element_text(size = base_size - 3, color = "#95a5a6",
                                      hjust = 0),
      axis.title       = element_text(size = base_size - 1, color = "#5d6d7e"),
      axis.text        = element_text(size = base_size - 2, color = "#5d6d7e"),
      panel.grid.major = element_line(color = "#ecf0f1", linewidth = 0.5),
      panel.grid.minor = element_blank(),
      panel.background = element_rect(fill = "white", color = NA),
      plot.background  = element_rect(fill = "white", color = NA),
      legend.position  = "bottom",
      legend.title     = element_text(face = "bold", size = base_size - 2),
      legend.text      = element_text(size = base_size - 2),
      strip.text       = element_text(face = "bold", size = base_size - 1,
                                      color = "#1a252f"),
      strip.background = element_rect(fill = "#eaf2ff", color = NA)
    )
}

pos_colors <- c(
  "PG" = "#2980b9",
  "SG" = "#27ae60",
  "SF" = "#e67e22",
  "PF" = "#8e44ad",
  "C"  = "#c0392b"
)

salaries <- read_csv("NBA Salaries(1990-2023) (1).csv", show_col_types = FALSE) |>
  select(playerName, seasonStartYear, salary, inflationAdjSalary) |>
  mutate(
    salary             = parse_number(salary),
    inflationAdjSalary = parse_number(inflationAdjSalary)
  )

payroll <- read_csv("NBA Payroll(1990-2023) (1).csv", show_col_types = FALSE) |>
  select(team, seasonStartYear, payroll, inflationAdjPayroll) |>
  mutate(
    payroll             = parse_number(payroll),
    inflationAdjPayroll = parse_number(inflationAdjPayroll)
  )

stats <- read_csv("NBA Player Stats(1950 - 2022).csv", show_col_types = FALSE) |>
  select(Season, Player, Pos, Age, Tm, G, MP,
         PTS, AST, TRB, STL, BLK,
         `3PA`, `3P%`, `FG%`, `eFG%`, TOV) |>
  filter(Season >= 1990, Season <= 2021) |>
  filter(!is.na(Pos), Pos != "")

nba <- stats |>
  left_join(salaries,
            by = c("Player" = "playerName",
                   "Season" = "seasonStartYear")) |>
  filter(!is.na(salary), !is.na(PTS), G >= 20) |>
  mutate(
    era = case_when(
      Season <= 1999 ~ "1990s",
      Season <= 2009 ~ "2000s",
      Season <= 2019 ~ "2010s",
      TRUE           ~ "2020s"
    ),
    value_score = (PTS + 1.5 * AST + TRB + STL + BLK) / (salary / 1e6),
    pts_per_mil = PTS / (salary / 1e6),
    pos_clean = case_when(
      str_detect(Pos, "^PG") ~ "PG",
      str_detect(Pos, "^SG") ~ "SG",
      str_detect(Pos, "^SF") ~ "SF",
      str_detect(Pos, "^PF") ~ "PF",
      str_detect(Pos, "^C")  ~ "C",
      TRUE ~ NA_character_
    )
  ) |>
  filter(!is.na(pos_clean))

Introduction

This dashboard explores three decades of NBA player salary and performance data (1990–2021), asking a deceptively simple question: do NBA teams actually pay for what they get? Using three publicly available datasets, player salaries, team payrolls, and per-game statistics, we examine how compensation aligns with on-court output, how the three-point revolution changed what the league rewards, and who the best and worst value players of each era were.


Q1 — Does Salary Reflect Performance?

Code
p_scatter <- nba |>
  ggplot(aes(x = salary / 1e6, y = PTS,
             color = pos_clean,
             text  = paste0(Player, "\n",
                            Season, " — ", Tm, "\n",
                            "Salary: $", comma(salary), "\n",
                            "PTS: ", round(PTS, 1)))) +
  geom_point(alpha = 0.4, size = 1.5) +
  geom_smooth(method = "lm", se = FALSE,
              color = "#2c3e50", linewidth = 0.8, linetype = "dashed") +
  facet_wrap(~ era, ncol = 2) +
  scale_x_continuous(labels = label_dollar(suffix = "M")) +
  scale_color_manual(values = pos_colors) +
  labs(
    title    = "Salary vs. Points Per Game by Era",
    subtitle = "Each point is one player-season. Dashed line = linear trend. Min. 20 games played.",
    x        = "Annual Salary (millions)",
    y        = "Points Per Game",
    color    = "Position",
    caption  = "Source: Kaggle — NBA Players & Team Data"
  ) +
  theme_nba()

ggplotly(p_scatter, tooltip = "text") |>
  layout(legend = list(orientation = "h", y = -0.15))

Takeaway: There is a positive but moderate relationship between salary and scoring in every era, but the correlation is far from tight. The 2010s and 2020s show the widest spread, reflecting the rise of large max contracts and the increasing salary premium placed on star guards and wings over traditional big men.


Q2 — The Three-Point Revolution

Code
three_trend <- stats |>
  filter(!is.na(`3PA`), G >= 20) |>
  group_by(Season) |>
  summarise(mean_3pa = mean(`3PA`, na.rm = TRUE), .groups = "drop")

p_3pa <- three_trend |>
  ggplot(aes(x = Season, y = mean_3pa)) +
  geom_area(fill = "#2980b9", alpha = 0.15) +
  geom_line(color = "#2980b9", linewidth = 1.2) +
  geom_point(color = "#2980b9", size = 2) +
  scale_x_continuous(breaks = seq(1990, 2022, 4)) +
  labs(
    title    = "The Three-Point Revolution: Mean 3PA Per Player Per Season",
    subtitle = "Average three-point attempts per game among players with 20+ games played",
    x        = "Season",
    y        = "Mean 3-Point Attempts Per Game",
    caption  = "Source: Kaggle — NBA Players & Team Data"
  ) +
  theme_nba()

three_salary <- nba |>
  filter(!is.na(`3PA`), `3PA` >= 1) |>
  mutate(
    three_tier = case_when(
      `3P%` >= 0.38 ~ "Elite (38%+)",
      `3P%` >= 0.33 ~ "Average (33-38%)",
      TRUE          ~ "Below avg (<33%)"
    ),
    three_tier = factor(three_tier,
                        levels = c("Below avg (<33%)",
                                   "Average (33-38%)",
                                   "Elite (38%+)"))
  ) |>
  filter(era %in% c("1990s", "2010s", "2020s"))

p_3p_salary <- three_salary |>
  ggplot(aes(x = three_tier, y = salary / 1e6, fill = three_tier)) +
  geom_boxplot(outlier.alpha = 0.2, outlier.size = 0.8) +
  facet_wrap(~ era, ncol = 3) +
  scale_fill_manual(values = c("#e74c3c", "#f39c12", "#27ae60")) +
  scale_y_continuous(labels = label_dollar(suffix = "M")) +
  labs(
    title    = "Salary by Three-Point Shooting Tier Across Eras",
    subtitle = "Among players attempting 1+ threes per game",
    x        = "Three-Point Shooting Tier",
    y        = "Annual Salary (millions)",
    fill     = "3P% Tier",
    caption  = "Source: Kaggle — NBA Players & Team Data"
  ) +
  theme_nba() +
  theme(axis.text.x = element_text(angle = 20, hjust = 1))

p_3pa / p_3p_salary +
  plot_annotation(
    title = "Q2: The Three-Point Revolution",
    theme = theme(plot.title = element_text(face = "bold", size = 15, color = "#1a252f"))
  )

Takeaway: Three-point attempts have more than tripled since 1990. In the 1990s there was almost no salary premium for elite shooters, by the 2010s and 2020s, elite three-point shooters earn meaningfully more, confirming the market has caught up to the tactical shift.


Q3 — Which Positions Are Overpaid or Underpaid?

Code
p_value_box <- nba |>
  filter(value_score < quantile(value_score, 0.99, na.rm = TRUE)) |>
  ggplot(aes(x = pos_clean, y = value_score, fill = pos_clean)) +
  geom_boxplot(outlier.alpha = 0.15, outlier.size = 0.7) +
  facet_wrap(~ era, ncol = 2) +
  scale_fill_manual(values = pos_colors) +
  scale_y_continuous(labels = comma) +
  labs(
    title    = "Value Score by Position and Era",
    subtitle = "Value = (PTS + 1.5xAST + TRB + STL + BLK) per $1M salary. Higher = better value.",
    x        = "Position",
    y        = "Value Score (stats per $1M)",
    fill     = "Position",
    caption  = "Source: Kaggle — NBA Players & Team Data"
  ) +
  theme_nba()

top_value <- nba |>
  filter(value_score < quantile(value_score, 0.99, na.rm = TRUE)) |>
  slice_max(value_score, n = 12) |>
  mutate(label = paste0(Player, " (", Season, ")"))

p_top_value <- top_value |>
  ggplot(aes(x = reorder(label, value_score),
             y = value_score,
             fill = pos_clean)) +
  geom_col() +
  coord_flip() +
  scale_fill_manual(values = pos_colors) +
  scale_y_continuous(labels = comma) +
  labs(
    title    = "Top 12 Best-Value Players (1990-2021)",
    subtitle = "Ranked by stats-per-dollar",
    x        = NULL,
    y        = "Value Score",
    fill     = "Position",
    caption  = "Source: Kaggle — NBA Players & Team Data"
  ) +
  theme_nba()

p_value_box / p_top_value +
  plot_annotation(
    title = "Q3: Position Value Analysis",
    theme = theme(plot.title = element_text(face = "bold", size = 15, color = "#1a252f"))
  )

Takeaway: Centers and Power Forwards historically provided strong value relative to salary, particularly before big men began commanding max contracts. Point Guards in the 2010s show the widest spread, reflecting the massive salary gap between star PGs and role players.


Q4 — Have Salaries Kept Pace With Inflation?

Code
salary_trend <- salaries |>
  group_by(seasonStartYear) |>
  summarise(
    median_nominal = median(salary, na.rm = TRUE),
    median_adj     = median(inflationAdjSalary, na.rm = TRUE),
    .groups = "drop"
  ) |>
  pivot_longer(cols = c(median_nominal, median_adj),
               names_to  = "type",
               values_to = "median_salary") |>
  mutate(type = if_else(type == "median_nominal",
                        "Nominal Salary", "Inflation-Adjusted (2023 $)"))

p_inflation <- salary_trend |>
  ggplot(aes(x = seasonStartYear, y = median_salary / 1e6,
             color = type, group = type)) +
  geom_line(linewidth = 1.2) +
  geom_point(size = 2) +
  scale_color_manual(values = c("Nominal Salary"              = "#e74c3c",
                                "Inflation-Adjusted (2023 $)" = "#2980b9")) +
  scale_x_continuous(breaks = seq(1990, 2021, 3)) +
  scale_y_continuous(labels = label_dollar(suffix = "M")) +
  labs(
    title    = "NBA Median Player Salary: Nominal vs. Inflation-Adjusted",
    subtitle = "Inflation-adjusted values in 2023 dollars",
    x        = "Season",
    y        = "Median Annual Salary (millions)",
    color    = NULL,
    caption  = "Source: Kaggle — NBA Players & Team Data"
  ) +
  theme_nba()

top_payroll <- payroll |>
  group_by(team) |>
  summarise(total_adj_payroll = sum(inflationAdjPayroll, na.rm = TRUE),
            .groups = "drop") |>
  slice_max(total_adj_payroll, n = 12)

p_team_payroll <- top_payroll |>
  ggplot(aes(x = reorder(team, total_adj_payroll),
             y = total_adj_payroll / 1e9,
             fill = total_adj_payroll)) +
  geom_col() +
  coord_flip() +
  scale_fill_gradient(low = "#aed6f1", high = "#1a5276") +
  scale_y_continuous(labels = label_dollar(suffix = "B")) +
  labs(
    title    = "Top 12 Highest Cumulative Inflation-Adjusted Payrolls (1990-2021)",
    subtitle = "Total inflation-adjusted spending across all seasons in 2023 dollars",
    x        = NULL,
    y        = "Total Payroll (billions, 2023 $)",
    caption  = "Source: Kaggle — NBA Players & Team Data"
  ) +
  theme_nba() +
  theme(legend.position = "none")

p_inflation / p_team_payroll +
  plot_annotation(
    title = "Q4: Salary Growth and Team Spending",
    theme = theme(plot.title = element_text(face = "bold", size = 15, color = "#1a252f"))
  )

Takeaway: NBA salaries have grown far faster than inflation, median real salaries roughly tripled between 1990 and 2021. The Knicks, Lakers, and Celtics top the all-time payroll charts.


Q5 — Interactive Player Value Leaderboard

Code
leaderboard_data <- nba |>
  filter(value_score < quantile(value_score, 0.995, na.rm = TRUE)) |>
  mutate(
    salary_display    = dollar(salary),
    value_score_round = round(value_score, 1),
    pts_round         = round(PTS, 1),
    ast_round         = round(AST, 1),
    trb_round         = round(TRB, 1)
  ) |>
  select(
    Player, Season, Team = Tm, Position = pos_clean, Age,
    Salary = salary_display,
    PTS = pts_round, AST = ast_round, REB = trb_round,
    `Value Score` = value_score_round,
    era
  )

shared_lb <- SharedData$new(leaderboard_data)

bscols(
  widths = c(3, 9),
  list(
    filter_select("pos_filter", "Position",  shared_lb, ~ Position),
    filter_select("era_filter", "Era",        shared_lb, ~ era),
    filter_slider("pts_filter", "Points Per Game", shared_lb, ~ PTS, step = 1),
    filter_slider("val_filter", "Value Score",     shared_lb, ~ `Value Score`, step = 1)
  ),
  plot_ly(
    shared_lb,
    x      = ~Salary,
    y      = ~`Value Score`,
    color  = ~Position,
    colors = unname(pos_colors),
    text   = ~paste0("<b>", Player, "</b><br>",
                     Season, " — ", Team, "<br>",
                     "PTS: ", PTS, "  AST: ", AST,
                     "  REB: ", REB, "<br>",
                     "Salary: ", Salary, "<br>",
                     "Value Score: ", `Value Score`),
    hoverinfo = "text",
    type   = "scatter",
    mode   = "markers",
    marker = list(size = 7, opacity = 0.7)
  ) |>
    layout(
      title  = list(text = "Player Value Score vs. Salary",
                    font = list(size = 14)),
      xaxis  = list(title = "Annual Salary"),
      yaxis  = list(title = "Value Score (stats per $1M)"),
      legend = list(orientation = "h", y = -0.2)
    )
)

Takeaway: Players in the bottom-right (high value score, low salary) are the league’s best bargains, typically young players on rookie contracts. Players in the top-left (low value score, high salary) represent the biggest overpays.


LLM Usage

Model used: Claude Sonnet (claude.ai)

Claude was used throughout the coding process to assist with syntax and debugging. It helped with formatting issues on the presentation and as a helper for how to break up the report. All research questions, analytical decisions, statistical findings, and writing are my own. All AI-generated code was reviewed and understood before submission.