Strike Zone Heat Maps

Intermediate 10 min read 1 views Nov 26, 2025

Strike Zone Heatmaps: Visual Pitch Analysis

Strike zone heatmaps transform raw pitch location data into intuitive, color-coded visualizations showing where pitches are thrown and how batters perform in different zones. Hot zones (warm colors) indicate high activity or performance, while cold zones (cool colors) represent low activity or performance.

Strike Zone Numbering System

ZoneLocationDescription
1Top-InsideUpper inside corner
2Top-MiddleUpper middle of zone
3Top-OutsideUpper outside corner
4Middle-InsideMiddle height, inside
5Middle-MiddleHeart of the plate
6Middle-OutsideMiddle height, outside
7Bottom-InsideLower inside corner
8Bottom-MiddleLower middle of zone
9Bottom-OutsideLower outside corner
11-14Off-ZoneOutside strike zone edges

Python Implementation

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from pybaseball import statcast_batter, playerid_lookup

# Get player data
def get_player_pitch_data(first_name, last_name, start_date, end_date):
    player = playerid_lookup(last_name, first_name)
    player_id = player.iloc[0]['key_mlbam']
    data = statcast_batter(start_date, end_date, player_id)
    return data.dropna(subset=['plate_x', 'plate_z'])

# Create pitch location heatmap
def create_pitch_heatmap(data, player_name):
    fig, ax = plt.subplots(figsize=(10, 10))

    # Create 2D histogram
    heatmap = ax.hexbin(data['plate_x'], data['plate_z'],
                        gridsize=15, cmap='YlOrRd', mincnt=1)

    # Draw strike zone
    strike_zone_x = [-0.83, 0.83, 0.83, -0.83, -0.83]
    strike_zone_z = [1.5, 1.5, 3.5, 3.5, 1.5]
    ax.plot(strike_zone_x, strike_zone_z, color='black', linewidth=2)

    ax.set_xlabel('Horizontal Location (ft)', fontsize=12)
    ax.set_ylabel('Vertical Location (ft)', fontsize=12)
    ax.set_title(f'{player_name} - Pitch Location Heatmap', fontsize=14, fontweight='bold')
    plt.colorbar(heatmap, ax=ax, label='Pitch Count')
    ax.set_xlim(-2, 2)
    ax.set_ylim(0, 5)

    plt.tight_layout()
    plt.savefig('pitch_heatmap.png', dpi=300)
    plt.show()

# Create performance heatmap by zone
def create_performance_heatmap(data, player_name, metric='avg'):
    # Create zone bins
    x_bins = np.linspace(-1.5, 1.5, 10)
    z_bins = np.linspace(1.0, 4.0, 10)

    data['x_zone'] = pd.cut(data['plate_x'], bins=x_bins)
    data['z_zone'] = pd.cut(data['plate_z'], bins=z_bins)

    # Calculate batting average by zone
    data['hit'] = data['events'].isin(['single', 'double', 'triple', 'home_run'])
    data['ab'] = data['events'].isin(['single', 'double', 'triple', 'home_run',
                                       'field_out', 'strikeout', 'force_out',
                                       'double_play', 'fielders_choice_out'])

    zone_stats = data[data['ab']].groupby(['x_zone', 'z_zone']).agg({
        'hit': 'sum', 'ab': 'sum'
    }).reset_index()
    zone_stats['avg'] = zone_stats['hit'] / zone_stats['ab']
    zone_stats = zone_stats[zone_stats['ab'] >= 5]

    # Create pivot for heatmap
    pivot = zone_stats.pivot_table(values='avg', index='z_zone', columns='x_zone')

    # Plot
    fig, ax = plt.subplots(figsize=(12, 10))
    sns.heatmap(pivot, annot=True, fmt='.3f', cmap='RdYlGn',
                center=pivot.mean().mean(), ax=ax, cbar_kws={'label': 'Batting Average'})

    ax.set_title(f'{player_name} - Batting Average by Zone', fontsize=14, fontweight='bold')
    ax.set_xlabel('Horizontal Location (Inside → Outside)', fontsize=12)
    ax.set_ylabel('Vertical Location (Low → High)', fontsize=12)

    plt.tight_layout()
    plt.savefig('performance_heatmap.png', dpi=300)
    plt.show()

# Swing rate heatmap
def create_swing_rate_heatmap(data, player_name):
    swing_types = ['hit_into_play', 'swinging_strike', 'foul',
                   'swinging_strike_blocked', 'foul_tip']
    data['swing'] = data['description'].isin(swing_types)

    x_bins = np.linspace(-2, 2, 15)
    z_bins = np.linspace(0.5, 4.5, 15)

    data['x_bin'] = pd.cut(data['plate_x'], bins=x_bins)
    data['z_bin'] = pd.cut(data['plate_z'], bins=z_bins)

    swing_rate = data.groupby(['x_bin', 'z_bin']).agg({
        'swing': ['sum', 'count']
    }).reset_index()
    swing_rate.columns = ['x_bin', 'z_bin', 'swings', 'total']
    swing_rate['rate'] = swing_rate['swings'] / swing_rate['total']
    swing_rate = swing_rate[swing_rate['total'] >= 5]

    pivot = swing_rate.pivot_table(values='rate', index='z_bin', columns='x_bin')

    fig, ax = plt.subplots(figsize=(14, 10))
    sns.heatmap(pivot, annot=True, fmt='.2f', cmap='coolwarm', center=0.5,
                ax=ax, vmin=0, vmax=1, cbar_kws={'label': 'Swing Rate'})

    ax.set_title(f'{player_name} - Swing Rate by Zone', fontsize=14, fontweight='bold')
    plt.tight_layout()
    plt.savefig('swing_rate_heatmap.png', dpi=300)
    plt.show()

# Example usage
data = get_player_pitch_data("Aaron", "Judge", "2024-04-01", "2024-09-30")
create_pitch_heatmap(data, "Aaron Judge")
create_performance_heatmap(data, "Aaron Judge")

R Implementation

library(baseballr)
library(ggplot2)
library(dplyr)
library(viridis)

# Get Statcast data
get_pitch_data <- function(player_id, start_date, end_date) {
  data <- scrape_statcast_savant(
    start_date = start_date,
    end_date = end_date,
    playerid = player_id,
    player_type = "batter"
  )

  return(data %>% filter(!is.na(plate_x), !is.na(plate_z)))
}

# Create heatmap
create_pitch_heatmap <- function(data, player_name) {
  # Strike zone boundaries
  strike_zone <- data.frame(
    x = c(-0.83, 0.83, 0.83, -0.83, -0.83),
    z = c(1.5, 1.5, 3.5, 3.5, 1.5)
  )

  p <- ggplot(data, aes(x = plate_x, y = plate_z)) +
    stat_density_2d(aes(fill = ..density..), geom = "raster", contour = FALSE) +
    scale_fill_viridis(option = "plasma", name = "Density") +
    geom_path(data = strike_zone, aes(x = x, y = z),
              color = "white", linewidth = 1.5) +
    coord_fixed(ratio = 1) +
    xlim(-2, 2) +
    ylim(0, 5) +
    labs(
      title = paste(player_name, "- Pitch Location Heatmap"),
      x = "Horizontal Location (ft)",
      y = "Vertical Location (ft)"
    ) +
    theme_minimal() +
    theme(
      plot.title = element_text(size = 16, face = "bold", hjust = 0.5)
    )

  ggsave("pitch_heatmap.png", p, width = 10, height = 8, dpi = 300)
  print(p)
}

# Performance heatmap
create_performance_heatmap <- function(data, player_name) {
  data <- data %>%
    mutate(
      x_zone = cut(plate_x, breaks = seq(-1.5, 1.5, length.out = 8)),
      z_zone = cut(plate_z, breaks = seq(1.0, 4.0, length.out = 8)),
      hit = events %in% c("single", "double", "triple", "home_run"),
      ab = events %in% c("single", "double", "triple", "home_run",
                         "field_out", "strikeout", "force_out")
    )

  zone_stats <- data %>%
    filter(ab) %>%
    group_by(x_zone, z_zone) %>%
    summarise(
      hits = sum(hit),
      at_bats = n(),
      avg = hits / at_bats,
      .groups = "drop"
    ) %>%
    filter(at_bats >= 5)

  p <- ggplot(zone_stats, aes(x = x_zone, y = z_zone, fill = avg)) +
    geom_tile(color = "white") +
    geom_text(aes(label = sprintf("%.3f", avg)), color = "white", fontface = "bold") +
    scale_fill_gradient2(low = "blue", mid = "yellow", high = "red",
                         midpoint = mean(zone_stats$avg), name = "AVG") +
    labs(
      title = paste(player_name, "- Batting Average by Zone"),
      x = "Horizontal (Inside → Outside)",
      y = "Vertical (Low → High)"
    ) +
    theme_minimal() +
    theme(
      plot.title = element_text(face = "bold", hjust = 0.5),
      axis.text = element_blank(),
      panel.grid = element_blank()
    )

  ggsave("performance_heatmap.png", p, width = 12, height = 10, dpi = 300)
  print(p)
}

# Example: Aaron Judge (592450)
# data <- get_pitch_data(592450, "2024-04-01", "2024-09-30")
# create_pitch_heatmap(data, "Aaron Judge")

Applications

Pitcher-Hitter Matchups

  • Overlay pitcher heat zones with batter cold zones
  • Identify optimal attack zones for specific matchups
  • Develop count-specific strategies

Pitch Sequencing

CountStrategyZone Targeting
0-0Establish tonePitcher's best command zones
0-1, 0-2Expand zoneChase areas outside zone
2-2, 3-2Critical countsBest putaway locations
3-0, 3-1Batter's countsSafe zones, avoid damage

Key Takeaways

  • Hot/cold zones reveal tendencies: Identify strengths and weaknesses
  • Performance by zone: Shows where hitters succeed or struggle
  • Swing rate analysis: Reveals discipline and chase tendencies
  • Matchup planning: Target optimal zones for specific batters
  • Count-specific: Different strategies for different situations

Discussion

Have questions or feedback? Join our community discussion on Discord or GitHub Discussions.