Skip to main content

Module 4 — Defining and versioning features

Once the two stores are in place, the question shifts from where features live to how they are declared. A feature is not a column; it is a contract. This module explains the declarative form of that contract, then the discipline that lets you change it without breaking the models that already depend on it.

The declarative form

A feature is defined declaratively — not through the code that computes it, but through what it means and how it should be treated. In Feast, that declaration lives in Python files versioned in git and applied to the registry with feast apply. Six pieces matter.

The fully qualified name. card_activity_1h:n_tx_last_1h — the feature view namespaces the field. Two features may share the same field name in different views. This is not decoration: it is how the registry addresses features and how consumers request them.

The type. Int64, Float32, String, Bool, Array, UnixTimestamp. Types are checked at materialization and at online read. A silent type mismatch — training saw Int64, production returns Float64 because Redis serialized as bytes — is exactly the kind of gap module 1 warned against.

The TTL (time to live). How long a value remains valid for a point-in-time join. If n_tx_last_1h has a TTL of one hour and the training set contains an event with no feature row within the previous hour, the join returns null rather than picking up a stale row from three days ago. TTL is not eviction from the store; it is a validity constraint on joins.

The owner. A team email — team-fraud@example.com — not an individual. A feature that outlives its author must have an owner reachable a year later.

The tags. Free-form metadata for discovery and governance: domain: fraud, pii: false, freshness: 5min, sensitivity: standard. Tags are what a governance layer filters on to answer questions like "list every feature exposed to any model touching cardholder data".

The source. The FileSource, KafkaSource or warehouse source from which the feature is computed. The registry follows the pointer, and both training and serving inherit that same pointer.

Here is a slightly richer version of the feature view from module 2:

from datetime import timedelta
from feast import Entity, FeatureView, Field, FileSource
from feast.types import Int64, Float64

card = Entity(name="card_id", join_keys=["card_id"])

tx_source = FileSource(
name="tx_source",
path="s3://fraud/transactions/",
timestamp_field="event_ts",
created_timestamp_column="ingested_at",
)

card_activity_1h = FeatureView(
name="card_activity_1h",
entities=[card],
ttl=timedelta(hours=2),
schema=[
Field(name="n_tx_last_1h", dtype=Int64),
Field(name="sum_amount_last_1h", dtype=Float64),
Field(name="avg_amount_last_1h", dtype=Float64),
],
source=tx_source,
owner="team-fraud@example.com",
tags={"domain": "fraud", "freshness": "15min", "pii": "false"},
)

The TTL of two hours here is chosen against the materialization cadence: features refresh every 15 minutes (module 6), so a value more than 2 hours old is a symptom of a broken pipeline, not a normal case.

The two timestamps that must be right

A feature source carries two timestamps that beginners often confuse.

The event timestamp (timestamp_field="event_ts") is when the underlying event happened in the real world — the moment the card was swiped, the click occurred, the sensor reading was taken. This is what point-in-time joins align on (module 5). It is the ground truth of when.

The created timestamp (created_timestamp_column="ingested_at") is when your system learned about the event — the moment it was written to the source. It exists because events can arrive late: a card swipe at 13:42:00 may not reach the Parquet directory until 13:47:30 due to a broker delay. The created timestamp is what tells you the value could not have been known at 13:44:00, so it must not be used in a decision made then.

Skipping the created timestamp is the second most common cause of leakage after the naive join of module 5.

Evolving a feature without breaking the models

Features change. Definitions of "fraudulent" change, TTLs are re-tuned, aggregate windows are re-scoped, formulas are corrected. The rule is simple and often violated: never change the semantics of an existing name silently. Three patterns work; one does not.

Add a field. New feature, new name in the same view. Existing models keep reading the old fields; the new model reads the new one. No breakage. This is the default.

Version through the name. When a formula changes materially — n_tx_last_1h_v2 treats declined transactions differently from n_tx_last_1h_v1 — introduce a new name. Both versions live side by side until the old models are retired, at which point the old field is deleted from the view (and, if you are careful, from the online store too). Cost of parallel storage buys you a safe migration.

Extend a type upward, never downward. Changing Int64 to Float64 is safe for readers; changing Float64 to Int64 truncates silently. If a truncation is what you want, do it as a new field, not as a type change on the old one.

Rename in place, on the other hand, is what breaks production. A rename is a delete plus an add for every consumer that hard-codes the name, and consumers on the serving path do hard-code names in the request payload. In an audit of one of our client projects, three of the last four Sev-2 incidents on the fraud service came from an in-place rename.

Ownership and lifecycle

A feature has a lifecycle: proposed, stable, deprecated, removed. The owner is who moves it between those states. Tags carry the current state so consumers can query "give me only stable features" or "warn me if I depend on a deprecated one".

The registry is the natural place to keep this lifecycle, because it is the one place the entire organization looks. A deprecated feature stays available for its TTL plus the training window of the longest-lived model that depends on it — usually the largest of "the current TTL", "the last quarter's training set" and "the retention agreed with the ML team".

A deleted feature is not deprecated, it is deleted

Removing a feature view from the registry immediately breaks every model that read it. Deprecation is a process, not a synonym for delete. Deprecate for a full retraining cycle (weeks, not hours), watch that no model still requests the field at inference, then remove. A feast apply that removes fields without that process is the operational equivalent of dropping a column consumers still SELECT.

Summary

  • A feature is declared by six pieces: fully qualified name, type, TTL, owner, tags, source; the definition, not the compute code, is what the store contracts on.
  • Sources carry two timestamps: event_ts for when the event happened (used for point-in-time joins) and ingested_at for when your system learned about it (used to prevent leakage).
  • Evolve by adding fields, versioning names (_v2) or widening types; never rename in place, and never change semantics under an existing name.
  • Feature lifecycle (proposed / stable / deprecated / removed) belongs to a named owner and is exposed through tags, so consumers can select and deprecation can be a process, not an incident.

Next module: point-in-time correct joins — the mechanism that makes the offline history usable for training without leaking the future.