Almost every seasonal decomposition I see in R is written the same way: put the series in a ts object, call stl(x, s.window = "periodic"), done. That argument says the seasonal pattern is identical in every year of the series. I wanted to know how much that assumption costs on a long series where the seasonality has had time to move, so I took 34 years of US retail sales and checked.
It costs a lot, and the reason is interesting on its own: Christmas is not what it was. In 1992, December retail sales ran 23% above the trend line. In 2025 they ran 11% above it. The largest seasonal swing in US retail has roughly halved over one series, and s.window = "periodic" averages the two eras into a single number that fits neither.
Getting the data
The Census Bureau publishes monthly retail sales in two forms, unadjusted and seasonally adjusted, and FRED serves both as keyless CSV. RSXFSN is the raw series, RSXFS is the official adjustment. Having the official version next to the raw one is what makes this checkable: I can adjust the raw series myself and see how close I land.
library(dplyr)
library(readr)
library(tidyr)
library(ggplot2)
fred <- function(id) {
read_csv(url(paste0("https://fred.stlouisfed.org/graph/fredgraph.csv?id=", id)),
show_col_types = FALSE) |>
setNames(c("date", "value"))
}
sales <- inner_join(fred("RSXFSN"), fred("RSXFS"), by = "date",
suffix = c("_raw", "_adj")) |>
mutate(year = as.integer(format(date, "%Y")),
month = as.integer(format(date, "%m")))
range(sales$date)
## [1] "1992-01-01" "2026-07-01"
One practical note, since it cost me a few minutes: with readr 2.2.0, handing read_csv() the FRED address directly failed with an HTTP/2 stream error. Wrapping it in base R’s url() connection, as above, reads it fine.
Why the raw series is unreadable month to month
Retail sales fall in January every single year. In the 34 Januaries since 1993 the unadjusted series dropped an average of 22.3%, never less than 16.0% and never more than 29.7%. That is the calendar, not the economy. Across the whole series the month-over-month change in the raw data carries the opposite sign to the adjusted change in 162 of 414 months, 39% of the time, which is why nobody reads growth off the raw line.

How do I seasonally adjust a time series in R?
Fit stl() to a ts object and subtract the seasonal component it returns. Because retail seasonality scales with the level of sales rather than adding a fixed number of dollars, take logs first, decompose additively, then exponentiate back. This block runs on its own:
library(readr)
raw <- read_csv(url("https://fred.stlouisfed.org/graph/fredgraph.csv?id=RSXFSN"),
show_col_types = FALSE)
y <- ts(log(raw$RSXFSN), start = c(1992, 1), frequency = 12)
fit_fixed <- stl(y, s.window = "periodic") # one pattern for every year
fit_evolving <- stl(y, s.window = 7) # pattern free to drift
adj_fixed <- exp(y - fit_fixed$time.series[, "seasonal"])
adj_evolving <- exp(y - fit_evolving$time.series[, "seasonal"])
s.window controls how the twelve monthly subseries are smoothed. With "periodic" each month gets one constant for the whole sample. With an odd number, stl() runs a loess over that month’s own values with a span of that many observations, so December 2025 is estimated mostly from nearby Decembers. s.window has no default in R 4.6.1, it is a required argument, and "periodic" is the value that gets pasted around.
What s.window = "periodic" costs
The fixed fit puts December 16.4% above trend in every year of the series. The evolving fit starts at 23.2% in 1992 and ends at 11.3% in 2025.

Measured against the Census Bureau’s own adjustment, the fixed version is off by an average of 1.12% of the level and the evolving version by 0.69%. The error is not noise, it is concentrated in December, understating recent ones and overstating the early 1990s. For December 2025, raw sales of $716.9B come out as $615.8B under the fixed pattern and $643.9B under the evolving one, against the official $634.8B. The fixed adjustment misses by $19 billion in one month, all of it in the direction of a Christmas that looks weaker than it was.
decompose() has the same limitation with no way out. It estimates the seasonal component as the mean of the detrended values for each month, so its December factor is a constant 16.4% by construction. If the seasonality in your series has drifted, decompose() cannot see it.
What actually changed in retail
December’s share of annual US retail sales fell from 10.6% in 1992 to 1994 to 9.4% in 2023 to 2025. The obvious explanation, that Black Friday pulled the season into November, is not what the data shows: November’s seasonal factor went from 2.5% to 1.9% across the same span, essentially flat, while December’s fell 11.8 points. What December lost went to the ordinary months, with January, March and May each picking up two to three points of seasonal factor. Holiday shopping did not move earlier so much as the rest of the year caught up.
Two caveats on the comparison. The official series is produced with X-13ARIMA-SEATS, which also corrects for trading days and moving holidays, so a small gap from any stl() fit is expected and is not evidence that stl() went wrong. And s.window is a judgment call rather than a fitted parameter: on this series spans of 5, 7 and 11 all sit within 0.9% of the official adjustment and leave the Decembers since 2020 unbiased, while a span of 21 puts them 1.3% low again, having smoothed the drift away. When the question is whether the seasonality itself changed, that is the argument to look at first.