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")

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()

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")

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

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

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

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)

Figure: Splitter location density with strike zone overlay.