Every NFL season someone says the wind will kill the over. This notebook checks the claim against the closing total, the betting market’s last number before kickoff, using 997 open-air games from September 2021 to October 2026. The question is not only “does wind lower scoring?” but “does it lower scoring by more than the closing total already expects?”
The NFL weather and scoring study has the plain-English write-up and every table. The same CSV is archived on Zenodo with DOI 10.5281/zenodo.23880021.
u <- "https://betbetter.world/studies/data/nfl-weather-scoring-2021-2026.csv"
wx <- read.csv(u, encoding = "UTF-8")
wx$date <- as.Date(wx$date)
nrow(wx); range(wx$date); table(wx$game_type)
## [1] 997
## [1] "2021-09-09" "2026-10-29"
##
## POST REG
## 50 947
str(wx[, c("wind_mph", "temp_f", "total_points", "closing_total", "points_vs_close")])
## 'data.frame': 997 obs. of 5 variables:
## $ wind_mph : int 9 17 0 15 8 15 9 19 8 4 ...
## $ temp_f : int 82 73 81 81 88 81 86 82 82 81 ...
## $ total_points : int 60 39 33 51 62 33 41 40 51 36 ...
## $ closing_total : num 52.5 48 44.5 47 54.5 43.5 49 41.5 54 45.5 ...
## $ points_vs_close: num 7.5 -9 -11.5 4 7.5 -10.5 -8 -1.5 -3 -9.5 ...
points_vs_close is the final combined score minus the
closing total. A positive number means the game went over, a negative
number means it went under.
all.equal(wx$points_vs_close, wx$total_points - wx$closing_total)
## [1] TRUE
sum(!is.na(wx$wind_mph))
## [1] 863
Cut the wind reading into five bands and compare the average score with the average closing total in each.
w <- wx[!is.na(wx$wind_mph), ]
w$band <- cut(w$wind_mph, c(-Inf, 4, 9, 14, 19, Inf),
labels = c("0-4", "5-9", "10-14", "15-19", "20+"))
band_tab <- do.call(rbind, lapply(split(w, w$band), function(d) data.frame(
games = nrow(d),
avg_points = round(mean(d$total_points), 1),
avg_close = round(mean(d$closing_total), 1),
vs_close = round(mean(d$points_vs_close), 1),
unders = sum(d$points_vs_close < 0),
overs = sum(d$points_vs_close > 0))))
band_tab
## games avg_points avg_close vs_close unders overs
## 0-4 205 45.8 44.5 1.2 106 97
## 5-9 361 45.2 44.2 0.9 161 196
## 10-14 203 42.9 43.6 -0.7 116 85
## 15-19 74 39.8 43.4 -3.6 46 26
## 20+ 20 42.4 42.1 0.2 11 9
A band of 74 games can swing a lot by chance. Resample the games in each band 4,000 times and take the middle 95% of the averages.
set.seed(2026)
boot_ci <- function(x, B = 4000) {
m <- replicate(B, mean(sample(x, replace = TRUE)))
round(quantile(m, c(0.025, 0.975)), 1)
}
t(sapply(split(w$points_vs_close, w$band), boot_ci))
## 2.5% 97.5%
## 0-4 -0.6 3.2
## 5-9 -0.4 2.4
## 10-14 -2.4 0.9
## 15-19 -6.2 -1.0
## 20+ -5.3 6.2
If a range crosses zero, the band’s result cannot be told apart from luck. The 15-19 mph band is the one that stays clear of zero.
Put the line at 15 mph and compare the two groups directly.
w$windy <- w$wind_mph >= 15
aggregate(cbind(total_points, closing_total, points_vs_close) ~ windy, data = w,
FUN = function(x) round(mean(x), 1))
## windy total_points closing_total points_vs_close
## 1 FALSE 44.7 44.2 0.6
## 2 TRUE 40.3 43.1 -2.8
diff_boot <- replicate(4000, {
a <- sample(w$total_points[w$windy], replace = TRUE)
b <- sample(w$total_points[!w$windy], replace = TRUE)
mean(a) - mean(b)
})
round(c(diff = mean(w$total_points[w$windy]) - mean(w$total_points[!w$windy]),
quantile(diff_boot, c(0.025, 0.975))), 1)
## diff 2.5% 97.5%
## -4.4 -7.1 -1.6
Windy games scored about four points fewer. The closing total for those games was only about a point lower, which is the gap the study is about.
Cold and wind often arrive together, so check temperature with wind held in the same model.
m <- lm(points_vs_close ~ I(wind_mph >= 15) + I(temp_f < 32), data = w)
round(summary(m)$coefficients, 2)
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 0.55 0.48 1.15 0.25
## I(wind_mph >= 15)TRUE -3.41 1.41 -2.42 0.02
## I(temp_f < 32)TRUE 0.51 1.65 0.31 0.76
Read the coefficients as points above or below the closing total. The wind term carries the effect. The freezing term is small next to its standard error.
op <- par(mar = c(4, 4, 1, 1))
plot(jitter(w$wind_mph), w$points_vs_close, pch = 16, col = rgb(0.1, 0.3, 0.7, 0.25),
xlab = "Wind (mph)", ylab = "Final points minus closing total")
abline(h = 0, lty = 2)
lines(lowess(w$wind_mph, w$points_vs_close, f = 0.4), lwd = 3, col = "firebrick")
par(op)
The smooth line sits near zero until about 10 mph and dips below it after that. The far right is thin: only 20 games had 20 mph or more, so do not read much into where the line ends.
Wind is the weather that shows up in NFL scores, from about 15 mph up, and in this sample the closing total moved less than the scoring did. Cold on its own did very little. This week’s stadium forecasts are on the NFL weather page; forecasts change, so check again on game day.
The data is free to reuse under CC BY 4.0, with a link back to the study.