Feature Stores Explained: Why Teams Need Them

A feature store keeps the features a model trains on and the features it is served the same. This guide builds one with Feast on a year of real retail orders and shows the problem it solves: a naive join that looked better in validation, 0.798 against 0.727, then did worse in production.

GloryTecks Team

Training & Placement Faculty

6 May 2026 8 min read

Last updated: 8 October 2026

Share:

Two problems appear as soon as a model goes live. Features get computed one way for training and another way in the serving code, so the model sees different numbers in production. And training data quietly includes the future, because someone joined today's values to last year's outcomes. A feature store addresses both.

This guide explains the idea and builds a small store with Feast on a year of real retail orders. It then measures what point-in-time correctness is worth.

What a feature store does

Feast's documentation describes two foundations: an offline store for historical feature extraction used in training, and an online store for serving features at low latency in production. It also promises point-in-time correct feature sets, so that future values do not leak into training (Feast: introduction).

PartHoldsUsed for
RegistryFeature definitions: names, entities, sources, typesSharing and reuse
Offline storeFull history with timestampsTraining sets, batch scoring
Online storeThe latest value per entityReal-time predictions
MaterialisationJobs copying offline values onlineKeeping serving fresh

Point-in-time correctness

A training row asks what was known about a customer at a moment in the past. Feast answers by joining, for each row, the newest feature values at or before that row's timestamp, scanning backward no further than the feature view's time-to-live (Feast: point-in-time joins). The common mistake is to join a "current" feature table to historical labels: every row then knows things that happened after its own moment.

The experiment

The UCI Online Retail data hold every transaction from 1 December 2010 to 9 December 2011 for a UK-based online gift retailer, many of whose customers are wholesalers, under CC BY 4.0 (UCI: Online Retail). The question: will a customer order again within 30 days? Features are lifetime orders, orders and spend over the last 90 days, and days since the last order, captured as weekly snapshots.

A one-off step keeps valid, identified sales lines:

python
import pandas as pd

# One-off conversion of the UCI Online Retail workbook (CC BY 4.0) to Parquet, keeping valid, identified sales lines
raw = pd.read_excel("Online Retail.xlsx", dtype={"InvoiceNo": str, "StockCode": str})
NOT_PRODUCTS = {"POST", "DOT", "M", "m", "C2", "D", "S", "B", "BANK CHARGES", "AMAZONFEE", "CRUK"}
sales = raw[raw.CustomerID.notna() & (raw.Quantity > 0) & (raw.UnitPrice > 0)
            & ~raw.InvoiceNo.str.startswith("C") & ~raw.StockCode.isin(NOT_PRODUCTS)
            & ~raw.StockCode.str.startswith("gift_")].copy()
sales["customer_id"] = sales.CustomerID.astype(int)
sales["revenue"] = sales.Quantity * sales.UnitPrice
sales[["InvoiceNo", "InvoiceDate", "customer_id", "revenue"]].to_parquet("transactions.parquet", index=False)
print(len(raw), "rows read,", len(sales), "kept,", sales.customer_id.nunique(), "customers,",
      sales.InvoiceDate.min(), "to", sales.InvoiceDate.max())
text
541909 rows read, 396340 kept, 4334 customers, 2010-12-01 08:26:00 to 2011-12-09 12:50:00

The Feast repository uses the local provider, with a SQLite online store:

yaml
project: retail
registry: data/registry.db
provider: local
online_store:
  type: sqlite
  path: data/online_store.db
entity_key_serialization_version: 3

Training rows come from the first Monday of each month from April to October 2011; 7 November 2011 plays the part of production.

python
import contextlib
import io
import os
from datetime import timedelta

import numpy as np
import pandas as pd
from feast import Entity, FeatureStore, FeatureView, Field, FileSource, ValueType
from feast.types import Float32, Int64
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import roc_auc_score

orders = (pd.read_parquet("transactions.parquet")                # UCI Online Retail, cleaned once (see prepare.py)
          .groupby(["customer_id", "InvoiceNo"], as_index=False)
          .agg(when=("InvoiceDate", "min"), revenue=("revenue", "sum")))


def snapshot(at):                                  # every known customer's features as of `at`, from earlier orders only
    past = orders[orders.when < at]
    recent = past[past.when >= at - pd.Timedelta(days=90)]
    features = past.groupby("customer_id").agg(orders_total=("InvoiceNo", "size"), last=("when", "max"))
    features["orders_90d"] = recent.groupby("customer_id").size()
    features["spend_90d"] = recent.groupby("customer_id").revenue.sum()
    features["days_since_last"] = (at - features.pop("last")).dt.days
    features = features.fillna({"orders_90d": 0, "spend_90d": 0.0}).astype({"orders_90d": "int64"})
    return features.reset_index().assign(event_timestamp=at.tz_localize("UTC"))


os.makedirs("data", exist_ok=True)
mondays = pd.date_range("2011-01-03", "2011-12-05", freq="W-MON")
weekly = pd.concat([snapshot(day) for day in mondays], ignore_index=True)
weekly.to_parquet("data/customer_weekly.parquet", index=False)     # the offline store's source table

customer = Entity(name="customer", join_keys=["customer_id"], value_type=ValueType.INT64)
customer_weekly = FeatureView(
    name="customer_weekly", entities=[customer], ttl=timedelta(days=7),
    schema=[Field(name="orders_total", dtype=Int64), Field(name="orders_90d", dtype=Int64),
            Field(name="spend_90d", dtype=Float32), Field(name="days_since_last", dtype=Int64)],
    source=FileSource(path="data/customer_weekly.parquet", timestamp_field="event_timestamp"))
store = FeatureStore(repo_path=".")                                 # feature_store.yaml: local provider, SQLite online store
store.apply([customer, customer_weekly])
NAMES = ["orders_total", "orders_90d", "spend_90d", "days_since_last"]
FEATURES = [f"customer_weekly:{name}" for name in NAMES]


def label_rows(cutoff):                            # customers known before the cutoff; did they order in the next 30 days?
    known = orders[orders.when < cutoff].customer_id.unique()
    buyers = orders[(orders.when >= cutoff) & (orders.when < cutoff + pd.Timedelta(days=30))].customer_id.unique()
    return pd.DataFrame({"customer_id": known, "event_timestamp": cutoff.tz_localize("UTC"), "bought": np.isin(known, buyers)})


train_rows = pd.concat([label_rows(day) for day in pd.date_range("2011-04-01", "2011-10-31", freq="WOM-1MON")], ignore_index=True)
test_rows = label_rows(pd.Timestamp("2011-11-07"))
print(f"offline store: {len(weekly):,} rows ({len(mondays)} weekly snapshots); "
      f"training rows: {len(train_rows):,} from 7 monthly cut-offs; November test rows: {len(test_rows):,}")

point_in_time = store.get_historical_features(entity_df=train_rows, features=FEATURES).to_df()
latest = weekly[weekly.event_timestamp == weekly.event_timestamp.max()]  # the "current" table someone might just join
naive = train_rows.merge(latest[["customer_id"] + NAMES], on="customer_id")
print(f"training rows returned: point-in-time {len(point_in_time):,}, naive join {len(naive):,}")

with contextlib.redirect_stdout(io.StringIO()):                    # materialize prints a progress bar
    store.materialize(start_date=pd.Timestamp("2011-01-01", tz="UTC"), end_date=pd.Timestamp("2011-11-07 01:00", tz="UTC"))
online = pd.DataFrame(store.get_online_features(features=FEATURES, entity_rows=[{"customer_id": int(c)} for c in test_rows.customer_id]).to_dict())
served = test_rows.merge(online, on="customer_id")
offline_view = store.get_historical_features(entity_df=test_rows, features=FEATURES).to_df()
check = served.merge(offline_view, on="customer_id", suffixes=("_online", "_offline"))
same = all(np.allclose(check[f"{n}_online"], check[f"{n}_offline"]) for n in NAMES)
print(f"online features match the offline store for all {len(check):,} test customers: {same}")

print(f"\n{'training data':<26} {'validation AUC, October':>24} {'AUC in November, live features':>31}")
for name, table in (("naive join, latest values", naive), ("point-in-time join", point_in_time)):
    october = table.event_timestamp == pd.Timestamp("2011-10-03", tz="UTC")
    model = HistGradientBoostingClassifier(random_state=0).fit(table.loc[~october, NAMES], table.loc[~october, "bought"])
    validation = roc_auc_score(table.loc[october, "bought"], model.predict_proba(table.loc[october, NAMES])[:, 1])
    live = roc_auc_score(served.bought, model.predict_proba(served[NAMES])[:, 1])
    print(f"{name:<26} {validation:>24.3f} {live:>31.3f}")
text
offline store: 134,401 rows (49 weekly snapshots); training rows: 20,411 from 7 monthly cut-offs; November test rows: 4,042
training rows returned: point-in-time 20,411, naive join 20,411
online features match the offline store for all 4,042 test customers: True

training data               validation AUC, October  AUC in November, live features
naive join, latest values                     0.798                           0.716
point-in-time join                            0.727                           0.738

What the output shows

  • The leak does not show in row counts. Both training sets had 20,411 rows.
  • The naive join looked better in validation, with an AUC of 0.798 against 0.727. Its features came from the final week of data, after the October cut-off, so they described the very purchases the label was asking about.
  • In production it was worse. With features served from the online store as they stood on 7 November, the naive model scored 0.716, while the point-in-time model reached 0.738, close to its own validation figure. Honest validation was lower, and it was the one that held.
  • Training and serving agreed. For all 4,042 test customers, online values matched the offline store, because one definition produced both. That consistency is the other half of the job.

When a team needs one

  • Several models reuse the same features, and duplicated pipelines are drifting apart.
  • Predictions happen in real time and need features in milliseconds.
  • Building point-in-time training sets by hand keeps going wrong.
  • Several teams need to discover and share features.

A single batch model fed by one SQL query rarely needs one. When you do, Feast is the main open-source option, and the major clouds offer managed versions; Amazon SageMaker's version, for example, provides an online store for real-time inference and an offline store for training (AWS: SageMaker documentation).

For computing fresh features from streams, see real-time feature engineering. For designing the features themselves, see feature engineering with examples, and for validation that respects time, see cross-validation in machine learning.

Costs

  • Another system to run, with jobs, storage and on-call.
  • Freshness to guarantee: materialisation must keep up, and stale values need alerts.
  • Ownership: every feature needs an owner and documentation, or the registry turns into a junk drawer.

Starting small

Begin with the features that more than one model needs, or that a live service needs within milliseconds. Define each once, with an owner, a description and a time-to-live that matches how fast it changes: a customer's lifetime order count can be a week old, while a count of card payments in the last hour cannot. Backfill enough history to build training sets, schedule materialisation, and alert when the newest online value is older than its time-to-live. Then move one model onto the store end to end, from training set to live lookups, before inviting other teams.

Common mistakes

  • Joining current values to historical labels.
  • Recomputing features differently in the serving code.
  • No time-to-live, so stale values are served silently.
  • A feature store with a single user.
  • Not monitoring materialisation freshness.

What is a feature store in machine learning?

A system that defines features once and serves them two ways: historical values with timestamps for building training sets, and the latest values at low latency for live predictions, so both come from the same definitions.

What is a point-in-time join?

A join that, for each training row, takes the newest feature values recorded at or before that row's timestamp. It prevents information from after the moment being predicted from leaking into training.

Do I need a feature store?

Not for one batch model built from a single query. It starts to pay off when several models share features, when predictions need fresh features in real time, or when hand-built training joins keep leaking the future.

What is the difference between the online and offline store?

The offline store keeps the full history for training and batch jobs. The online store keeps only the latest value per entity, optimised for fast lookups during live predictions.

Feature stores are part of the Advanced MLOps Concepts module of the MLOps course.

Sources

GloryTecks Team

Training & Placement Faculty

The GloryTecks faculty team mentors learners across Data Science, AI and Data Engineering, with a focus on real-time projects and placement readiness in Hyderabad.

Want to master MLOps?

Join GloryTecks’ expert-led MLOps training in Hyderabad with real-time projects and 100% placement support.

Explore course