First Pitches: Standing Up the Statcast Pipeline

Baseball
Featured
Pipeline
Proving the pipeline end-to-end - from Cloud Run to BigQuery to this chart.
Published

July 8, 2026

This is the first post through the full pipeline: a Cloud Run Job pulls pitch-level Statcast data daily and appends it to BigQuery, and this page reads straight from that table and renders a chart. Nothing here is a deep analytical claim yet - the point of this post is narrower and more important than that: confirming that data really does flow from the source all the way to a published page, with no manual step in between.

Querying the pipeline’s output

from google.cloud import bigquery

client = bigquery.Client()

query = """
SELECT
  pitch_type,
  COUNT(*) AS n_pitches,
  AVG(release_speed) AS avg_release_speed,
  AVG(release_spin_rate) AS avg_spin_rate
FROM `maydaystats.mlb_statcast.pitches`
WHERE pitch_type IS NOT NULL
GROUP BY pitch_type
ORDER BY n_pitches DESC
"""

df = client.query(query).to_dataframe()
df
pitch_type n_pitches avg_release_speed avg_spin_rate
0 FF 1479 95.085936 2340.053414
1 SI 832 93.824760 2213.739183
2 SL 593 85.249747 2434.556492
3 CH 461 85.870065 1746.629067
4 ST 433 83.066051 2686.658199
5 FC 386 89.453627 2413.054404
6 CU 283 80.454770 2629.568905
7 FS 148 88.922973 1570.432432
8 KC 124 84.465323 2385.064516
9 FO 22 83.954545 1258.636364
10 FA 20 63.150000 1503.700000
11 SV 13 82.800000 2873.769231
12 CS 1 67.200000 2449.000000

As of this post, the table holds 4795 pitches across 13 distinct pitch types.

Average release speed by pitch type

import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(8, 5))
ax.bar(df["pitch_type"], df["avg_release_speed"], color="#2c3e50")
ax.set_xlabel("Pitch type")
ax.set_ylabel("Average release speed (mph)")
ax.set_title("Average Release Speed by Pitch Type")
ax.spines[["top", "right"]].set_visible(False)
plt.tight_layout()
plt.show()
Figure 1: Average release speed by pitch type, all games loaded so far

What this confirms, and what’s next

Every number and every bar above came from a live query against mlb_statcast.pitches, which itself only exists because a Cloud Scheduler job triggers a Cloud Run Job every morning that pulls the previous day’s Statcast data. As more days accumulate, this same query pattern (and others like it) becomes the basis for real analysis rather than pipeline validation.

The pipeline code behind this post lives here.

Note

This post uses Quarto’s frozen execution (freeze: true): the code above only re-runs when I re-render locally with fresh BigQuery access. The deployed site reuses that committed output rather than re-querying BigQuery on every build, which is what lets Cloudflare Pages build this site without needing my GCP credentials.