datarekha
Time Series June 7, 2026

Why you can't shuffle a time series: splits and leakage

Random k-fold on a time series leaks the future into training. Learn the correct time-based train test split, rolling-origin CV, and how to avoid leakage.

11 min read · by datarekha · time seriesforecastingtrain test splitdata leakagecross-validation

The model validates at 94% accuracy. You deploy it. The first week of live predictions is embarrassing.

This is not a hypothetical. It is the standard failure mode for teams that treat time series data like any other tabular dataset and reach for scikit-learn’s default KFold without pausing to think about what shuffling actually does to a sequence ordered by time. The accuracy number was real — it was just measuring something that can never happen in production: a model that has already seen the future.

Understanding why shuffling breaks here, and what to do instead, is one of the highest-leverage concepts in applied time series work. The mechanism is not subtle once you see it, but it is invisible until you go looking.

Why temporal order is information, not decoration

A time series is a sequence of observations where the index encodes when something happened. Temperature at 2 p.m. is causally downstream of temperature at 1 p.m. Yesterday’s sales volume influences today’s demand. A user’s activity last week shapes the probability that they churn this week. The time index is not just a bookkeeping detail — it encodes the causal direction of the world.

That direction matters for two distinct reasons.

First, autocorrelation. Adjacent observations in a time series are statistically dependent on each other in ways that neighboring rows of a customer table typically are not. A model trained on data from time t can trivially exploit the correlation with time t-1 and t+1 if both are visible during training — but in production, t+1 has not happened yet.

Second, distribution shift. Most real-world time series drift. Seasonality, trend, regime changes, and external shocks mean the distribution of the data in 2023 is not the same as in 2025. A model that trains on 2025 data and then predicts 2024 targets is not being evaluated on whether it generalizes forward. It is being evaluated on its ability to hindcast into a period it has implicitly already absorbed.

When you shuffle and split randomly, both of these mechanisms destroy the validity of your evaluation simultaneously. You get artificially low error not because the model learned something useful, but because it already had the answer.

What leakage looks like here

In standard supervised learning, data leakage means a feature that directly encodes the target — a column derived from the outcome, added before splitting. In time series, the leakage is structural. It lives in the split itself.

The diagram below makes this concrete. On the left, random shuffling scatters red test points throughout the timeline — several test observations fall earlier than training observations that the model already absorbed. On the right, a proper time-ordered split places all training in the past and all test in the future, so no test point is ever upstream of any training point.

Random shuffle (leakage) vs. time-ordered split (correct)Random k-fold — leakagetTRTETRTRTETRTETRTRTETRTEtest points appear before many training points — future leaked into trainingTime-ordered split — correcttTRAIN (past)TEST (future)cutoffevery test point lies strictly after every training point — no leakage
Left: random k-fold scatters test observations throughout the timeline, placing some test points before training points. Right: a clean temporal cut keeps all training in the past and all test in the future.

The correct time-based split

The fix is conceptually simple: sort by time, choose a cutoff, and assign everything before the cutoff to training and everything after to test. No shuffling, no stratification, no random seeds.

import pandas as pd

df = df.sort_values("date").reset_index(drop=True)

cutoff = pd.Timestamp("2024-10-01")
train = df[df["date"] < cutoff]
test  = df[df["date"] >= cutoff]

The size of the test set should reflect the forecast horizon that matters in production. If you are forecasting a week ahead, hold out at least several weeks. If monthly, hold out several months. Holding out only a handful of observations gives you an unreliable estimate regardless of how clean the split is.

One nuance: the split should be made on the raw time index, not on an engineered feature derived from time. Splitting on a standardized numeric timestamp or a cyclic encoding introduces subtle off-by-one errors. Always split on the original datetime column.

Rolling-origin cross-validation: more than one fold

A single train-test split tells you how the model performs on one slice of the future. That slice might be atypical — a holiday period, an external shock, or just a stretch of low variance. Rolling-origin cross-validation, also called forward chaining or time-series cross-validation, gives you multiple folds without ever letting any test point precede its training data.

The idea is to slide the test window forward through time. In each fold, the model trains on all data up to some point and is tested on the next window. Two variants exist:

Expanding window (most common): the training set grows with each fold. Fold 1 trains on months 1–6 and tests on month 7. Fold 2 trains on months 1–7 and tests on month 8. And so on. The model sees more and more history with each fold, which mirrors the real deployment scenario where historical data accumulates over time.

Sliding window: the training set has a fixed length and shifts forward with each fold. Fold 1 trains on months 1–6, fold 2 trains on months 2–7, fold 3 trains on months 3–8. This is appropriate when older data is believed to be less relevant — seasonal businesses, markets after a structural break, or cases where stationarity holds only over short windows.

Expanding-window forward-chaining cross-validationFold 1TRAINTESTFold 2TRAINTESTFold 3TRAINTESTFold 4TRAINTESTtime →Training set expands each fold. Test window slides one step forward. No fold ever sees the future during training.
Expanding-window forward chaining: each fold trains on strictly more past data and evaluates on the next unseen window. Average the test metrics across folds for a robust estimate.

The gap: defending against lagged-feature leakage at the boundary

A clean temporal split is necessary but not sufficient when your features include lagged values or rolling aggregates. Consider a 7-day rolling mean of sales. If the last training observation is day 100, then a feature computed at day 101 already incorporates days 95–101 — some of which are in the test set. The rolling window straddles the boundary and silently pulls future information into what looks like a training-time feature.

The standard defense is an embargo gap: a buffer of observations between the end of training and the start of test that is excluded from both sets. The gap should be at least as long as your longest lag or rolling window.

gap_days = 7

train = df[df["date"] < cutoff - pd.Timedelta(days=gap_days)]
test  = df[df["date"] >= cutoff]

The same principle applies in TimeSeriesSplit: use the gap parameter to drop observations from the leading edge of each test fold.

Feature-engineering leakage: the silent killer

Splitting the data correctly is only half the battle. The other half is the order of operations in your preprocessing pipeline.

The most common form of feature-engineering leakage is computing statistics over the entire series before splitting. If you fit a scaler, imputer, or rolling-mean normalizer on the full dataset and then split, the statistics used to transform the training set already encode information from the test period. The model has not technically seen the raw test targets, but the transformation parameters carry information about the future distribution.

The correct pipeline is: split first, then fit transformers exclusively on training data, then apply (transform only, no refit) to test data.

from sklearn.preprocessing import StandardScaler

scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled  = scaler.transform(X_test)

Anything fitted on X_train and then applied to X_test is safe. Anything fitted on the concatenation of X_train and X_test — even implicitly, via a pandas operation on the full dataframe before the split — is contaminated. See the feature engineering for time series deep-dive for a full treatment of safe versus unsafe feature pipelines.

Look-ahead bias and why it compounds

Look-ahead bias is a broader term for any modeling decision that uses information that would not have been available at the time a prediction is made. In financial and operational forecasting, it is especially dangerous because it compounds: a slightly contaminated feature in one lag propagates forward into derived features, the contamination amplifies at each step, and the final model operates in a state that can never exist at inference time.

Common sources beyond the split itself:

  • Target encoding computed before the split. A category’s historical mean conversion rate, computed on the full dataset, encodes future outcomes into training features.
  • Train-time normalization. Z-scoring the target using the full-series mean and standard deviation before splitting is the same as giving the model a hint about where the test distribution sits.
  • Interpolation across the boundary. If missing values are imputed using forward-fill or spline interpolation computed on the combined dataset, the interpolated training values are drawn partly from the test period.

The diagnostic question is always: “At the time this prediction is supposed to be made, would a production system have access to this value?” If the answer is no, the value cannot appear in the feature set.

A concrete worked example

Imagine you have 24 months of daily e-commerce sales. You want to forecast the next 30 days, and you have built a feature set with 7-day and 28-day lag values plus day-of-week indicators. Here is what correct evaluation looks like:

import pandas as pd
import numpy as np
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.model_selection import TimeSeriesSplit
from sklearn.metrics import mean_absolute_error

df = pd.read_parquet("sales.parquet").sort_values("date")

df["lag_7"]  = df["sales"].shift(7)
df["lag_28"] = df["sales"].shift(28)
df["dow"]    = df["date"].dt.dayofweek

df = df.dropna()

feature_cols = ["lag_7", "lag_28", "dow"]
X = df[feature_cols].values
y = df["sales"].values

tscv = TimeSeriesSplit(n_splits=5, gap=28)

maes = []
for train_idx, test_idx in tscv.split(X):
    X_tr, X_te = X[train_idx], X[test_idx]
    y_tr, y_te = y[train_idx], y[test_idx]

    model = GradientBoostingRegressor(n_estimators=200, max_depth=4)
    model.fit(X_tr, y_tr)

    preds = model.predict(X_te)
    maes.append(mean_absolute_error(y_te, preds))

print(f"CV MAE: {np.mean(maes):.1f} +/- {np.std(maes):.1f}")

Key decisions above: lags are computed before the split because they reference only past values of the series (not future values); the gap=28 parameter in TimeSeriesSplit matches the longest lag, so no test observation’s feature window overlaps its fold’s training period; no scaler is fitted on combined data.

If you ran the same pipeline with KFold(shuffle=True) instead, the reported MAE would typically be 15–40% lower — a number that sounds better but is measuring a fictional scenario. For more on what makes time series fundamentally different from i.i.d. tabular data, see why time series is different.

Frequently asked questions

Q: Can I use random k-fold if I include a time feature in my model?

No. Adding a time index as a feature does not undo the damage of a random split. The model can still absorb the correlation between neighboring shuffled observations during training, and the test set still contains points that are temporally earlier than some training points. The split itself is the mechanism of leakage, not the feature set.

Q: How many folds should I use in TimeSeriesSplit?

Five is a reasonable default for datasets with at least a few hundred observations per fold. The practical constraint is that each fold’s test set should be large enough to represent a meaningful forecast horizon — a fold with only 10 test observations gives an unstable MAE estimate. More folds help with variance of the CV estimate but shrink each test set. Start with 5 and check that each test set covers at least two full seasonal cycles.

Q: What is the difference between a gap and a walk-forward validation hold-out?

A gap is a buffer of observations dropped from both the end of training and the start of test within a single split, designed to prevent lagged features from straddling the boundary. A walk-forward hold-out is a final out-of-sample test set that is withheld entirely from cross-validation and used only for a single final evaluation after all hyperparameter choices have been made. Both are important and serve different purposes: the gap guards against feature leakage, the final hold-out guards against overfitting the CV metric.

Q: My time series is short. Can I still use TimeSeriesSplit?

With a short series you face a genuine trade-off: more folds produce smaller training sets and may underfit, while fewer folds produce more variance in the CV estimate. One pragmatic approach is to use 3 folds with no gap (if your features are short-lag), accept slightly noisy estimates, and compensate by averaging performance over multiple random seeds for any stochastic models. What you should not do is fall back to shuffled k-fold — a noisier but honest estimate is always preferable to a precise but contaminated one.

Skip to content