Run Rate and Game Script: 49ers vs. Vikings, 2023
The 49ers ran the ball more than any team in 2023, and the Vikings barely ran at all. It's tempting to read that as two coaching philosophies. Mostly, it's the scoreboard.
Two very different seasons on the ground
Kyle Shanahan's offense has been built around the run for years, and 2023 may have been its best version. Christian McCaffrey ran for 5.4 yards per carry on 272 carries. Deebo Samuel's carries out of the backfield averaged 6.1 yards. San Francisco finished first in expected points added (EPA) per run, one of only three teams (with Baltimore and Buffalo) above zero.
Minnesota went the other way. Alexander Mattison carried 180 times for −0.18 EPA per carry, and the offense lost Kirk Cousins to a torn Achilles at midseason. The Vikings ended up 25th in rushing EPA and 29th in run rate.
The obvious reading of Figure 1 is that good rushing teams choose to run more. But teams that run more are also usually teams that are winning, and a team with a lead runs to drain the clock whatever its philosophy. To separate the two, we need to know when each team was running.
Where the snaps happened
San Francisco went 12–5 with an average margin of +11.4 points. Minnesota went 7–10 and played 14 one-score games. The result is that the two offenses spent their seasons in very different situations:
Teams pass when they're behind and run when they're ahead. Every team follows this pattern, so a team that leads all season will post a high run rate almost automatically. The real question is how each team behaved within the same situation:
Three things stand out:
- Behind, the 49ers threw like everyone else. Down by 9 or more, they ran only 20.5% of the time, lower than the league's 28.2%.
- Ahead, they really did lean on the run. Up by 9 or more, San Francisco ran 58% of the time, against 50% for the league and 38% for Minnesota.
- Minnesota was pass-first even when tied (35.5% run rate vs. the league's 40.1%). That is the one part of its low run rate that game script doesn't explain.
A cleaner way to strip out game script is to look only at "neutral" plays: 1st and 2nd down, win probability between 20% and 80%, and outside the last two minutes of each half. On those plays the 49ers ran 46.4% of the time (19th in the league) and the Vikings 44.5% (23rd). That's a two-point gap, compared with twelve points overall.
import pandas as pd
pbp = pd.read_parquet("play_by_play_2023.parquet")
plays = pbp[(pbp.season_type == "REG") & ((pbp["pass"] == 1) | (pbp.rush == 1))]
# Neutral = early down, game still in doubt, not a two-minute drill
neutral = plays[
(plays.down <= 2)
& plays.wp.between(0.2, 0.8)
& (plays.half_seconds_remaining > 120)
]
run_rate = plays.groupby("posteam").rush.mean()
neutral_run_rate = neutral.groupby("posteam").rush.mean()
print(run_rate[["SF", "MIN"]], neutral_run_rate[["SF", "MIN"]])
The same pattern across every game in the league
Figure 4 plots each of the 544 team-games from 2023: the team's run rate in that game against its final margin. The correlation is r = 0.52. Each extra point of final margin adds about a third of a percentage point of run rate. That doesn't mean running wins games. Winning causes running, because teams with a lead hand the ball off to kill the clock.
Who carried the ball
Efficiency did differ between the two teams, and it shows up at the player level. Every 49ers back with 30 or more carries except Elijah Mitchell beat every Vikings back on a per-carry basis. Ty Chandler (+0.007 EPA per carry) was Minnesota's one bright spot. He took over late in the season, after the Vikings were already throwing on most downs.
A better yardstick: pass rate over expected
The neutral-situation filter works, but it throws away most of a team's plays. The more common fix in public analytics is
pass rate over expected (PROE). nflfastR ships a model, xpass, that gives every play the probability
of a dropback based on down, distance, field position, score, clock, and win probability. Averaging xpass over a
team's plays gives the pass rate a league-average play-caller would have had in exactly the same situations. PROE is the
difference between that and what the team actually did.
cols = ["season", "season_type", "posteam", "pass", "rush", "xpass", "epa"]
pbp = load_pbp(range(2020, 2026), columns=cols)
plays = pbp[(pbp.season_type == "REG") & ((pbp["pass"] == 1) | (pbp.rush == 1))]
plays = plays.dropna(subset=["xpass", "posteam", "epa"])
proe = plays.groupby(["season", "posteam"]).agg(pass_rate=("pass", "mean"),
xpass=("xpass", "mean"))
proe["proe"] = proe.pass_rate - proe.xpass
In 2023 Minnesota passed on 67.4% of plays against an expected 64.7%, a PROE of +2.7% (6th most pass-heavy). San Francisco passed on 55.0% against an expected 57.0%, a PROE of −2.0% (19th). So of the 12.4-point gap between the two teams' pass rates, 7.7 points come from the situations they were in. Only about 4.7 points reflect a choice.
The extremes are more interesting than either of our two teams. Atlanta (−9.2%) ran far more than its situations called for, and Kansas City (+6.9%), Cincinnati, and Washington threw far more.
Is play-calling philosophy a stable trait?
If PROE measures a coaching tendency rather than noise, it should carry over from one season to the next. Correlating each team's value with its value the following year, across 2020–2025, gives an average r = 0.52 for PROE. Raw pass rate carries over less well (r = 0.44) because it moves with the team's record.
The two histories line up with the coaching staffs. San Francisco was below zero in all six seasons under Kyle Shanahan. Minnesota was run-leaning in 2020–21 under Mike Zimmer (−4.3% and −2.2%), then flipped to pass-leaning in 2022, Kevin O'Connell's first season as head coach, and stayed there through 2024. The Vikings' low run rate in 2023 was partly game script and partly a new philosophy.
How sure are we about the neutral gap?
Earlier, the two teams' neutral run rates differed by 1.9 points. That's small, but is it distinguishable from zero? A bootstrap answers this: resample each team's games with replacement 10,000 times and recompute the gap. Resampling games rather than plays matters here, because plays in the same game share an opponent, a game plan, and weather, so they aren't independent.
rng = np.random.default_rng(7)
by_game = {t: neutral[neutral.posteam == t].groupby("game_id").rush.agg(["sum", "count"])
for t in ["SF", "MIN"]}
diffs = []
for _ in range(10_000):
rate = {}
for t, g in by_game.items():
idx = rng.integers(0, len(g), len(g)) # resample games, keep plays together
rate[t] = g["sum"].values[idx].sum() / g["count"].values[idx].sum()
diffs.append(rate["SF"] - rate["MIN"])
np.quantile(diffs, [0.025, 0.975]) # [-0.037, 0.073]
The 95% interval runs from −3.7 to +7.3 points. It comfortably includes zero, so in neutral situations the data can't tell these two play-callers apart at all.
So what?
"Establish the run" is one of football's most durable clichés, and season-long run rate is the stat usually quoted to support it. This comparison is a reminder of how much that number depends on the scoreboard. The 49ers' 45% run rate mostly reflects how often they were winning, and their efficiency on the ground was one reason they were winning. Strip out the game script and the two teams' play-calling was far more alike than their season totals suggest.
When you compare teams' tendencies, look at neutral-situation rates, or at least split by score. Raw run rate tells you more about a team's record than about its philosophy.
Caveats
- One season, two teams. Minnesota started four different quarterbacks after Cousins' injury, which affected how often it could pass well, not just how often it chose to.
- EPA per run penalizes short-yardage carries that convert but gain little. Success rate tells the same story, though (SF 1st at 47.0%, MIN 14th at 39.9%).
- "Neutral" is a convention. Shifting the win-probability window to 30–70% doesn't change the conclusion.
Data and code
Play-by-play from nflverse (nflfastR), 2023 regular season
(2020–2025 for the PROE section), limited to designed runs and dropbacks with a valid EPA. The analysis script that produces every number and chart
on this page is football-analysis/percentage_run_offense.py.