Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/getting-started/concepts/feast-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Feast's type system is built on top of [protobuf](https://github.com/protocolbuf
Feast supports the following categories of data types:

- **Primitive types**: numerical values (`Int32`, `Int64`, `Float32`, `Float64`), `String`, `Bytes`, `Bool`, and `UnixTimestamp`.
- **Zoned timestamp type**: `ZonedTimestamp` stores a timezone-aware datetime as both the UTC instant and its originating zone, so the original wall-clock zone round-trips losslessly. This differs from `UnixTimestamp`, which is always decoded as UTC and discards the source zone. Use `ZonedTimestamp` when local time-of-day or the offset/zone itself is meaningful. It must be explicitly declared in schema (it is not inferred by any backend), and is not supported as an entity key.
- **Domain-specific primitives**: `PdfBytes` (PDF binary data for RAG/document pipelines) and `ImageBytes` (image binary data for multimodal pipelines). These are semantic aliases over `Bytes` and must be explicitly declared in schema — no backend infers them.
- **UUID types**: `Uuid` and `TimeUuid` for universally unique identifiers. Stored as strings at the proto level but deserialized to `uuid.UUID` objects in Python.
- **Array types**: ordered lists of any primitive type, e.g. `Array(Int64)`, `Array(String)`, `Array(Uuid)`.
Expand Down
42 changes: 41 additions & 1 deletion docs/reference/type-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Feast supports the following data types:
| `Bytes` | `bytes` | Binary data |
| `Bool` | `bool` | Boolean value |
| `UnixTimestamp` | `datetime` | Unix timestamp (nullable) |
| `ZonedTimestamp` | `datetime` | Timezone-aware datetime preserving its source zone (nullable) |
| `Uuid` | `uuid.UUID` | UUID (any version) |
| `TimeUuid` | `uuid.UUID` | Time-based UUID (version 1) |
| `Decimal` | `decimal.Decimal` | Arbitrary-precision decimal number |
Expand Down Expand Up @@ -202,7 +203,8 @@ from datetime import timedelta
from feast import Entity, FeatureView, Field, FileSource
from feast.types import (
Int32, Int64, Float32, Float64, String, Bytes, Bool, UnixTimestamp,
Uuid, TimeUuid, Decimal, Array, Set, Map, ScalarMap, Json, Struct
Uuid, TimeUuid, Decimal, Array, Set, Map, ScalarMap, Json, Struct,
ZonedTimestamp
)

# Define a data source
Expand Down Expand Up @@ -232,6 +234,7 @@ user_features = FeatureView(
Field(name="profile_picture", dtype=Bytes),
Field(name="is_active", dtype=Bool),
Field(name="last_login", dtype=UnixTimestamp),
Field(name="event_time", dtype=ZonedTimestamp),
Field(name="session_id", dtype=Uuid),
Field(name="event_id", dtype=TimeUuid),
Field(name="price", dtype=Decimal),
Expand Down Expand Up @@ -362,6 +365,43 @@ unique_prices = {decimal.Decimal("9.99"), decimal.Decimal("19.99"), decimal.Deci
`Decimal` is **not** inferred from any backend schema. You must declare it explicitly in your feature view schema. The pandas dtype for `Decimal` columns is `object` (holding `decimal.Decimal` instances), not a numeric dtype.
{% endhint %}

### ZonedTimestamp Type Usage Examples

The `ZonedTimestamp` type stores a timezone-aware `datetime` as both the UTC instant
and its originating zone, so the original wall-clock zone round-trips losslessly.
By contrast, `UnixTimestamp` always decodes to UTC and discards the source zone.

```python
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

# A datetime in a specific zone — both the instant and "America/Los_Angeles" are kept
event_time = datetime(2026, 6, 17, 9, 0, 0, tzinfo=ZoneInfo("America/Los_Angeles"))

# ZonedTimestamp values are returned as tz-aware datetime objects, in their own zone
response = store.get_online_features(
features=["event_features:event_time"],
entity_rows=[{"user_id": 1001}],
)
result = response.to_dict()
# result["event_time"][0] == event_time (same instant AND same zone, e.g. 09:00-07:00)

# Two values at the same instant but different zones stay distinct
la = datetime(2026, 6, 17, 9, 0, 0, tzinfo=ZoneInfo("America/Los_Angeles"))
utc = datetime(2026, 6, 17, 16, 0, 0, tzinfo=timezone.utc) # same instant as `la`

# A naive (tz-less) datetime is interpreted as UTC
naive = datetime(2026, 6, 17, 12, 0, 0) # stored zone is empty, decoded as UTC
```

{% hint style="warning" %}
`ZonedTimestamp` is **not** inferred from any backend schema — you must declare it
explicitly in your feature view schema. It is not supported as an entity key. The
zone is stored as an IANA name (e.g. `America/Los_Angeles`) when available, falling
back to a fixed-offset string; offline stores that cannot natively carry a zone may
normalize to UTC on that backend.
{% endhint %}

### Nested Collection Type Usage Examples

```python
Expand Down
13 changes: 13 additions & 0 deletions protos/feast/types/Value.proto
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ message ValueType {
DECIMAL_LIST = 45;
DECIMAL_SET = 46;
SCALAR_MAP = 47;
ZONED_TIMESTAMP = 48;
}
}

Expand Down Expand Up @@ -120,13 +121,25 @@ message Value {
StringList decimal_list_val = 45;
StringSet decimal_set_val = 46;
ScalarMap scalar_map_val = 47;
ZonedTimestamp zoned_timestamp_val = 48;
Comment thread
nquinn408 marked this conversation as resolved.
}
}

enum Null {
NULL = 0;
}

// A timezone-aware datetime: the UTC instant plus its originating zone, so a
// zoned datetime round-trips losslessly (unlike UNIX_TIMESTAMP, which is decoded
// as UTC and discards the original zone).
message ZonedTimestamp {
// Epoch seconds (UTC instant), same convention as unix_timestamp_val.
int64 unix_timestamp = 1;
// IANA zone name (e.g. "America/Los_Angeles") or a fixed-offset string
// (e.g. "-07:00", "UTC"). Empty string is treated as UTC on read.
string zone = 2;
}

message BytesList {
repeated bytes val = 1;
}
Expand Down
19 changes: 19 additions & 0 deletions sdk/python/feast/feature_server_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ def _value_to_native(v: Value) -> Optional[Any]:
"scalar_map_val is not yet supported by convert_response_to_dict; value will be None"
)
return None
# zoned_timestamp_val is a ZonedTimestamp message; serialize as an ISO 8601
# string in its stored zone so JSONResponse can encode it (the raw message
# is not JSON-serializable).
elif which == "zoned_timestamp_val":
return _zoned_timestamp_to_str(v.zoned_timestamp_val)
# bytes_list_val / bytes_set_val — base64-encode each element
elif which in ("bytes_list_val", "bytes_set_val"):
return [base64.b64encode(b).decode("ascii") for b in getattr(v, which).val]
Expand All @@ -109,6 +114,20 @@ def _value_to_native(v: Value) -> Optional[Any]:
return getattr(v, which)


def _zoned_timestamp_to_str(zoned) -> Optional[str]:
"""Convert a ZonedTimestamp proto to an ISO 8601 string in its stored zone.

A null instant (NaT sentinel) returns None. The zone is resolved via the
same logic used on the read path so IANA names and fixed offsets round-trip.
"""
from feast.type_map import NULL_TIMESTAMP_INT_VALUE, _zone_from_name

if zoned.unix_timestamp == NULL_TIMESTAMP_INT_VALUE:
return None
tz = _zone_from_name(zoned.zone)
return datetime.fromtimestamp(zoned.unix_timestamp, tz=tz).isoformat()


def _timestamp_to_str(ts) -> str:
"""Convert protobuf Timestamp to RFC 3339 format with Z suffix.

Expand Down
8 changes: 7 additions & 1 deletion sdk/python/feast/proto_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,15 @@ def to_json_object(printer: _Printer, message: ProtoMessage) -> JsonObject:
elif which in ("list_val", "set_val"):
# Nested collection: RepeatedValue containing Values
repeated = getattr(message, which)
value = [
value: JsonObject = [
printer._MessageToJsonObject(inner_val) for inner_val in repeated.val
]
elif which == "zoned_timestamp_val":
# ZonedTimestamp is a message; render it as an ISO 8601 string in its
# stored zone so the result is JSON-serializable (the raw message is not).
from feast.feature_server_utils import _zoned_timestamp_to_str

value = _zoned_timestamp_to_str(message.zoned_timestamp_val)
elif "_list_" in which:
value = list(getattr(message, which).val)
else:
Expand Down
Loading
Loading