Atlanta Braves - Pitching Tendencies (StatCast)

Descriptive statistics and visualizations of Atlanta Braves pitching tendencies in 2025 using StatCast data, Python, and Jupyter notebooks.

BaseballStatCastBravesPitchingPythonJupyter
September 2025

Atlanta Braves - Pitching Tendencies (StatCast)

This post uses publicly available StatCast data to explore how Atlanta Braves pitchers attack hitters in 2025. The focus is on descriptive statistics and visuals created from a reproducible Jupyter notebook.

  • Timeframe: 2025-03-01 → 2025-09-15
  • Sample: all pitches where Atlanta is on defense (home/Top, away/Bot)
  • Tools: Python (pybaseball, pandas, plotly, seaborn)

Method at a glance

  • A local parquet cache is used to avoid re-downloading StatCast.
  • Swings and whiffs are derived from StatCast description flags.
  • Braves data is compared to rest of league.
  • A 15-game rolling average is used for team whiff% trend.
  • O-Swing% uses a dynamic strike zone per pitch via sz_top/sz_bot and x/z limits.
  • CSW% (Called Strikes + Whiffs) is computed per pitch and aggregated by pitch type and team.

Formulas used

CSW% = (Called Strikes + Swinging Strikes) / Total Pitches * 100


Pitch type legend

  • FF: Four-seam fastball
  • SI: Sinker
  • FC: Cutter
  • SL: Slider
  • CU: Curveball
  • KC: Knuckle curve
  • CH: Changeup
  • FS: Splitter
  • ST: Sweeper (StatCast label)
  • EP: Eephus

Notes

  • Raw notebook can be shared separately if you want to reproduce or extend the analysis.

Getting our data

We first need to grab some pitching data. Let's make a function that pulls Statcast data - storing in a parquet file once we get it, as the Statcast data can be quite large depending on the date params that are passed.

def get_mlb_statcast(start, end, path="data/baseball_stats_pybaseball_2025.parquet"):
    if os.path.exists(path):
        print("Loading from local parquet...")
        return pd.read_parquet(path)
    else:
        # Pull Statcast data
        from pybaseball import statcast
        df = statcast(start_dt=start, end_dt=end)
        df.to_parquet(path, index=False)
        return df

Calling the function:

df_mlb = get_mlb_statcast("2025-03-01", "2025-09-15")
df_mlb.shape
(702346, 118)

702k rows — plenty of data!


Mapping pitches

Now that we have filtered the Braves data, let's see which pitches we are dealing with.

df_mlb['pitch_type'].unique()
array(['FF', 'SL', 'SI', 'CU', 'ST', 'CH', 'SV', 'FC', 'FS', 'KC', None,
       'EP', 'CS', 'PO', 'FA', 'KN', 'SC', 'FO'], dtype=object)

Curating the pitches

Mapping the pitches so they are easier for the human eye.

pitch_names = {
    "FF": "Four-Seam Fastball",
    "FA": "Fastball (Generic)",
    "FT": "Two-Seam Fastball",
    "SI": "Sinker",
    "FC": "Cutter",
    "FS": "Splitter",
    "FO": "Forkball",
    "SC": "Screwball",
    "SV": "Slurve",
    "SL": "Slider",
    "ST": "Sweeper",
    "CU": "Curveball",
    "KC": "Knuckle Curve",
    "CS": "Slow Curve",
    "CH": "Changeup",
    "EP": "Eephus",
    "KN": "Knuckleball",
    "PO": "Pitch Out",
    None: "Unknown Pitch"
}

df_mlb['Pitch Type'] = df_mlb['pitch_type'].map(pitch_names).fillna(df_mlb['pitch_type'])
df_mlb['Pitch Type'].unique()
array(['Four-Seam Fastball', 'Slider', 'Sinker', 'Curveball', 'Sweeper',
       'Changeup', 'Slurve', 'Cutter', 'Splitter', 'Knuckle Curve',
       'Unknown Pitch', 'Eephus', 'Slow Curve', 'Pitch Out',
       'Fastball (Generic)', 'Knuckleball', 'Screwball', 'Forkball'],
      dtype=object)

Looking good. Now to filter Braves pitching data only.
If the home team, get the top of the inning.
If the away team, get the bottom of the inning.

df_mlb['inning_topbot'].unique()
array(['Top', 'Bot'], dtype=object)


is_braves_pitching = (
    ((df_mlb['home_team'] == 'ATL') & (df_mlb['inning_topbot'] == 'Top')) |
    ((df_mlb['away_team'] == 'ATL') & (df_mlb['inning_topbot'] == 'Bot'))
)

df_braves = df_mlb[is_braves_pitching].copy()
df_braves.shape

(23818, 118)


Pitch Usage

Let's see how often each pitch type was used by Braves pitchers.

# Calculate usage %
usage = (
    df_braves.groupby('Pitch Type')
             .size()
             .div(len(df_braves))
             .mul(100)
             .round(1)
             .reset_index(name='Usage%')
)

# Sort descending by Usage%
usage = usage.sort_values('Usage%', ascending=False)

fig = px.bar(
    usage, x='Pitch Type', y='Usage%',
    text='Usage%',
    color='Usage%',
    color_continuous_scale='Blues',
    title="Braves Pitch Usage % (2025)"
)

# Show numbers above bars
fig.update_traces(texttemplate='%{text:.1f}%', textposition='outside')

# Layout tweaks
fig.update_layout(
    yaxis_title="Usage %",
    xaxis_title="Pitch Type",
    width=700, height=500
)

fig.show()

save_plotly(fig, "usage-by-pitch")

Braves Pitch Usage % (2025)

Figure: Pitch usage percentage by pitch type for Braves pitchers (2025).


Whiff% by pitch type (Braves)

First data exploration, let's see how often each pitch type generates a whiff when the batter swings.

# Define swings and whiffs
SWING_DESCRIPTIONS = {
    'swinging_strike', 'swinging_strike_blocked',
    'foul', 'foul_tip', 'foul_bunt',
    'hit_into_play', 'hit_into_play_no_out', 'hit_into_play_score'
}

df_braves['swing'] = df_braves['description'].isin(SWING_DESCRIPTIONS)
df_braves['whiff'] = df_braves['description'].isin({'swinging_strike', 'swinging_strike_blocked'})

To calculate whiff% (whiffs per swing), formula:
Whiff% = (Swings and Misses) / (Total Swings)

I will also group by pitch type to see what kind of whiff rates we get.

# Calculate whiff%
whiff = (
    df_braves.loc[df_braves['swing']]
             .groupby('Pitch Type')['whiff']
             .mean()
             .mul(100)
             .round(1)
             .reset_index(name='Whiff%')
)


# Plot with Plotly
fig = px.bar(
    whiff, x='Pitch Type', y='Whiff%',
    text='Whiff%',
    color='Whiff%',
    color_continuous_scale='Blues',
    title="Whiff% by Pitch Type (Braves 2025)"
)

# Show numbers above bars
fig.update_traces(texttemplate='%{text:.1f}%', textposition='outside')

# Tidy layout
fig.update_layout(
    yaxis_title="Whiff %",
    xaxis_title="Pitch Type",
    width=700, height=500
)

fig.show()

Whiff% by Pitch Type — Braves 2025

Figure: Whiff rate on swings by pitch type for Braves pitchers (2025).

Okay, the splitter has the highest whiff rate at 52.7%. I'd really like to see how that compares to the rest of the MLB...


Whiff%: Braves vs. MLB by pitch type

First, let's filter the old dataframe containing all the MLB data we originally pulled.

#Filtering the mlb dataframe
df_mlb['swing'] = df_mlb['description'].isin(SWING_DESCRIPTIONS)
df_mlb['whiff'] = df_mlb['description'].isin({'swinging_strike', 'swinging_strike_blocked'})

mlb_whiff = (
    df_mlb.loc[df_mlb['swing']]
          .groupby('Pitch Type')['whiff']
          .mean()
          .mul(100)
          .round(1)
)

Now to compare the Braves to the rest of the MLB.

compare = pd.DataFrame({
    'Braves': (
        df_braves.loc[df_braves['swing']]
                 .groupby('Pitch Type')['whiff']
                 .mean()
                 .mul(100)
                 .round(1)
    ),
    'MLB': mlb_whiff
}).dropna().sort_values('MLB', ascending=False)

#print(compare)

fig = px.bar(compare.reset_index(),
             x="Pitch Type", y=["Braves","MLB"],
             barmode="group",
             title="Whiff% by Pitch Type: Braves vs MLB (2025)")
fig.show()
save_plotly(fig, "whiff-trend-with-pitch-type")

Whiff% by Pitch Type — Braves vs MLB (2025)

Figure: Comparison of swing-and-miss rates by pitch type for Braves vs. MLB average.



To be continued, but a few more things that are a work in progress...



Chase rate (O-Swing%) by pitch type

Chase Rate (O-Swing%) — Braves 2025

Figure: Percentage of swings at pitches outside the zone (StatCast per-pitch zone) by pitch type.


Team whiff% trend

Team Whiff% Trend — Braves 2025

Figure: Daily whiff% with a 15-game rolling average overlay for the Braves (2025).
The rolling curve stabilizes noisy day-to-day results.


CSW% by pitch type: Braves vs MLB

CSW% by Pitch Type — Braves vs MLB (2025)

Figure: Called Strikes + Whiffs by pitch type for Braves vs. MLB (2025).
Breaking/off-speed offerings lead the margin; fastballs sit nearer to league norms.


Pitch location heatmap (example)

Splitter Location Heatmap — Braves 2025

Figure: Splitter location density with strike zone overlay.