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.

Load the data

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

Wind bands

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

How sure can we be?

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.

Windy against calm

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.

Is it really cold, not wind?

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.

A picture

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.

What to take from it

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.