docs/adr/feature-view-versioning.md
Status: In Review
Authors: @farceo
Branch: featureview-versioning
Date: 2026-03-17
This RFC proposes adding automatic version tracking to Feast feature views. Every time feast apply detects a schema or UDF change to a feature view, a versioned snapshot is saved to the registry. Users can list version history, pin serving to a prior version, and optionally query specific versions at read time using @v<N> syntax.
Today, when a feature view's schema changes, the old definition is silently overwritten. This creates several problems:
Shows what happens during feast apply and get_online_features, and how version
history, pinning, and version-qualified reads fit together.
feast apply
|
v
+------------------------+
| Compare new definition |
| against active FV |
+------------------------+
| |
schema/UDF metadata only
changed changed
| |
v v
+--------------+ +------------------+
| Save old as | | Update in place, |
| version N | | no new version |
| Save new as | +------------------+
| version N+1 |
+--------------+
|
+------------+------------+
| |
v v
+----------------+ +-------------------+
| Registry | | Online Store |
| (version | | (only if flag on) |
| history) | +-------------------+
+----------------+ |
| +------+------+
| | |
v v v
+----------------+ +--------+ +-----------+
| feast versions | | proj_ | | proj_ |
| feast pin v2 | | fv | | fv_v1 |
| list / get | | (v0) | | fv_v2 ... |
+----------------+ +--------+ +-----------+
Always available Unversioned Versioned
table tables
get_online_features
|
v
+---------------------+
| Parse feature refs |
+---------------------+
| |
"fv:feature" "fv@v2:feature"
(no version) (version-qualified)
| |
v v
+------------+ +------------------+
| Read from | | flag enabled? |
| active FV | +------------------+
| table | | |
+------------+ yes no
| |
v v
+------------+ +-------+
| Look up v2 | | raise |
| snapshot, | | error |
| read from | +-------+
| proj_fv_v2 |
+------------+
Shows how version data is stored in the registry and online store, and the relationship between the active definition and historical snapshots.
+--feature_store.yaml------------------------------------------+
| registry: |
| path: data/registry.db |
| enable_online_feature_view_versioning: true (optional) |
+--------------------------------------------------------------+
| |
v v
+--Registry (file or SQL)--+ +--Online Store (SQLite, ...)---+
| | | |
| Active Feature Views | | Unversioned tables (v0) |
| +--------------------+ | | +-------------------------+ |
| | driver_stats | | | | proj_driver_stats | |
| | version: latest | | | | driver_id | trips | . | |
| | current_ver: 2 | | | +-------------------------+ |
| | schema: [...] | | | |
| +--------------------+ | | Versioned tables (v1+) |
| | | +-------------------------+ |
| Version History | | | proj_driver_stats_v1 | |
| +--------------------+ | | | driver_id | trips | . | |
| | v0: proto snapshot | | | +-------------------------+ |
| | created: Jan 15 | | | +-------------------------+ |
| | v1: proto snapshot | | | | proj_driver_stats_v2 | |
| | created: Jan 16 | | | | driver_id | trips | . | |
| | v2: proto snapshot | | | +-------------------------+ |
| | created: Jan 20 | | | |
| +--------------------+ | +-------------------------------+
| |
| Always active. | Only created when flag is on
| No flag needed. | and feast materialize is run.
+--------------------------+
version="v2" on a feature view replaces the active definition with the v2 snapshot — essentially a revert.@v<N> syntax in feature references (e.g., driver_stats@v2:trips_today) for reading from a specific version's online store table.Only schema and UDF changes create new versions. Metadata-only changes (description, tags, owner, TTL, online/offline flags) update the active definition in place without creating a version.
Schema-significant changes include:
This keeps version history meaningful — a new version number always means a real structural change.
description, tags, ownerttl, online, offline flagsVersion history tracking is lightweight registry metadata — just a serialized proto snapshot per version. There is no performance cost to the online path and no additional infrastructure required. For this reason, version history is always active with no opt-in flag needed.
Out of the box, every feast apply that changes a feature view will:
feast feature-views list-versions <name> to list historyregistry.list_feature_view_versions(name, project) programmaticallyregistry.get_feature_view_by_version(name, project, version_number) for snapshot retrievalversion="v2" in feature view definitionsThe expensive/risky part of versioning is creating separate online store tables per version and routing reads to them. This is gated behind a config flag:
registry:
path: data/registry.db
enable_online_feature_view_versioning: true
When enabled, version-qualified refs like driver_stats@v2:trips_today in get_online_features() will:
project_driver_stats_v2)When disabled (the default), using @v<N> refs raises a clear error. All other versioning features (history, listing, pinning, snapshot retrieval) work regardless.
File-based registry: Version history is stored as a repeated FeatureViewVersionRecord message in the registry proto, alongside the existing feature view definitions.
SQL registry: A dedicated feature_view_version_history table with columns for name, project, version number, type, proto bytes, and creation timestamp.
Pinning replaces the active feature view with a historical snapshot:
driver_stats = FeatureView(
name="driver_stats",
entities=[driver],
schema=[...],
source=my_source,
version="v2", # revert to v2's definition
)
Safety constraints:
feast apply raises FeatureViewPinConflict. This prevents accidental "I thought I was reverting but I also changed things."version="latest") returns to auto-incrementing behavior. If the next feast apply detects a schema change, a new version is created.The @v<N> syntax extends the existing feature_view:feature reference format:
features = store.get_online_features(
features=[
"driver_stats:trips_today", # latest (default)
"driver_stats@v2:trips_today", # read from v2
"driver_stats@v1:avg_rating", # read from v1
],
entity_rows=[{"driver_id": 1001}],
)
Online store table naming:
project_driver_stats) for backward compatibilityproject_driver_stats_v1, project_driver_stats_v2)Each version requires its own materialization. @latest always resolves to the active version.
Versioning works on all three feature view types:
FeatureView / BatchFeatureViewStreamFeatureViewOnDemandFeatureViewVersion-qualified reads (@v<N>) are currently implemented for the SQLite online store. Other online stores will raise a clear error. Expanding to additional stores is follow-up work.
Each version's data lives in its own online store table (e.g., project_fv_v1, project_fv_v2). By default, feast materialize and feast materialize-incremental populate the active (latest) version's table. To populate a specific version's table, pass the --version flag along with a single --views target:
# Materialize v1 of driver_stats
feast materialize --views driver_stats --version v1 2024-01-01T00:00:00 2024-01-15T00:00:00
# Incrementally materialize v2 of driver_stats
feast materialize-incremental --views driver_stats --version v2 2024-01-15T00:00:00
Python SDK equivalent:
store.materialize(
feature_views=["driver_stats"],
version="v2",
start_date=start,
end_date=end,
)
Requirements:
enable_online_feature_view_versioning: true must be set in feature_store.yaml--version requires --views with exactly one feature view namefeast apply)--version, materialization targets the active version's table (existing behavior)Multi-version workflow example:
# Model A uses v1, Model B uses v2 — populate both tables
feast materialize --views driver_stats --version v1 2024-01-01T00:00:00 2024-02-01T00:00:00
feast materialize --views driver_stats --version v2 2024-01-01T00:00:00 2024-02-01T00:00:00
# Models can now query their respective versions online
# Model A: store.get_online_features(features=["driver_stats@v1:trips_today"], ...)
# Model B: store.get_online_features(features=["driver_stats@v2:trips_today"], ...)
# List version history
versions = store.list_feature_view_versions("driver_stats")
# [{"version": "v0", "version_number": 0, "created_timestamp": ..., ...}, ...]
# Get a specific version's definition
fv_v1 = store.registry.get_feature_view_by_version("driver_stats", project, 1)
# Pin to a version
FeatureView(name="driver_stats", ..., version="v2")
# Version-qualified online read (requires enable_online_feature_view_versioning)
store.get_online_features(features=["driver_stats@v2:trips_today"], ...)
# Materialize a specific version
store.materialize(feature_views=["driver_stats"], version="v2", start_date=start, end_date=end)
store.materialize_incremental(feature_views=["driver_stats"], version="v2", end_date=end)
# List versions
feast feature-views list-versions driver_stats
# Output:
# VERSION TYPE CREATED VERSION_ID
# v0 feature_view 2024-01-15 10:30:00 a1b2c3d4-...
# v1 feature_view 2024-01-16 14:22:00 e5f6g7h8-...
# Materialize a specific version
feast materialize --views driver_stats --version v2 2024-01-01T00:00:00 2024-02-01T00:00:00
feast materialize-incremental --views driver_stats --version v2 2024-02-01T00:00:00
# feature_store.yaml
registry:
path: data/registry.db
# Optional: enable versioned online tables and @v<N> reads (default: false)
enable_online_feature_view_versioning: true
version parameter defaults to "latest" and current_version_number defaults to None.feast apply.version and current_version_number fields use proto defaults (empty string and 0) so old protos deserialize correctly.Two concurrent feast apply calls on the same feature view can race on version number assignment. The behavior depends on the version mode and registry backend.
version="latest" (auto-increment)The registry computes MAX(version_number) + 1 and saves the new snapshot. If two concurrent applies race on the same version number:
(feature_view_name, project_id, version_number) causes an IntegrityError. The registry catches this and retries up to 3 times, re-reading MAX + 1 each time. Since the client said "latest", the exact version number doesn't matter.version="v<N>" (explicit version)The registry checks whether version N already exists:
If two concurrent applies both try to forward-declare the same version:
ConcurrentVersionConflict error with a clear message to pull latest and retry.--no-promote)By default, feast apply atomically saves a version snapshot and promotes it to the active definition. This works well for additive changes, but for breaking schema changes you may want to stage the new version without disrupting unversioned consumers.
Without --no-promote, a phased rollout looks like:
feast apply — saves v2 and promotes it (all unversioned consumers now hit v2)version="v1" in the definition, then feast apply againThis leaves a transition window where unversioned consumers briefly see the new schema. Authors can also forget the pin-back step.
The --no-promote flag saves the version snapshot without updating the active feature view definition. The new version is accessible only via explicit @v<N> reads and --version materialization.
CLI usage:
feast apply --no-promote
Python SDK equivalent:
store.apply([entity, feature_view], no_promote=True)
Stage the new version:
feast apply --no-promote
This publishes v2 without promoting it. All unversioned consumers continue using v1.
Populate the v2 online table:
feast materialize --views driver_stats --version v2 ...
Migrate consumers one at a time:
driver_stats@v2:trips_todaydriver_stats@v2:avg_ratingPromote v2 as the default:
feast apply
Or pin to v2: set version="v2" in the definition and run feast apply.
Note: By default,
feast apply(without--no-promote) promotes the new version immediately. Use--no-promoteonly when you need a controlled, phased rollout.
Feature services work with versioned feature views when the online versioning flag is enabled:
enable_online_feature_view_versioning is true and a feature service references a versioned feature view (current_version_number > 0), the serving path automatically sets version_tag on the projection. This ensures get_online_features() reads from the correct versioned online store table (e.g., project_driver_stats_v1) instead of the unversioned table._get_features() and _get_feature_views_to_use() produce version-qualified keys (e.g., driver_stats@v1:trips_today) for feature services referencing versioned FVs, keeping the feature ref index and the FV lookup index in sync.current_version_number > 0) but enable_online_feature_view_versioning is false:
feast apply will reject the feature service with a clear error.get_online_features() will fail at retrieval time with a descriptive error message.@v<N> syntax in feature services. Version-qualified reads (driver_stats@v2:trips_today) using the @v<N> syntax require string-based feature references passed directly to get_online_features(). Feature services always resolve to the active (latest) version of each referenced feature view.FeatureService(features=[driver_stats["v2"]])).--no-promote versions are not served. Feature services always resolve to the active (promoted) version. Versions published with --no-promote are not visible to feature services until promoted via a regular feast apply or explicit pin.get_historical_features is not supported.@ or : since these characters are reserved for version-qualified references (fv@v2:feature). feast apply rejects new feature views with these characters. The parser falls back gracefully for legacy feature views that already contain @ in their names — unrecognized @ suffixes are treated as part of the name rather than raising errors.max_versions config or TTL-based pruning could help.get_historical_features? The current implementation is online-only. Offline versioned reads would require point-in-time-correct version resolution.driver_stats@stable:trips mapping to a pinned version number via config.featureview-versioningdocs/getting-started/concepts/feature-view.md (Versioning section)sdk/python/tests/integration/registration/test_versioning.py, sdk/python/tests/unit/test_feature_view_versioning.py