Mound
A CLI and Python toolkit for retrieving, analyzing and visualizing MLB pitch-level data — without needing to know MLB player IDs or the underlying API structures.
> How many splitters did Roki Sasaki throw against the Diamondbacks last night?
> How often has he thrown it relative to his other pitches over his last four starts?
> What does its location look like over that period?
Mound answers questions like these with a few CLI commands or a few lines of Python.
Install
pip install mound
# Parquet export support:
pip install "mound[parquet]"
# KDE heatmaps (kind="kde"):
pip install "mound[viz]"
Or from a local checkout (editable):
git clone https://github.com/stiles/mound.git
cd mound
pip install -e .
Requires Python 3.10+.
Quickstart
CLI
# Find a player and their MLB ID
mound search "Roki Sasaki"
# Retrieve pitches from his last 4 starts
mound pitches "Roki Sasaki" --last 4
# Isolate one pitch type
mound pitches "Roki Sasaki" --last 4 --pitch splitter
# Pitch mix and results by pitch type
mound mix "Roki Sasaki" --last 4
mound results "Roki Sasaki" --last 4 --pitch splitter
# Velocity, spin, movement and whiff rate, side by side
mound arsenal "Roki Sasaki" --game 825051
# Plot pitch locations against the strike zone
mound zone "Roki Sasaki" --pitch splitter --last 4 --out splitter_zone.png
# Export the underlying data
mound pitches "Roki Sasaki" --last 4 --export roki_last4.csv
# Cache Savant responses locally; a later run for the same pitcher only
# fetches the games it hasn't seen yet
mound pitches "Roki Sasaki" --last 4 --cache
# Download broadcast clips for a set of pitches
mound video "Roki Sasaki" --pitch splitter --last 4 --out-dir clips
# Download just one clip
mound video "Roki Sasaki" --pitch splitter --last 1 --limit 1
# Already have a pitch_id? Download its clip directly, no lookup needed
mound video-id 7468ecb9-0918-3aca-8ef5-6396e6ab80c3
Run mound --help or mound <command> --help for the full option list.
Python
from mound import Pitcher
roki = Pitcher("Roki Sasaki")
pitches = roki.pitches(last=4)
splitters = pitches.filter(pitch_type="splitter")
splitters.pitch_mix()
splitters.strike_rate()
splitters.swing_rate()
splitters.whiff_rate() # of swings, not of every pitch -- see below
splitters.plot_zone(out="splitter_zone.png")
pitches.pitch_metrics() # avg velocity/spin/movement per pitch type
pitches.to_csv("roki_last4.csv")
# Cache Savant responses locally; a later call for the same pitcher only
# fetches the games it hasn't seen yet
pitches = roki.pitches(last=8, cache=True)
# Download a broadcast clip for a single pitch, or a whole collection
splitters.pitches[0].download_video()
splitters.download_videos(out_dir="clips")
Pitcher.pitches() and PitchCollection.filter() both accept:
| Argument | Meaning |
|---|---|
last |
most recent N appearances |
since / until |
date range ("YYYY-MM-DD" or date), inclusive |
game |
one or more MLB game_pk values |
pitch_type |
a pitch name, alias, or Statcast code (see below) |
stand |
batter side: "L"/"left"/"LHB" or "R"/"right"/"RHB" |
at_bat_number |
a specific at-bat — pair with game, since it's only unique within one game |
pitch_number |
a specific pitch within that at-bat (e.g. 3 for the third pitch) — pair with game and at_bat_number to land on one exact pitch |
Filtering a PitchCollection always returns another PitchCollection, so any combination of .filter(), .pitch_mix(), .strike_rate(), .plot_zone() and export methods composes freely.
Whiff rate and pitch metrics
swing_rate() and whiff_rate() (each with a by_pitch_type option) answer "how nasty was it": whiff rate is the percentage of swings that missed, matching Baseball Savant's own convention — misses divided by swings, not by every pitch thrown, so a pitch rarely swung at can still post a high whiff rate on the swings it draws. pitch_metrics() averages velocity, spin rate and movement (horizontal_break, induced_vertical_break) per pitch type.
Compare one outing against a wider window to see what stood out:
last_start = roki.pitches(last=1)
season = roki.pitches(since="2026-03-01")
last_start.whiff_rate(by_pitch_type=True)["splitter"] # nasty last night?
season.whiff_rate(by_pitch_type=True)["splitter"] # ...or business as usual?
last_start.pitch_metrics().loc["four-seam fastball", "spin_rate"] # spinning it more?
season.pitch_metrics().loc["four-seam fastball", "spin_rate"]
The CLI's mound arsenal combines pitch_metrics() and whiff_rate() into one table:
mound arsenal "Roki Sasaki" --game 825051
pitches velocity spin_rate release_extension horizontal_break induced_vertical_break whiff_rate
pitch_type
four-seam fastball 35 98.8 2427.1 7.1 11.2 16.9 27.3
splitter 32 90.2 868.1 7.2 5.3 1.0 13.6
Plots
plot_zone() renders a headline, a dek (pitch count, strike rate, date range) and a source line around the strike-zone chart itself, rather than relying on axis titles or a boxed legend:
All three are auto-generated but overridable:
splitters.plot_zone(
title="Sasaki leans on the splitter",
subtitle="134 pitches since the All-Star break",
source="Source: Baseball Savant",
kind="heatmap", # "scatter" (default), "heatmap", or "kde"
out="splitter_zone.png",
)
kind="heatmap" bins pitches into a plain 2D histogram; kind="kde" renders a smoother kernel density surface instead (better suited to larger samples), via the optional scipy dependency (pip install "mound[viz]"). Pass bw_method to control its bandwidth, e.g. plot_zone(kind="kde", bw_method=0.3).
Pass subtitle="" or source="" to omit either. Passing your own ax (e.g. for a multi-panel figure) skips the dek/source and falls back to a plain left-aligned title, so plot_zone() behaves as a well-mannered subplot.
Pitch location isn't mirrored for batter handedness, so mixing lefties and righties in one panel can blur the picture — pass split_by="stand" to break it into a vs-LHB / vs-RHB pair, each with its own strike zone and pitch count:
splitters.plot_zone(split_by="stand", out="splitter_zone_by_stand.png")
mound zone "Roki Sasaki" --last 4 --pitch splitter --split-by stand --out splitter_zone_by_stand.png
is_strike vs. in_zone
These sound interchangeable but aren't, and it's easy to expect a plotted zone box to reconcile with the wrong one:
is_strikeis whatever counts as a strike by rule: a called strike, a swinging strike, a foul ball, or a ball put in play. It's about the ruling, not the location — a pitch that draws a swing and a miss (or a foul, or a groundout) well outside the box still counts as a strike.in_zoneis purely locational: does the pitch — modeled as an actual baseball, not a point — overlap the strike-zone rectangle for that batter'ssz_top/sz_bot?
A good chase pitch (splitters, sweepers, low sinkers) will show a much higher is_strike rate than in_zone rate. That's the pitch working as intended, not a bug — batters are swinging at (or getting jammed by) pitches outside the zone on purpose. If a plot_zone() subtitle's strike percentage doesn't match how many dots visually sit inside the drawn box, that's this distinction at work; check in_zone counts (or .filter(in_zone=True)) for the locational answer, not strike_rate().
in_zone models the ball as a sphere overlapping the zone rectangle, which matches Statcast's own methodology (checked against Baseball Savant's own zone/isInZone fields across thousands of live pitches with zero mismatches). One consequence: a pitch can register in_zone=True even when its center is outside the box on both axes at once, as long as it's within one ball radius of a corner — a legitimate, if visually surprising, edge case. in_zone also reflects Statcast's calculated geometry, not the home-plate umpire's real-time call; the two disagree routinely on borderline pitches, especially double-edge corner cases (away and low/high at once). That's normal umpire variance, not an error in Mound.
Pitch types
Statcast tags every pitch with a short code. Mound normalizes these into human-readable names and accepts common aliases when filtering, so pitch_type="four-seam", "fastball" and "FF" are all equivalent.
| Code | Name | Common aliases |
|---|---|---|
FF |
four-seam fastball | fastball, four-seam |
FT |
two-seam fastball | two-seam |
SI |
sinker | |
FC |
cutter | cut fastball |
SL |
slider | |
ST |
sweeper | sweeping slider |
SV |
slurve | |
CU |
curveball | curve |
KC |
knuckle curve | |
CH |
changeup | change-up |
FS |
splitter | split-finger |
FO |
forkball | |
SC |
screwball | |
KN |
knuckleball | knuckler |
EP |
eephus |
Note on Roki Sasaki's signature pitch: Statcast classifies it inconsistently start-to-start — sometimes as a splitter (FS), sometimes as a forkball (FO), depending on its movement profile in a given game. If a pitch_type="splitter" query looks incomplete, check pitch_type="forkball" too, or filter using both.
Caching
By default every call re-fetches from Baseball Savant. Pass cache=True (Python) or --cache (CLI) to cache each game's raw Savant response locally, keyed by game_pk:
pitches = roki.pitches(last=8, cache=True)
mound pitches "Roki Sasaki" --last 8 --cache
Because a finished game's data never changes, a cache hit is never stale — calling again later for the same pitcher only fetches the starts it hasn't seen yet, without any separate "update" step. The cache defaults to ~/.cache/mound (override with the MOUND_CACHE_DIR environment variable, cache="/some/dir", or --cache-dir).
Video downloads
Each pitch's pitch_id doubles as the playId on a Baseball Savant clip page, which embeds a direct broadcast clip:
splitters.pitches[0].download_video() # videos/<pitch_id>.mp4
splitters.download_videos(out_dir="clips") # every pitch in the collection
# One specific at-bat, or one exact pitch within it
game = roki.pitches(game=717404)
at_bat = game.filter(at_bat_number=34)
at_bat.download_videos(out_dir="clips") # every pitch of that at-bat
at_bat.filter(pitch_number=3).pitches[0].download_video() # just the 3rd pitch of it
# Already have a pitch_id (e.g. from an earlier export)? Skip the
# pitcher/game lookup entirely and download it directly
from mound.video import download_video_by_id
download_video_by_id("7468ecb9-0918-3aca-8ef5-6396e6ab80c3")
mound video "Roki Sasaki" --pitch splitter --last 4 --out-dir clips
# Just one clip: pass --limit to cap how many clips are downloaded
mound video "Roki Sasaki" --pitch splitter --last 1 --limit 1
# One specific at-bat (--at-bat is only unique within a --game), or one
# exact pitch within it by adding --pitch-number on top
mound video "Roki Sasaki" --game 823524 --at-bat 6 --out-dir clips
mound video "Roki Sasaki" --game 823524 --at-bat 6 --pitch-number 3 --out-dir clips
# Already have a pitch_id (e.g. from an earlier export)? Skip the
# pitcher/game lookup entirely and download it directly
mound video-id 7468ecb9-0918-3aca-8ef5-6396e6ab80c3
Only the clip page's default embedded angle is captured this way (in practice, the home broadcast feed) — the page's away-broadcast toggle loads its clip via client-side JavaScript rather than a second tag in the page's HTML, so it isn't reachable with a plain request. Pitches with no video coverage are skipped with a warning by default; pass skip_errors=False to raise instead.
Data sources
Mound calls two unofficial, public MLB data services directly:
- MLB Stats API — player search/lookup and game logs, used to resolve a pitcher's identity and discover which games to pull.
- Baseball Savant — the
/gfgame-feed endpoint, used for pitch-by-pitch Statcast data (location, velocity, pitch type, count, outcome).
Both are unofficial and undocumented; endpoints or response shapes could change without notice. Mound sends a descriptive User-Agent and retries transient failures. Responses aren't cached unless you opt in with cache=True/--cache (see Caching).
Development
pip install -e ".[dev]"
pytest
ruff check .
Tests run entirely against mocked HTTP fixtures in tests/fixtures/ (via the responses library) and don't require network access.
Known limitations
- Caching is opt-in and off by default — every call re-fetches unless
cache=True/--cacheis given (see Caching). - Pitch classification comes from Statcast's own model and can be inconsistent for pitches with unusual movement (see the Roki Sasaki note above).
in_zoneis Statcast's calculated geometry, not the umpire's call, andis_strikeisn't the same thing as "located in the zone" — seeis_strikevs.in_zoneabove.- Only pitchers are supported as the primary retrieval unit; there's no batter-vs-pitcher matchup view yet (see ROADMAP.md).
- Historical data availability depends on Statcast/Savant coverage, which is generally reliable from 2015 onward.
- All requests are synchronous and unthrottled beyond basic retry/backoff; heavy bulk retrieval (e.g. a full season) will be slow.
- Video downloads only capture a clip page's default embedded broadcast angle (see Video downloads).
Roadmap
See ROADMAP.md for planned enhancements beyond this prototype.
Changelog
See CHANGELOG.md.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file mound-0.6.1.tar.gz.
File metadata
- Download URL: mound-0.6.1.tar.gz
- Upload date:
- Size: 198.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.10.18
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
217555ec50718293200d24a1d0fb4789deefeb5ff52e84353577d9abd247e634
|
|
| MD5 |
fc74ff7483d25f4ed2c4c9ea6cc5ccbf
|
|
| BLAKE2b-256 |
24d6d914a5a216deb7c839c6792905d0649c9a9a542edf50c8689f274b5c1ae6
|
File details
Details for the file mound-0.6.1-py3-none-any.whl.
File metadata
- Download URL: mound-0.6.1-py3-none-any.whl
- Upload date:
- Size: 39.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.10.18
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
64ae4fc17062af66f8ebfa94539638bc5e775b5440b14ce8155d191bb3c43a77
|
|
| MD5 |
2a860ef4b84657698d9d28a3d1a8f741
|
|
| BLAKE2b-256 |
6b67034b09d967deb6cbda0d3755cec358a25b885804e612d51bbda7a2a0f787
|