How to Check for Stationarity in Python

Stationarity is a fundamental assumption underlying most time series forecasting models. A stationary time series has statistical properties that don't change over time. Specifically, this means:

Key Insights

  • Stationarity requires constant mean, variance, and autocorrelation over time—a prerequisite for most time series forecasting models including ARIMA and exponential smoothing methods.
  • The Augmented Dickey-Fuller (ADF) and KPSS tests provide complementary perspectives: ADF tests for non-stationarity while KPSS tests for stationarity, so use both to avoid misleading conclusions.
  • Visual inspection through rolling statistics and decomposition plots should always precede statistical tests—they reveal patterns that raw test statistics might obscure and help you choose appropriate transformations.

Introduction to Stationarity

Stationarity is a fundamental assumption underlying most time series forecasting models. A stationary time series has statistical properties that don’t change over time. Specifically, this means:

  • Constant mean: The average value doesn’t trend upward or downward
  • Constant variance: The spread of values remains stable
  • Constant autocorrelation: The relationship between observations at different lags stays consistent

Why does this matter? Models like ARIMA, exponential smoothing, and many machine learning algorithms assume that patterns observed in historical data will continue into the future. If your data has a trend or changing variance, predictions based on past patterns become unreliable. A model trained on data from 2020 won’t generalize well to 2024 if the underlying distribution has shifted.

Non-stationary data is the norm in real-world scenarios. Stock prices trend upward, seasonal sales fluctuate with increasing magnitude, and economic indicators drift over decades. The good news is that most non-stationary series can be transformed into stationary ones through differencing, detrending, or other techniques.

Visual Inspection Methods

Before running statistical tests, plot your data. Visual inspection provides intuition that raw numbers can’t convey.

Start with a basic time series plot overlaid with rolling statistics:

import pandas as pd
import matplotlib.pyplot as plt
import numpy as np

# Generate sample non-stationary data
np.random.seed(42)
trend = np.linspace(0, 10, 200)
seasonality = 3 * np.sin(np.linspace(0, 8*np.pi, 200))
noise = np.random.normal(0, 1, 200)
data = trend + seasonality + noise

df = pd.DataFrame({'value': data}, index=pd.date_range('2020-01-01', periods=200))

# Calculate rolling statistics
rolling_mean = df['value'].rolling(window=12).mean()
rolling_std = df['value'].rolling(window=12).std()

# Plot
fig, ax = plt.subplots(figsize=(12, 6))
ax.plot(df['value'], label='Original', alpha=0.7)
ax.plot(rolling_mean, label='Rolling Mean (12)', color='red', linewidth=2)
ax.plot(rolling_std, label='Rolling Std (12)', color='green', linewidth=2)
ax.legend()
ax.set_title('Time Series with Rolling Statistics')
plt.tight_layout()
plt.show()

In a stationary series, the rolling mean and standard deviation should remain relatively flat. If you see upward trends in the rolling mean or increasing volatility in the rolling standard deviation, your data is likely non-stationary.

Seasonal decomposition provides another powerful visualization:

from statsmodels.tsa.seasonal import seasonal_decompose

# Decompose the time series
decomposition = seasonal_decompose(df['value'], model='additive', period=20)

fig, axes = plt.subplots(4, 1, figsize=(12, 10))
decomposition.observed.plot(ax=axes[0], title='Observed')
decomposition.trend.plot(ax=axes[1], title='Trend')
decomposition.seasonal.plot(ax=axes[2], title='Seasonal')
decomposition.resid.plot(ax=axes[3], title='Residual')
plt.tight_layout()
plt.show()

A strong trend component indicates non-stationarity. The residuals should look like white noise if the decomposition successfully isolated all systematic patterns.

Statistical Tests for Stationarity

Visual inspection is subjective. Statistical tests provide objective measures.

Augmented Dickey-Fuller (ADF) Test

The ADF test is the workhorse of stationarity testing. Its null hypothesis states that a unit root is present—meaning the series is non-stationary. You want to reject this null hypothesis.

from statsmodels.tsa.stattools import adfuller

def adf_test(series, name=''):
    """
    Perform Augmented Dickey-Fuller test
    """
    result = adfuller(series.dropna())
    
    print(f'ADF Test Results for {name}:')
    print(f'ADF Statistic: {result[0]:.6f}')
    print(f'p-value: {result[1]:.6f}')
    print(f'Critical Values:')
    for key, value in result[4].items():
        print(f'   {key}: {value:.3f}')
    
    if result[1] <= 0.05:
        print(f"Result: Reject null hypothesis. Data is stationary.\n")
    else:
        print(f"Result: Fail to reject null hypothesis. Data is non-stationary.\n")
    
    return result

# Test our sample data
adf_result = adf_test(df['value'], 'Original Series')

Interpretation guidelines:

  • p-value < 0.05: Reject the null hypothesis; data is stationary
  • ADF statistic < critical values: Additional evidence for stationarity
  • p-value > 0.05: Data is likely non-stationary

The ADF test is conservative. It may fail to detect stationarity in series with structural breaks or near-unit-root processes.

KPSS Test

The KPSS (Kwiatkowski-Phillips-Schmidt-Shin) test flips the script. Its null hypothesis assumes stationarity. Use it alongside ADF to avoid contradictory conclusions.

from statsmodels.tsa.stattools import kpss

def kpss_test(series, name=''):
    """
    Perform KPSS test
    """
    result = kpss(series.dropna(), regression='ct', nlags='auto')
    
    print(f'KPSS Test Results for {name}:')
    print(f'KPSS Statistic: {result[0]:.6f}')
    print(f'p-value: {result[1]:.6f}')
    print(f'Critical Values:')
    for key, value in result[3].items():
        print(f'   {key}: {value:.3f}')
    
    if result[1] < 0.05:
        print(f"Result: Reject null hypothesis. Data is non-stationary.\n")
    else:
        print(f"Result: Fail to reject null hypothesis. Data is stationary.\n")
    
    return result

# Test our sample data
kpss_result = kpss_test(df['value'], 'Original Series')

The regression parameter matters:

  • 'c': Test for level stationarity (constant mean)
  • 'ct': Test for trend stationarity (constant trend)

Use both tests together:

  • ADF rejects + KPSS fails to reject: Data is stationary
  • ADF fails to reject + KPSS rejects: Data is non-stationary
  • Both reject or both fail to reject: Inconclusive; data may be near-stationary or have structural issues

Transforming Non-Stationary Data

When tests confirm non-stationarity, transform your data.

Differencing removes trends by subtracting consecutive observations:

# First-order differencing
df['diff_1'] = df['value'].diff()

# Second-order differencing (if needed)
df['diff_2'] = df['diff_1'].diff()

# Test differenced data
adf_test(df['diff_1'], 'First Difference')
kpss_test(df['diff_1'], 'First Difference')

Log transformation stabilizes variance, particularly useful for data with exponential growth:

# Log transformation (only for positive values)
df['log_value'] = np.log(df['value'] + abs(df['value'].min()) + 1)

# Log + differencing
df['log_diff'] = df['log_value'].diff()

adf_test(df['log_diff'], 'Log Difference')

Seasonal differencing handles seasonal patterns:

# Seasonal differencing (period = 12 for monthly data)
df['seasonal_diff'] = df['value'].diff(12)

adf_test(df['seasonal_diff'], 'Seasonal Difference')

Visualize transformations to ensure they make sense:

fig, axes = plt.subplots(3, 1, figsize=(12, 10))

df['value'].plot(ax=axes[0], title='Original Series')
df['diff_1'].plot(ax=axes[1], title='First Difference')
df['log_diff'].plot(ax=axes[2], title='Log Difference')

plt.tight_layout()
plt.show()

Practical Example with Real Dataset

Let’s apply everything to airline passenger data—a classic non-stationary time series:

# Load data (using seaborn's built-in dataset)
import seaborn as sns

flights = sns.load_dataset('flights')
ts = flights.pivot(index='year', columns='month', values='passengers')
ts = ts.stack().reset_index(name='passengers')
ts['date'] = pd.to_datetime(ts['year'].astype(str) + '-' + ts['month'].astype(str))
ts = ts.set_index('date')['passengers']

# Step 1: Visual inspection
fig, axes = plt.subplots(2, 1, figsize=(12, 8))

ts.plot(ax=axes[0], title='Original Airline Passengers')
rolling_mean = ts.rolling(window=12).mean()
rolling_std = ts.rolling(window=12).std()
ts.plot(ax=axes[1], alpha=0.5, label='Original')
rolling_mean.plot(ax=axes[1], label='Rolling Mean', color='red')
rolling_std.plot(ax=axes[1], label='Rolling Std', color='green')
axes[1].legend()
plt.tight_layout()
plt.show()

# Step 2: Statistical tests on original data
print("=== ORIGINAL DATA ===")
adf_test(ts, 'Airline Passengers')
kpss_test(ts, 'Airline Passengers')

# Step 3: Apply log transformation
ts_log = np.log(ts)

# Step 4: Apply differencing
ts_log_diff = ts_log.diff().dropna()

# Step 5: Test transformed data
print("=== LOG DIFFERENCED DATA ===")
adf_test(ts_log_diff, 'Log Differenced')
kpss_test(ts_log_diff, 'Log Differenced')

# Step 6: Visualize transformation
fig, axes = plt.subplots(2, 1, figsize=(12, 8))
ts_log.plot(ax=axes[0], title='Log Transformed')
ts_log_diff.plot(ax=axes[1], title='Log Differenced (Stationary)')
plt.tight_layout()
plt.show()

This workflow demonstrates the complete process: visual inspection reveals an upward trend and increasing variance, statistical tests confirm non-stationarity, log transformation stabilizes variance, and differencing removes the trend. Final tests confirm stationarity.

Conclusion & Best Practices

Checking for stationarity isn’t a one-step process. Follow this workflow:

  1. Always start with plots. Rolling statistics and decomposition reveal what’s happening in your data.

  2. Use both ADF and KPSS tests. They provide complementary information and catch edge cases where one test alone might mislead.

  3. Choose transformations based on your data’s characteristics. Use log transforms for exponential growth, differencing for trends, and seasonal differencing for periodic patterns.

  4. Don’t over-difference. Each differencing operation removes information. Test after each transformation and stop when you achieve stationarity.

  5. Remember the context. Some models (like trend-stationary models) can handle certain types of non-stationarity. Understand your modeling approach before forcing stationarity.

  6. Document your transformations. You’ll need to reverse them when interpreting forecasts or making predictions.

Stationarity testing is a critical preprocessing step, not a theoretical exercise. Master these techniques, and your time series models will be more reliable and your forecasts more accurate.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.