Stationarity
A stationary series keeps the same average and the same wobble forever, and most forecasting models quietly assume yours does too.
- 16 min read
- 3 reading levels
- Updated
Read these first
On this page 10
One lesson, three depths. Pick the one that fits you today — you can switch any time.
Beginner — No maths. Plain English.
The short answer
A series is stationary when its behaviour does not change over time — same average, same size of wobble, forever.
The analogy you have already lived
Sit in a room with the air conditioner set to twenty-four degrees. The temperature wobbles a little. It drifts up when someone opens the door, then the AC pulls it back down.
Come back in three hours and the room is at twenty-four degrees again. Come back tomorrow, same thing. Nothing about the room's behaviour changed. That room is stationary.
Now stand on your balcony from January to June. The temperature climbs, week after week, and never returns. Knowing that it was pleasant in January tells you nothing useful about May. That balcony is not stationary.
The AC room is easy to describe: one number and a wobble size. The balcony needs a whole story about the seasons.
Why models care so much
Most classical forecasting methods learn one set of rules and apply it forever. They learn the average, they learn how strongly today follows yesterday, and then they project that forward.
If the average keeps moving, there is no single average to learn. The model computes one anyway, and gets a number that describes neither the past nor the future.
Think of two questions. What is the usual price of a cup of tea? And what is the price of a cup of tea, across thirty years? The first question is answerable. The second one has no single answer.
The three things that must stay put
A series is treated as stationary when all three of these hold:
- The average stays put. No slow climb, no slow fall.
- The size of the wobble stays put. The ups and downs are as big in year ten as in year one.
- The link between neighbours stays put. How much today depends on yesterday does not change over time.
Fail any one and the series is non-stationary.
The fix, and it is simpler than you expect
Stop modelling the level. Model the change.
Your electricity bill climbs every year, so the level is not stationary. But look at the change from one month to the next. Up nine units, down four, up eleven. That change hovers around a small number and stays there.
That move is called differencing: replacing each value with how much it moved since the previous one.
units : 90 99 95 106 104 115
change : none up 9 down 4 up 11 down 2 up 11
^^^^
the first change cannot exist,
there is nothing before itThe climbing series became a flat, wobbling series. Notice the cost: you lost a row. Differencing always costs you the first observation.
What about the festival bump?
Differencing against yesterday removes a trend. It does not remove a yearly pattern.
For that, you subtract the value from the same month last year instead. October minus last October. That is called seasonal differencing, and it wipes out a repeating pattern in one move.
Sometimes you need both. Do the seasonal one first, then check whether a trend is still left.
Where you have already seen this thinking
- News reports say "inflation was six percent" rather than quoting the price level. That is a difference.
- A fitness app shows "two kilograms lost this month", not your weight since birth.
- Your bank statement highlights the change in balance, not only the balance.
- Cricket commentary quotes the run rate, which is runs per over — a change, not a total.
People reach for changes naturally, because a level that keeps moving is hard to reason about.
What is honestly hard here
This concept is confusing for almost everyone the first time. Read this part twice — that is normal.
Two things make it slippery.
First, stationarity is about the rules that generate the data, not about the data looking flat on a chart. A series can wander a long way and still be stationary, and a series can look calm and not be.
Second, you cannot prove a series is stationary. You can run a test, and the test gives you evidence, not certainty. Tests disagree with each other on real data all the time.
There is also a real cost to differencing. Over-differencing damages your data. If you difference a series that was already fine, you inject noise and make forecasts worse. More differencing is not safer.
Remember this
- Stationary means the behaviour does not change over time: same average, same wobble.
- Model the change, not the level, when the level keeps climbing.
- Difference once if you need to. Twice is rare. Three times is almost always a mistake.
What to learn next
- Moving averages — the simplest way to see a trend you are about to remove.
- ARIMA — the model built directly on differencing, where the
Istands for integrated. - Multivariate forecasting — where spurious correlation does the most damage.
Developer — Code and libraries.
Setup
pip install numpy pandas statsmodelsSeeing non-stationarity, then removing it
The cheapest check needs no test at all: split the series in half and compare the two averages.
import numpy as np
import pandas as pd
from statsmodels.tsa.stattools import adfuller
rng = np.random.default_rng(5)
n = 240
months = pd.date_range("2006-01-01", periods=n, freq="MS")
# A household's monthly electricity units: creeping upward as appliances pile up.
units = pd.Series(np.linspace(90, 260, n) + rng.normal(0, 9, n), index=months, name="units")
def describe(name, s):
s = s.dropna()
half = len(s) // 2
stat, p = adfuller(s, autolag="AIC")[:2]
print(f"{name:22s} mean(first half)={s[:half].mean():7.2f} "
f"mean(second half)={s[half:].mean():7.2f} ADF p={p:.4f}")
describe("raw units", units)
describe("change from last month", units.diff())raw units mean(first half)= 130.68 mean(second half)= 218.54 ADF p=0.9028 change from last month mean(first half)= 0.80 mean(second half)= 0.63 ADF p=0.0000
The raw series averages 131 units early and 219 units late. There is no single "average electricity use" to learn.
After one .diff(), the two halves average 0.80 and 0.63. That is one stable number describing the whole twenty years.
Reading the ADF test without getting it backwards
The Augmented Dickey-Fuller test is the standard check. Its null hypothesis is awkward and people invert it constantly, so here it is stated plainly:
- Null hypothesis: the series is NOT stationary (it has a unit root, meaning it wanders without returning).
- A small p-value (below 0.05) is evidence against the null, so you conclude stationary.
- A large p-value means you failed to find evidence. That is not proof of non-stationarity.
Above, the raw series gave p = 0.9028, so no evidence of stationarity. The differenced series gave p = 0.0000, which is strong evidence of stationarity.
One important trap. ADF with default settings tests against a constant-only alternative. A series that wobbles tightly around a straight upward line is trend-stationary, and ADF will still reject it unless you pass regression="ct" to include a deterministic trend. Trend-stationary series should be detrended by regression, not differenced.
Why this matters more than a p-value: spurious correlation
Here is the reason stationarity is not academic housekeeping. Two series that have nothing to do with each other will look strongly related if both wander.
import numpy as np
import pandas as pd
rng = np.random.default_rng(42)
n = 300
# Two completely unrelated wandering series. Neither knows the other exists.
a = pd.Series(np.cumsum(rng.normal(0, 1, n)))
b = pd.Series(np.cumsum(rng.normal(0, 1, n)))
print("correlation of the raw series :", round(a.corr(b), 3))
print("correlation of month-to-month change:", round(a.diff().corr(b.diff()), 3))
big = 0
for seed in range(200):
r = np.random.default_rng(seed)
x = np.cumsum(r.normal(0, 1, n)); y = np.cumsum(r.normal(0, 1, n))
if abs(np.corrcoef(x, y)[0, 1]) > 0.5:
big += 1
print(f"out of 200 unrelated pairs, {big} showed a correlation stronger than 0.5")correlation of the raw series : 0.548 correlation of month-to-month change: 0.031 out of 200 unrelated pairs, 80 showed a correlation stronger than 0.5
Read that last line again. Forty percent of completely unrelated pairs looked more than half correlated. Every one of those correlations is an illusion produced by two things wandering at the same time.
Differencing collapsed the correlation to 0.031, which is the truth.
This is why "our sales correlate with X at 0.8" means nothing on raw trending data. Correlate the changes, and only then be interested.
Seasonal differencing
For a yearly pattern in monthly data, subtract the value twelve rows back.
import numpy as np
import pandas as pd
from statsmodels.tsa.stattools import adfuller
rng = np.random.default_rng(2)
months = pd.date_range("2015-01-01", periods=120, freq="MS")
festival = np.tile([-20, -25, -10, 0, 5, -5, 0, 15, 45, 90, 30, 10], 10)
sales = pd.Series(np.linspace(200, 400, 120) + festival + rng.normal(0, 7, 120), index=months)
for name, s in [
("raw", sales),
("difference by 1 month", sales.diff(1)),
("difference by 12 months", sales.diff(12)),
("both", sales.diff(12).diff(1)),
]:
s = s.dropna()
print(f"{name:24s} n={len(s):4d} spread={s.std():6.2f} ADF p={adfuller(s)[1]:.4f}")raw n= 120 spread= 68.78 ADF p=0.9632 difference by 1 month n= 119 spread= 29.20 ADF p=0.0000 difference by 12 months n= 108 spread= 10.08 ADF p=0.0000 both n= 107 spread= 14.12 ADF p=0.0018
Four things are worth staring at.
The seasonal difference shrank the spread most, from 68.78 down to 10.08. The festival pattern was the biggest source of variation, and subtracting last October from this October removed it in one move.
The seasonal difference also flattened the trend. Subtracting the value twelve months ago from a steadily climbing series leaves roughly the same amount of climb every time — a constant. A constant offset is stationary, which is why ADF passes at p = 0.0000. One operation handled both problems here.
Doing both made things worse. The spread rose from 10.08 to 14.12 and the p-value got less convincing. That is over-differencing showing its face. Every extra difference adds variance, and the second one bought nothing.
Row counts fall. Twelve rows vanish with a seasonal difference, and thirteen with both. On four years of monthly data, twelve rows is a quarter of your history.
Common mistakes
Reading the ADF p-value backwards. A high p-value does not mean stationary. Write the null hypothesis on a sticky note: "null = has a unit root = not stationary".
Differencing until the p-value is tiny. The test will keep obliging. Use the smallest number of differences that passes, and check the autocorrelation plot: a large negative spike at lag one is the signature of over-differencing.
Differencing then forgetting to undo it. Your model now forecasts changes. To get a level forecast back you must add the changes onto the last known value with np.cumsum. Forgetting this produces forecasts near zero, and it is a bug people ship.
Trusting one test. Run ADF alongside KPSS, whose null is the opposite (from statsmodels.tsa.stattools import kpss). If ADF says stationary and KPSS agrees, you have real evidence. If they disagree, look at the chart.
Differencing when the tool already does it. ARIMA(p, d, q) differences internally through d, and SARIMAX handles the seasonal difference through D. Passing a pre-differenced series and setting d=1 differences twice.
Try it yourself
Change rng.normal(0, 9, n) in the first script to rng.normal(0, 60, n), so the noise is much larger relative to the trend. The trend itself is untouched.
Predict whether the raw ADF p-value goes up or down before you run it. It moves from 0.9028 to roughly 0.40 — still not rejecting, but far less certain.
Then write one sentence explaining why the same trend produced a different verdict. The answer is that the test measures a signal-to-noise ratio, not the presence of a trend, and that is exactly why you should look at a chart alongside it.
What to learn next
- Moving averages — the simplest way to see a trend you are about to remove.
- ARIMA — the model built directly on differencing, where the
Istands for integrated. - Multivariate forecasting — where spurious correlation does the most damage.
Researcher — Mathematics and papers.
Two definitions
Strict stationarity. ${Y_t}$ is strictly stationary if for every $k$, every set of indices $t_1 < \dots < t_k$ and every shift $h$,
$$ (Y_{t_1}, \dots, Y_{t_k}) \;\overset{d}{=}\; (Y_{t_1+h}, \dots, Y_{t_k+h}) $$
The entire joint distribution is shift-invariant. This is far stronger than anything you can test for.
Weak (second-order) stationarity. ${Y_t}$ is weakly stationary if
$$ \mathbb{E}[Y_t] = \mu \;\; \forall t, \qquad \mathrm{Var}(Y_t) < \infty, \qquad \mathrm{Cov}(Y_t, Y_{t+k}) = \gamma(k) \;\; \forall t $$
- $\mu$ — a constant mean, not indexed by $t$.
- $\gamma(k)$ — the autocovariance at lag $k$, depending on the separation only, not on where you are in the series.
Weak stationarity is the working definition throughout forecasting. Under Gaussianity the two coincide, since a Gaussian process is determined by its first two moments.
Unit roots against deterministic trends
These are different diseases with different cures, and conflating them is the most common error in applied work.
Difference-stationary (stochastic trend). $Y_t = Y_{t-1} + \delta + \varepsilon_t$. Shocks are permanent: $\mathrm{Var}(Y_t \mid Y_0) = t\sigma^2$, growing without bound. The cure is differencing.
Trend-stationary (deterministic trend). $Y_t = \alpha + \beta t + \varepsilon_t$ with $\varepsilon_t$ stationary. Shocks are transient and the series returns to the line. The cure is regression on $t$, then modelling the residual.
Differencing a trend-stationary series induces a non-invertible MA(1) unit root in the errors: if $Y_t = \alpha + \beta t + \varepsilon_t$ then $\Delta Y_t = \beta + \varepsilon_t - \varepsilon_{t-1}$, which has an MA polynomial root exactly on the unit circle. Maximum likelihood then behaves badly at the boundary. This is over-differencing stated precisely.
Nelson and Plosser (1982), Trends and random walks in macroeconomic time series, is the paper that made this distinction economically consequential.
The ADF test
The regression estimated is
$$ \Delta y_t = \alpha + \beta t + \gamma y_{t-1} + \sum_{i=1}^{p} \delta_i \Delta y_{t-i} + \varepsilon_t $$
- $\gamma$ — the coefficient of interest. $H_0: \gamma = 0$ (unit root) against $H_1: \gamma < 0$ (stationary).
- $\alpha, \beta t$ — the deterministic terms, included or excluded via the
regressionargument ("n","c","ct","ctt"). - $p$ — the number of augmenting lags, absorbing serial correlation in $\varepsilon_t$; chosen by AIC or BIC.
The statistic is $\hat{\gamma}/\mathrm{se}(\hat{\gamma})$, but its null distribution is not Student's $t$. It follows a Dickey-Fuller distribution, obtained from functionals of Brownian motion, and the critical values depend on which deterministic terms were included. Using $t$-tables here gives badly wrong answers.
Two properties matter operationally:
- Low power against near-unit roots. For $\gamma$ corresponding to an AR coefficient of, say, 0.95, ADF frequently fails to reject at moderate $T$. Failure to reject is weak evidence.
- Sensitivity to structural breaks. A single level shift makes a stationary series look like a unit-root process. Perron (1989) showed this and proposed a break-augmented test; Zivot and Andrews (1992) made the break date endogenous.
KPSS, and why you run both
Kwiatkowski, Phillips, Schmidt and Shin (1992) reverse the hypotheses. The model is
$$ y_t = \xi t + r_t + \varepsilon_t, \qquad r_t = r_{t-1} + u_t, \quad u_t \sim \mathrm{iid}(0, \sigma_u^2) $$
with $H_0: \sigma_u^2 = 0$, that is, the random-walk component is absent and the series is (trend-)stationary. The statistic is an LM statistic built from partial sums of residuals, normalised by a long-run variance estimate.
Confirmatory analysis uses the pair:
| ADF | KPSS | Conclusion |
|---|---|---|
| reject | fail to reject | Stationary. Both agree. |
| fail to reject | reject | Unit root. Difference it. |
| fail to reject | fail to reject | Uninformative. Not enough data. |
| reject | reject | Likely a structural break or fractional integration. |
The fourth row is the interesting one and gets ignored most often.
Choosing the order of differencing
Rather than iterating tests by hand, the KPSS-based procedure of Hyndman and Khandakar (2008) is what pmdarima.arima.ndiffs and R's forecast::ndiffs implement: difference, retest, stop when KPSS stops rejecting, capped at $d \le 2$. The seasonal analogue uses the OCSB or Canova-Hansen test for $D$, capped at $D \le 1$.
Beyond integer orders, fractional integration $(1-L)^d y_t = \varepsilon_t$ with $d \in (0, 0.5)$ describes long memory: autocorrelations decaying hyperbolically rather than geometrically. Granger and Joyeux (1980) and Hosking (1981) introduced ARFIMA for this. Estimating $d$ by the Geweke-Porter-Hudak log-periodogram regression is standard.
Spurious regression, formally
Granger and Newbold (1974), Spurious regressions in econometrics, showed by simulation that regressing one independent random walk on another yields $t$-statistics that reject at rates far above nominal, with $R^2$ values that look publishable. Phillips (1986) supplied the asymptotic theory: under independent unit roots the OLS estimator does not converge in probability at all, and $t$-statistics diverge at rate $\sqrt{T}$.
The exception is cointegration. If two $I(1)$ series admit a linear combination that is $I(0)$, the regression is meaningful and superconsistent, converging at rate $T$ rather than $\sqrt{T}$. Engle and Granger (1987) gave the two-step test; Johansen (1991) gave the full-system maximum-likelihood treatment. The practical rule: differencing two cointegrated series throws away the long-run relationship, and an error-correction model is the right form. This matters directly in multivariate forecasting.
Reading
- Dickey and Fuller (1979), Distribution of the estimators for autoregressive time series with a unit root, JASA 74.
- Kwiatkowski, Phillips, Schmidt and Shin (1992), Testing the null hypothesis of stationarity against the alternative of a unit root, Journal of Econometrics 54.
- Granger and Newbold (1974), Spurious regressions in econometrics, Journal of Econometrics 2.
- Engle and Granger (1987), Co-integration and error correction, Econometrica 55(2).
- Perron (1989), The Great Crash, the oil price shock, and the unit root hypothesis, Econometrica 57(6).
- Hamilton, Time Series Analysis (1994), chapters 15 to 19 — the standard graduate treatment.
What to learn next
- Moving averages — the simplest way to see a trend you are about to remove.
- ARIMA — the model built directly on differencing, where the
Istands for integrated. - Multivariate forecasting — where spurious correlation does the most damage.