This guide takes you from an empty machine to a working feature store serving
the AcmeCorp retail-risk features shipped in this package. It assumes Python
3.9+ and basic comfort with the command line.
Feast is modular: you install the framework plus the extras for the stores you
use. For the default config in feature_repo/feature_store.yaml (Redis online,
file offline) you need:
python -m venv .venv && source .venv/bin/activate
pip install "feast[redis]" pandas pyarrowFor a zero-infrastructure local run, you can skip Redis entirely and use the
SQLite online store (see step 4). Other common extras: feast[aws],
feast[gcp], feast[snowflake], feast[postgres].
feature-store-bootstrap/
src/feature_store/ importable library (definitions + helpers)
definitions.py entities, sources, batch feature views, services
transformations.py pure feature logic + on-demand feature views
ingestion.py materialize / push helpers
retrieval.py training + serving retrieval helpers
registry.py apply / describe / teardown helpers
feature_repo/
feature_store.yaml the project config Feast reads
example_repo.py the file `feast apply` scans (imports the library)
data/ parquet fixtures + registry.db (created on first run)
The split between src/feature_store/ (a tested library) and feature_repo/
(a thin Feast entry point) is deliberate. Feature logic lives in version-
controlled, unit-tested Python; the repo file just imports and re-exports it so
Feast can discover the objects. As your feature set grows past a single file,
this scales far better than one monolithic example_repo.py.
feature_store.yaml has five things Feast must know:
| Key | What it controls | Production swap |
|---|---|---|
project | Namespace for the registry + online keys | Keep stable; renaming orphans data |
registry | Where the object catalogue lives | sql registry on Postgres for teams |
provider | Default stores + materialization engine | aws / gcp for managed infra |
online_store | Low-latency serving backend | Redis / DynamoDB / Bigtable |
offline_store | Historical retrieval backend | BigQuery / Snowflake / Redshift |
entity_key_serialization_version: 2 should always be set on new projects --
it is a more compact online-store key encoding. Do not change it on an existing
project, as it would invalidate already-materialized keys.
Edit feature_repo/feature_store.yaml, comment the redis block, and uncomment
the SQLite alternative:
online_store:
type: sqlite
path: data/online_store.dbSQLite is single-process and slow under load -- perfect for development and CI,
never for production serving.
apply is idempotent: it diffs your definitions against the registry and
creates/updates/deletes objects to match. Run it from the repo directory:
cd feature_repo
feast applyYou should see Feast create the entities, feature views, on-demand view, and the
two feature services. Equivalently, from Python:
from feast import FeatureStore
from feature_store.registry import RegistryManager
store = FeatureStore(repo_path="feature_repo")
RegistryManager(store).apply() # registers everything
RegistryManager(store).describe() # prints what is now in the registryMaterialization copies feature values from the offline store into the online
store so they can be served in milliseconds.
# One-off backfill of an explicit window:
feast materialize 2024-01-01T00:00:00 2024-01-08T00:00:00
# Scheduled top-up since the last run (the cron-friendly command):
feast materialize-incremental $(date -u +"%Y-%m-%dT%H:%M:%S")In Python, prefer the helpers in ingestion.py -- they add logging and return
an IngestionStats you can emit to your metrics system:
from feature_store.ingestion import materialize_window, materialize_incremental
materialize_window(store, days=7) # explicit backfill
materialize_incremental(store) # scheduled top-upScheduled materialization has a built-in lag equal to your cron interval. For
features that must be near-real-time (transaction velocity, for example), stream
updates through the PushSource:
from feature_store.ingestion import push_micro_batch
push_micro_batch(store, "customer_txn_push_source", fresh_df, mode="online")mode="online_and_offline" also appends to the offline store so the pushed
value is available for future training joins -- use it when the streamed
aggregate should become part of the historical record.
A minimal production cadence:
feast materialize-incremental for slowly-changing views(profiles, merchant stats) via Airflow / Cron / GitHub Actions.
push_micro_batch for fast-movingaggregates.
feast apply in CI so registry changes ship withcode. Pin the Feast version in requirements.txt so apply behaviour is
reproducible.
| Symptom | Likely cause | Fix |
|---|---|---|
feast apply finds no objects | Repo file did not import the definitions | Confirm example_repo.py re-exports them and ../src is importable |
Online lookup returns all None | Features never materialized, or entity key absent | Run materialize-incremental; verify the customer_id exists |
| Training values look "from the future" | Entity timestamp earlier than feature event time | Set the entity event_timestamp to the label time, not now |
ConnectionError to Redis | Online store unreachable | Start Redis or switch to the SQLite online store |
| Stale features in a long-running server | Registry cached | Set registry.cache_ttl_seconds or call RegistryManager.refresh() |
With apply, materialize, and serve working, move on to
feature-engineering-patterns.md for how to design features that stay correct
in production.
Practical patterns for building features that stay correct once they hit
production. Every example references the real objects in this package
(customer_txn_stats, transaction_risk_features, the fraud_detection_v1
service) so you can map the idea straight onto code.
The defining job of the offline store is to answer: *"what did we know about
this entity at this exact moment?"* If your training set is built with a naive
JOIN on the latest feature row, you leak future information into the past and
your offline metrics become a fantasy.
Feast enforces this with the entity dataframe's timestamp column. For each
training row it finds the most recent feature value whose event_timestamp is
at or before the row's timestamp, and never after:
entity_df = build_entity_dataframe(
entity_rows=[{"customer_id": 1001, "churned": 0}],
event_timestamps=[label_observed_at], # the label's event time, NOT now
)
get_training_dataframe(store, entity_df, features=churn_model_v1)Rule of thumb: the entity timestamp is the moment the *label* became true,
not the moment you run the training job.
Skew is when the feature a model trains on differs from the feature it serves
on -- the quietest, most expensive bug in applied ML. The cure is to compute a
feature exactly once and read it through both paths.
This package does that by binding a model to a FeatureService:
# Training and serving both reference the SAME service object.
get_training_dataframe(store, entity_df, features=fraud_detection_v1) # offline
ServingClient(store, "fraud_detection_v1").get_features(entity_row, ctx) # onlineBecause both read fraud_detection_v1, adding or removing a feature changes
both paths together. Never hand a model an ad-hoc feature list at serving time
that differs from what it trained on.
Decide where a feature is computed by how its inputs arrive:
| Input availability | Compute location | Example in this package |
|---|---|---|
| Known ahead of time, per entity | Batch feature view (materialized) | txn_amount_avg_30d |
| Only known at request time | On-demand feature view | amount_to_avg_ratio |
| Both | On-demand view combining them | transaction_risk_features |
amount_to_avg_ratio *cannot* be precomputed -- it depends on the live
transaction amount, which does not exist until the request. So it lives in an
on-demand view that combines the request (transaction_request) with the
materialized average. Anything that depends only on the entity (the 30-day
average itself) should be precomputed once, not recomputed on every request.
On-demand views are hard to test if the logic lives inside the decorated
function. This package factors every calculation into a pure function:
# transformations.py -- pure, unit-tested
def zscore(value, mean, std): ...
# the on-demand view is a thin adapter over the pure function
@on_demand_feature_view(...)
def transaction_risk_features(inputs):
out["amount_zscore"] = zscore(inputs["transaction_amount"], ...)Now zscore has real test coverage (tests/test_definitions.py) independent of
Feast, pandas plumbing, or a running store. Apply the same split to your own
features.
Most behavioural features are windowed counts/sums/averages (txn_count_7d,
txn_amount_avg_30d). Two things keep them honest:
1. Name the window in the feature. txn_count_7d is unambiguous;
txn_count invites a future engineer to change the window and silently
shift the model's inputs.
2. Compute them in the warehouse, not at request time. Sliding-window
aggregates over months of events are expensive; precompute them on a
schedule and materialize. The on-demand layer is for cheap, request-coupled
maths only.
Every transform runs on rows you did not anticipate -- a customer with no
history, a zero average, a NaN standard deviation. Bake the defence into the
pure function:
def ratio_to_average(value, average):
safe_avg = average.where(average > 0, other=1.0) # no divide-by-zero
ratio = value / safe_avg
return ratio.where(average > 0, other=1.0) # cold start -> neutral 1.0A z-score floors the standard deviation (MIN_STD) so a customer with zero
spending variance yields a finite score instead of infinity. Decide the
cold-start behaviour explicitly; do not let inf/NaN reach the model.
customer and merchant are separate entitieswith separate join keys. A feature service can pull from both
(fraud_detection_v1 reads customer *and* merchant features) by listing
views for each.
keep them small and immutable. Do not key on something that changes (email).
entity whose key appears in its source rows.
ttl bounds how far back online serving will look for a row before returning
None. Match it to the cadence at which the source updates:
| Feature kind | Update cadence | Suggested TTL | This package |
|---|---|---|---|
| Slowly-changing profile | Weekly | 30-90 days | PROFILE_TTL = 90d |
| Rolling behavioural stats | Daily / streamed | 1-3 days | TXN_STATS_TTL = 3d |
| Merchant aggregates | Daily | ~7 days | MERCHANT_TTL = 7d |
Too short and fresh-enough values expire to None; too long and the model
serves stale data after a pipeline outage. The TTL is a freshness SLA -- treat
a sudden rise in None lookups as an alert that materialization is behind.
Batch materialization has a built-in lag equal to its schedule. For features
where that lag matters (transaction velocity during an attack), stream updates
through the push source and reserve batch for the slow-moving views:
push_micro_batch(store, "customer_txn_push_source", fresh_df, mode="online")Use mode="online_and_offline" when the streamed value should also become part
of the training history; mode="online" when it is serving-only.
When you add a feature, backfill it before training on it, or early rows will be
None:
materialize_window(store, days=180) # populate history for the new viewPin your Feast version and persist generated training sets
(churn_training_set.parquet in the example) so a model can always be retrained
on the exact data it originally saw.
*_7d, *_30d).FeatureService (no train/serve skew).