Skip to content

STL Decomposition: Trend, Seasonal and Residual, By Hand

10 min read · updated August 11, 2026

STL splits a series into trend, seasonal and remainder. Descriptions of it usually stop there, which leaves the reader unable to answer the questions that actually come up: why the seasonal component is allowed to change, what the low-pass step is for, and why the whole thing is a loop.

The order is the algorithm

STL is Seasonal-Trend decomposition using Loess, published by Robert Cleveland, William Cleveland, Jean McRae and Irma Terpenning in the Journal of Official Statistics in 1990. It is an additive decomposition: the series equals trend plus seasonal plus remainder, and a multiplicative structure is handled by taking logs first.

The core is an inner loop of six steps, run with the trend estimate from the previous pass. Starting from a trend of all zeros:

  1. Detrend. Subtract the current trend estimate from the series.
  2. Cycle-subseries smoothing. Split the detrended series into subseries by position in the period — all the Januaries together, all the Februaries together — and loess-smooth each one along the years, extending one period beyond each end.
  3. Low-pass filter. Pass the smoothed cycle-subseries through three moving averages and then a loess, extracting whatever low-frequency movement leaked into it.
  4. Detrend the seasonal. Subtract that low-pass output from the smoothed cycle-subseries. This is the new seasonal component, and this step is why it does not drift.
  5. Deseasonalise. Subtract the new seasonal component from the original series.
  6. Trend smoothing. Loess-smooth the deseasonalised series to get the new trend, and go back to step one.

Steps three and four are the ones people skip, and they are the ones that make the decomposition well defined. Without them, a slow drift would be free to live in the seasonal component as well as the trend, the two would not be identified, and the split would depend on where the iteration happened to stop.

One pass, worked on twelve numbers

Here is one inner-loop pass on a quarterly series of twelve observations, with a simple mean and a three-point moving average standing in for the loess smoothers. That substitution is not STL — the real thing fits local weighted regressions — but every subtraction below is in exactly the place STL puts it, and it is the placement that is worth carrying away.

y (12 quarterly values, period 4)
  Y1: 10  14  12   8
  Y2: 12  17  14   9
  Y3: 14  20  16  10

1. detrend with T = 0            -> unchanged

2. cycle-subseries means
     Q1: 10, 12, 14  -> 12
     Q2: 14, 17, 20  -> 17
     Q3: 12, 14, 16  -> 14
     Q4:  8,  9, 10  ->  9
   C = 12 17 14 9  12 17 14 9  12 17 14 9

3. low pass of C                 -> L = (12+17+14+9)/4 = 13, everywhere

4. seasonal  S = C - L           -> -1  +4  +1  -4  (repeating)

5. deseasonalise  D = y - S
   Y1: 11  10  11  12
   Y2: 13  13  13  13
   Y3: 15  16  15  14

6. trend  T = 3-point moving average of D
   11.00 10.67 11.00 12.00 12.67 13.00 13.00 13.67 14.67 15.33 15.00 14.00

   remainder R = D - T
    0.00 -0.67  0.00  0.00 +0.33  0.00  0.00 -0.67 +0.33 +0.67  0.00  0.00

Two things in that output are worth pointing at. The seasonal component sums to zero across the four quarters, which is the low-pass step doing its job: the average level was removed from the seasonal and handed to the trend, where it belongs. And the remainder is small but not zero, and it is largest at the points where the trend curves most sharply, which is exactly what a smoother leaves behind. A remainder that is patterned rather than noisy is the signal that the windows are wrong.

What the second pass changes

The loop matters because step two was computed on a detrended series that had not, on the first pass, been detrended at all. The Q2 mean of 17 was taken over values of 14, 17 and 20, which are rising because the trend is rising, not because the Q2 effect is growing. That upward drift inside each subseries contaminated the seasonal estimate.

On the second pass, step one subtracts the trend just computed, so the Q2 subseries becomes roughly 14−10.67, 17−13.00, 20−15.33, which is 3.33, 4.00 and 4.67 — a much flatter set whose mean is a cleaner estimate of the quarterly effect. Cleveland and colleagues recommend two inner iterations when robustness is switched on, and more when it is not. The reason there is a loop at all is that the trend estimate and the seasonal estimate are each defined in terms of the other.

The two windows that decide everything

STL’s behaviour is controlled almost entirely by two loess spans. In the statsmodels implementation, whose documentation records the constraints explicitly, seasonal defaults to 7 and must be an odd integer normally at least 7; trend must also be odd and defaults to the smallest odd integer greater than 1.5 × period divided by (1 − 1.5 / seasonal); low_pass must be odd, at least 3, and defaults to the smallest odd integer greater than the period.

  • The seasonal window is measured in cycles, not observations. It says how many years of the same quarter are smoothed together. A small value lets the seasonal shape evolve; a very large one forces it to be identical every cycle, which is what R’s periodic setting means. Choosing it is a judgement about whether your seasonality is changing, and it is the parameter that most changes the answer.
  • The trend window sets how much movement counts as trend rather than remainder. Too short and the trend chases the noise, leaving a suspiciously flat remainder that has absorbed nothing; too long and genuine level shifts fall into the remainder as a run of same-signed values, which is the pattern a change point detector would flag.
The statsmodels STL reference, which cites the 1990 Cleveland paper

Robustness, and what STL will not do

The inner loop sits inside an outer loop that exists solely to handle outliers. After a pass, the remainder is used to compute robustness weights: points with large absolute remainders get downweighted by a bisquare function, and the inner loop runs again with those weights applied inside every loess. A single spike therefore stops dragging the trend toward itself. This is what the robust flag switches on, and it costs one further set of iterations.

What STL does not do is worth stating plainly, because it is often expected to. It does not forecast — it decomposes history, and a forecast is built by extrapolating the trend and repeating the seasonal separately. It does not handle a non-integer period, and it does not handle multiple periods in one call; multiple seasonalities are normally handled by applying STL repeatedly, shortest period first, an approach usually labelled MSTL. It requires a period to be supplied or inferred, which is why detecting the period is a separate step. And it assumes additivity, so a series whose seasonal swing grows in proportion to its level needs a log transform first, after which the remainder is multiplicative and a value of 0.05 means five per cent rather than five units.