Mariners 2026
  • Home
  • Full analysis
  • Trends

On this page

  • Stat glossary
  • Who’s hot, who’s not
  • The numbers
  • Player pages on Baseball Savant

Seattle Mariners 2026

  • Show All Code
  • Hide All Code

  • View Source

Stats, refreshed the morning after every game

Published

August 23, 2026

Last refreshed: August 23, 2026 at 6:26 AM PDT

This site treats a baseball season like a bioinformatics experiment: players are samples, their offensive stats are features, and we look for the hitters and pitchers behaving differently from the rest. The numbers come from FanGraphs and the MLB Stats API and are re-rendered automatically the morning after each Mariners game.

Read the full differential analysis →

Stat glossary

New to the sabermetric alphabet soup? Here’s what every stat on this page means.

wOBA — weighted on-base average
One number that captures a hitter’s total offensive value. Instead of treating every time on base equally (like OBP does), it weights each outcome — walk, single, double, triple, home run — by how much that event is actually worth in run-scoring terms. It’s scaled to look like OBP, so league average is about .320, a good hitter is around .370, and an elite one pushes .400+. Higher is better.
xwOBA — expected weighted on-base average
What a hitter should have produced based on how they hit the ball, not whether it found a glove. Statcast feeds the exit velocity and launch angle of every batted ball (plus sprint speed on grounders) into a model that returns the wOBA a typical hitter earns on contact like that. Because it strips out luck, defense, and ballpark, comparing it to actual wOBA is revealing: wOBA − xwOBA above zero means a hitter is out-producing their contact quality (riding hot, likely to cool off), while below zero flags strong contact that hasn’t paid off yet (an upside regression candidate).
BABIP — batting average on balls in play
Batting average counting only balls put in play — home runs and strikeouts are excluded. League average sits near .300, and sustained extremes are rare, so a sky-high BABIP usually signals good luck or favorable sequencing that regresses, and a very low one signals the opposite.
ERA / xERA — earned run average vs. expected
ERA is the earned runs a pitcher allows per nine innings — the classic bottom-line number. xERA is the contact-quality-based expectation, derived the same way as xwOBA. ERA − xERA separates skill from luck: a negative gap means a pitcher has allowed fewer runs than their stuff predicts (possibly lucky), a positive gap the reverse.
Hard-hit %, exit velocity & launch angle — the batted-ball inputs
Exit velocity is how fast the ball leaves the bat (mph); hard-hit % is the share of batted balls hit at 95 mph or more — a proxy for raw contact quality. Launch angle is the vertical angle off the bat: too low is a grounder, too high is a pop-up, and the productive line-drive window sits in between. Together these are the ingredients xwOBA is built from.
PA / IP — sample size
Plate appearances (hitters) and innings pitched (pitchers). They aren’t performance stats — they tell you how much to trust the rest. Early in the season small samples swing wildly, so a gaudy line on 30 PA means far less than the same line on 300.

Who’s hot, who’s not

Hard contact vs results. Does hitting the ball hard translate into production? The dashed line is the league-wide trend; the grey band is its 95% confidence interval (how sure we are of that average relationship — not a per-player normal range). Players well above the line are out-producing their contact quality (lucky or crafty); well below, under-producing (unlucky or poor launch angles).

Code
chart_hard_hit(batting)

BABIP — luck on balls in play. League average is ~.300; sustained extremes are rare, so red bars tend to cool off and navy bars tend to warm up.

Code
chart_babip(batting)

Pitching: ERA vs the peripherals. Negative bars mean a pitcher has allowed fewer runs than his strikeouts, walks, and contact quality would predict.

Code
chart_pitching_luck(pitching)

The numbers

The biggest gaps between what hitters have actually produced (wOBA) and what their batted-ball quality says they should have produced (xwOBA).

Code
glance <- batting |>
  dplyr::transmute(
    Player = player,
    Pos    = pos,
    PA     = pa,
    wOBA   = round(woba, 3),
    xwOBA  = round(xwoba, 3),
    Diff   = round(woba - xwoba, 3)
  ) |>
  dplyr::arrange(dplyr::desc(Diff))

over  <- dplyr::slice_max(glance, Diff, n = 5, with_ties = FALSE)
under <- dplyr::slice_min(glance, Diff, n = 5, with_ties = FALSE) |>
  dplyr::arrange(Diff)
Code
knitr::kable(over, row.names = FALSE)
knitr::kable(under, row.names = FALSE)

Running hot — over-performing

Player Pos PA wOBA xwOBA Diff
Brendan Donovan 3B 159 0.349 0.314 0.034
Miles Mastrobuoni UNK 30 0.202 0.184 0.018
Randy Arozarena LF 526 0.366 0.350 0.016
Colt Emerson UNK 241 0.256 0.248 0.008
Victor Robles RF 141 0.285 0.281 0.004

Running cold — under-performing

Player Pos PA wOBA xwOBA Diff
Patrick Wisdom UNK 44 0.172 0.265 -0.093
Connor Joe UNK 45 0.274 0.337 -0.063
Rob Refsnyder UNK 129 0.207 0.254 -0.047
Julio Rodríguez CF 517 0.316 0.348 -0.031
Jhonny Pereda C 79 0.299 0.330 -0.031

Pitching staff — ERA − xERA

Code
pitching |>
  dplyr::transmute(
    Player = player,
    Role   = role,
    IP     = ip,
    ERA    = round(era, 2),
    xERA   = round(xera, 2),
    `ERA − xERA` = round(era_minus_xera, 2)
  ) |>
  dplyr::arrange(`ERA − xERA`) |>
  knitr::kable(row.names = FALSE)
Player Role IP ERA xERA ERA − xERA
Matt Brash RP 16.2 0.54 4.19 -3.65
Cole Wilcox SP 27.0 4.00 5.44 -1.44
Gabe Speier SP 34.0 2.65 4.01 -1.37
Eduard Bazardo SP 58.0 3.10 4.42 -1.32
Nick Davila SP 26.1 4.10 4.65 -0.54
Emerson Hancock SP 133.2 3.37 3.87 -0.50
Logan Gilbert SP 150.1 3.35 3.69 -0.33
José A. Ferrer SP 52.2 3.59 3.56 0.03
Cooper Criswell SP 30.2 3.52 3.38 0.14
George Kirby SP 140.0 4.18 4.00 0.18
Bryan Woo SP 138.1 4.10 3.56 0.54
Bryce Miller SP 89.2 3.71 3.04 0.68
Michael Rucker SP 29.1 4.30 3.24 1.05
Andrés Muñoz SP 47.1 4.37 3.28 1.09

Player pages on Baseball Savant

Want the pitch-by-pitch Statcast detail behind these numbers? Each player links to their Baseball Savant page.

Code
batting |>
  dplyr::arrange(dplyr::desc(pa)) |>
  savant_bullets("hitting")
pitching |>
  dplyr::arrange(dplyr::desc(ip)) |>
  savant_bullets("pitching")

Hitters

  • Randy Arozarena
  • Cole Young
  • Josh Naylor
  • Julio Rodríguez
  • Cal Raleigh
  • Dominic Canzone
  • Brendan Donovan
  • Victor Robles
  • Leo Rivas
  • Jhonny Pereda

Pitchers

  • Logan Gilbert
  • George Kirby
  • Bryan Woo
  • Emerson Hancock
  • Bryce Miller
  • Eduard Bazardo
  • José A. Ferrer
  • Andrés Muñoz
  • Gabe Speier
  • Michael Rucker

How this updates: a scheduled GitHub Actions workflow checks each morning whether the Mariners finished a game the night before. If so, it re-fetches the stats, re-renders this site, and deploys it to Vercel. Off-days are skipped, and the synthetic fallback is never published.

Source Code
---
title: "Seattle Mariners 2026"
subtitle: "Stats, refreshed the morning after every game"
date: today
page-layout: full
code-fold: true
---

```{r setup, include=FALSE}
source(here::here("R", "00_setup.R"))
source(here::here("R", "charts.R"))

# In CI the data is fetched once before the render. When this page is rendered
# standalone the .rds files may not exist yet — fetch then.
if (!file.exists(here("data", "batting_2026.rds"))) {
  source(here::here("R", "01_fetch_data.R"))
}

batting  <- readRDS(here("data", "batting_2026.rds"))
pitching <- readRDS(here("data", "pitching_2026.rds"))

refreshed <- format(Sys.time(), tz = "America/Los_Angeles",
                    format = "%B %e, %Y at %l:%M %p %Z")

# Note when the underlying numbers came from the synthetic fallback rather than
# the live feed, so the page is never silently wrong.
src <- tryCatch(readLines(here("data", "data_source.txt"), n = 1),
                error = function(e) "unknown")
```

::: {.refreshed-badge}
Last refreshed: `r refreshed`
:::

```{r live-warning, echo=FALSE, results='asis'}
if (!identical(src, "live")) {
  cat("> ⚠️ **Heads up:** these numbers are from the synthetic fallback — the live",
      "FanGraphs / MLB Stats API feed was unavailable at render time.\n")
}
```

This site treats a baseball season like a bioinformatics experiment: players are
*samples*, their offensive stats are *features*, and we look for the hitters and
pitchers behaving differently from the rest. The numbers come from FanGraphs and
the MLB Stats API and are re-rendered automatically the morning after each
Mariners game.

[**Read the full differential analysis →**](/analysis)

## Stat glossary

New to the sabermetric alphabet soup? Here's what every stat on this page means.

::: {.stat-glossary}
<dl>
<dt>wOBA — weighted on-base average</dt>
<dd>One number that captures a hitter's total offensive value. Instead of treating
every time on base equally (like OBP does), it weights each outcome — walk, single,
double, triple, home run — by how much that event is actually worth in run-scoring
terms. It's scaled to look like OBP, so league average is about **.320**, a good
hitter is around **.370**, and an elite one pushes **.400+**. Higher is better.</dd>

<dt>xwOBA — expected weighted on-base average</dt>
<dd>What a hitter <em>should</em> have produced based on <strong>how</strong> they hit
the ball, not whether it found a glove. Statcast feeds the exit velocity and launch
angle of every batted ball (plus sprint speed on grounders) into a model that returns
the wOBA a typical hitter earns on contact like that. Because it strips out luck,
defense, and ballpark, comparing it to actual wOBA is revealing: <strong>wOBA − xwOBA
above zero</strong> means a hitter is out-producing their contact quality (riding hot,
likely to cool off), while <strong>below zero</strong> flags strong contact that hasn't
paid off yet (an upside regression candidate).</dd>

<dt>BABIP — batting average on balls in play</dt>
<dd>Batting average counting only balls put in play — home runs and strikeouts are
excluded. League average sits near <strong>.300</strong>, and sustained extremes are
rare, so a sky-high BABIP usually signals good luck or favorable sequencing that
regresses, and a very low one signals the opposite.</dd>

<dt>ERA / xERA — earned run average vs. expected</dt>
<dd><strong>ERA</strong> is the earned runs a pitcher allows per nine innings — the
classic bottom-line number. <strong>xERA</strong> is the contact-quality-based
expectation, derived the same way as xwOBA. <strong>ERA − xERA</strong> separates skill
from luck: a negative gap means a pitcher has allowed fewer runs than their stuff
predicts (possibly lucky), a positive gap the reverse.</dd>

<dt>Hard-hit %, exit velocity & launch angle — the batted-ball inputs</dt>
<dd><strong>Exit velocity</strong> is how fast the ball leaves the bat (mph);
<strong>hard-hit %</strong> is the share of batted balls hit at 95 mph or more — a proxy
for raw contact quality. <strong>Launch angle</strong> is the vertical angle off the
bat: too low is a grounder, too high is a pop-up, and the productive line-drive window
sits in between. Together these are the ingredients xwOBA is built from.</dd>

<dt>PA / IP — sample size</dt>
<dd><strong>Plate appearances</strong> (hitters) and <strong>innings pitched</strong>
(pitchers). They aren't performance stats — they tell you how much to trust the rest.
Early in the season small samples swing wildly, so a gaudy line on 30 PA means far
less than the same line on 300.</dd>
</dl>
:::

## Who's hot, who's not

**Hard contact vs results.** Does hitting the ball hard translate into production?
The dashed line is the league-wide trend; the grey band is its 95% confidence
interval (how sure we are of that *average* relationship — not a per-player normal
range). Players well *above* the line are out-producing their contact quality
(lucky or crafty); well *below*, under-producing (unlucky or poor launch angles).

```{r hard-hit, fig.width = 9, fig.height = 6}
chart_hard_hit(batting)
```

**BABIP — luck on balls in play.** League average is ~.300; sustained extremes are
rare, so red bars tend to cool off and navy bars tend to warm up.

```{r babip, fig.width = 9, fig.height = 5}
chart_babip(batting)
```

**Pitching: ERA vs the peripherals.** Negative bars mean a pitcher has allowed
fewer runs than his strikeouts, walks, and contact quality would predict.

```{r pitching-luck, fig.width = 9, fig.height = 5}
chart_pitching_luck(pitching)
```

## The numbers

The biggest gaps between what hitters have actually produced (**wOBA**) and what
their batted-ball quality says they *should* have produced (**xwOBA**).

```{r glance}
glance <- batting |>
  dplyr::transmute(
    Player = player,
    Pos    = pos,
    PA     = pa,
    wOBA   = round(woba, 3),
    xwOBA  = round(xwoba, 3),
    Diff   = round(woba - xwoba, 3)
  ) |>
  dplyr::arrange(dplyr::desc(Diff))

over  <- dplyr::slice_max(glance, Diff, n = 5, with_ties = FALSE)
under <- dplyr::slice_min(glance, Diff, n = 5, with_ties = FALSE) |>
  dplyr::arrange(Diff)
```

::: {layout-ncol="2"}

::: {}
**Running hot — over-performing**

```{r over}
knitr::kable(over, row.names = FALSE)
```
:::

::: {}
**Running cold — under-performing**

```{r under}
knitr::kable(under, row.names = FALSE)
```
:::

:::

**Pitching staff — ERA − xERA**

```{r pitching-glance}
pitching |>
  dplyr::transmute(
    Player = player,
    Role   = role,
    IP     = ip,
    ERA    = round(era, 2),
    xERA   = round(xera, 2),
    `ERA − xERA` = round(era_minus_xera, 2)
  ) |>
  dplyr::arrange(`ERA − xERA`) |>
  knitr::kable(row.names = FALSE)
```

## Player pages on Baseball Savant

Want the pitch-by-pitch Statcast detail behind these numbers? Each player links to
their [Baseball Savant](https://baseballsavant.mlb.com) page.

```{r savant-helper, include=FALSE}
# Emit a markdown bullet list of Savant links for a data frame that has
# `player` + `mlbam_id`. Players without an id are skipped.
savant_bullets <- function(df, kind) {
  if (!"mlbam_id" %in% names(df)) return(invisible())
  urls <- savant_url(df$player, df$mlbam_id, kind)
  ok   <- !is.na(urls)
  cat(paste0("- [", df$player[ok], "](", urls[ok], ")"), sep = "\n")
  cat("\n")
}
```

::: {layout-ncol="2"}

::: {.savant-list}
### Hitters

```{r savant-hitters, results='asis'}
batting |>
  dplyr::arrange(dplyr::desc(pa)) |>
  savant_bullets("hitting")
```
:::

::: {.savant-list}
### Pitchers

```{r savant-pitchers, results='asis'}
pitching |>
  dplyr::arrange(dplyr::desc(ip)) |>
  savant_bullets("pitching")
```
:::

:::

---

*How this updates: a scheduled GitHub Actions workflow checks each morning whether
the Mariners finished a game the night before. If so, it re-fetches the stats,
re-renders this site, and deploys it to Vercel. Off-days are skipped, and the
synthetic fallback is never published.*

Data via FanGraphs & the MLB Stats API · refreshed the morning after each game.

 

Built with Quarto · deployed on Vercel.