Skip to content

Commit eb042f0

Browse files
authored
feat: New zoned timestamp feature type (#6536) (#6537)
1 parent f0c5be8 commit eb042f0

13 files changed

Lines changed: 402 additions & 65 deletions

File tree

docs/getting-started/concepts/feast-types.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Feast's type system is built on top of [protobuf](https://github.com/protocolbuf
88
Feast supports the following categories of data types:
99

1010
- **Primitive types**: numerical values (`Int32`, `Int64`, `Float32`, `Float64`), `String`, `Bytes`, `Bool`, and `UnixTimestamp`.
11+
- **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.
1112
- **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.
1213
- **UUID types**: `Uuid` and `TimeUuid` for universally unique identifiers. Stored as strings at the proto level but deserialized to `uuid.UUID` objects in Python.
1314
- **Array types**: ordered lists of any primitive type, e.g. `Array(Int64)`, `Array(String)`, `Array(Uuid)`.

docs/reference/type-system.md

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Feast supports the following data types:
2424
| `Bytes` | `bytes` | Binary data |
2525
| `Bool` | `bool` | Boolean value |
2626
| `UnixTimestamp` | `datetime` | Unix timestamp (nullable) |
27+
| `ZonedTimestamp` | `datetime` | Timezone-aware datetime preserving its source zone (nullable) |
2728
| `Uuid` | `uuid.UUID` | UUID (any version) |
2829
| `TimeUuid` | `uuid.UUID` | Time-based UUID (version 1) |
2930
| `Decimal` | `decimal.Decimal` | Arbitrary-precision decimal number |
@@ -202,7 +203,8 @@ from datetime import timedelta
202203
from feast import Entity, FeatureView, Field, FileSource
203204
from feast.types import (
204205
Int32, Int64, Float32, Float64, String, Bytes, Bool, UnixTimestamp,
205-
Uuid, TimeUuid, Decimal, Array, Set, Map, ScalarMap, Json, Struct
206+
Uuid, TimeUuid, Decimal, Array, Set, Map, ScalarMap, Json, Struct,
207+
ZonedTimestamp
206208
)
207209

208210
# Define a data source
@@ -232,6 +234,7 @@ user_features = FeatureView(
232234
Field(name="profile_picture", dtype=Bytes),
233235
Field(name="is_active", dtype=Bool),
234236
Field(name="last_login", dtype=UnixTimestamp),
237+
Field(name="event_time", dtype=ZonedTimestamp),
235238
Field(name="session_id", dtype=Uuid),
236239
Field(name="event_id", dtype=TimeUuid),
237240
Field(name="price", dtype=Decimal),
@@ -362,6 +365,43 @@ unique_prices = {decimal.Decimal("9.99"), decimal.Decimal("19.99"), decimal.Deci
362365
`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.
363366
{% endhint %}
364367

368+
### ZonedTimestamp Type Usage Examples
369+
370+
The `ZonedTimestamp` type stores a timezone-aware `datetime` as both the UTC instant
371+
and its originating zone, so the original wall-clock zone round-trips losslessly.
372+
By contrast, `UnixTimestamp` always decodes to UTC and discards the source zone.
373+
374+
```python
375+
from datetime import datetime, timezone
376+
from zoneinfo import ZoneInfo
377+
378+
# A datetime in a specific zone — both the instant and "America/Los_Angeles" are kept
379+
event_time = datetime(2026, 6, 17, 9, 0, 0, tzinfo=ZoneInfo("America/Los_Angeles"))
380+
381+
# ZonedTimestamp values are returned as tz-aware datetime objects, in their own zone
382+
response = store.get_online_features(
383+
features=["event_features:event_time"],
384+
entity_rows=[{"user_id": 1001}],
385+
)
386+
result = response.to_dict()
387+
# result["event_time"][0] == event_time (same instant AND same zone, e.g. 09:00-07:00)
388+
389+
# Two values at the same instant but different zones stay distinct
390+
la = datetime(2026, 6, 17, 9, 0, 0, tzinfo=ZoneInfo("America/Los_Angeles"))
391+
utc = datetime(2026, 6, 17, 16, 0, 0, tzinfo=timezone.utc) # same instant as `la`
392+
393+
# A naive (tz-less) datetime is interpreted as UTC
394+
naive = datetime(2026, 6, 17, 12, 0, 0) # stored zone is empty, decoded as UTC
395+
```
396+
397+
{% hint style="warning" %}
398+
`ZonedTimestamp` is **not** inferred from any backend schema — you must declare it
399+
explicitly in your feature view schema. It is not supported as an entity key. The
400+
zone is stored as an IANA name (e.g. `America/Los_Angeles`) when available, falling
401+
back to a fixed-offset string; offline stores that cannot natively carry a zone may
402+
normalize to UTC on that backend.
403+
{% endhint %}
404+
365405
### Nested Collection Type Usage Examples
366406

367407
```python

protos/feast/types/Value.proto

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ message ValueType {
6969
DECIMAL_LIST = 45;
7070
DECIMAL_SET = 46;
7171
SCALAR_MAP = 47;
72+
ZONED_TIMESTAMP = 48;
7273
}
7374
}
7475

@@ -120,13 +121,25 @@ message Value {
120121
StringList decimal_list_val = 45;
121122
StringSet decimal_set_val = 46;
122123
ScalarMap scalar_map_val = 47;
124+
ZonedTimestamp zoned_timestamp_val = 48;
123125
}
124126
}
125127

126128
enum Null {
127129
NULL = 0;
128130
}
129131

132+
// A timezone-aware datetime: the UTC instant plus its originating zone, so a
133+
// zoned datetime round-trips losslessly (unlike UNIX_TIMESTAMP, which is decoded
134+
// as UTC and discards the original zone).
135+
message ZonedTimestamp {
136+
// Epoch seconds (UTC instant), same convention as unix_timestamp_val.
137+
int64 unix_timestamp = 1;
138+
// IANA zone name (e.g. "America/Los_Angeles") or a fixed-offset string
139+
// (e.g. "-07:00", "UTC"). Empty string is treated as UTC on read.
140+
string zone = 2;
141+
}
142+
130143
message BytesList {
131144
repeated bytes val = 1;
132145
}

sdk/python/feast/feature_server_utils.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@ def _value_to_native(v: Value) -> Optional[Any]:
9999
"scalar_map_val is not yet supported by convert_response_to_dict; value will be None"
100100
)
101101
return None
102+
# zoned_timestamp_val is a ZonedTimestamp message; serialize as an ISO 8601
103+
# string in its stored zone so JSONResponse can encode it (the raw message
104+
# is not JSON-serializable).
105+
elif which == "zoned_timestamp_val":
106+
return _zoned_timestamp_to_str(v.zoned_timestamp_val)
102107
# bytes_list_val / bytes_set_val — base64-encode each element
103108
elif which in ("bytes_list_val", "bytes_set_val"):
104109
return [base64.b64encode(b).decode("ascii") for b in getattr(v, which).val]
@@ -109,6 +114,20 @@ def _value_to_native(v: Value) -> Optional[Any]:
109114
return getattr(v, which)
110115

111116

117+
def _zoned_timestamp_to_str(zoned) -> Optional[str]:
118+
"""Convert a ZonedTimestamp proto to an ISO 8601 string in its stored zone.
119+
120+
A null instant (NaT sentinel) returns None. The zone is resolved via the
121+
same logic used on the read path so IANA names and fixed offsets round-trip.
122+
"""
123+
from feast.type_map import NULL_TIMESTAMP_INT_VALUE, _zone_from_name
124+
125+
if zoned.unix_timestamp == NULL_TIMESTAMP_INT_VALUE:
126+
return None
127+
tz = _zone_from_name(zoned.zone)
128+
return datetime.fromtimestamp(zoned.unix_timestamp, tz=tz).isoformat()
129+
130+
112131
def _timestamp_to_str(ts) -> str:
113132
"""Convert protobuf Timestamp to RFC 3339 format with Z suffix.
114133

sdk/python/feast/proto_json.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,15 @@ def to_json_object(printer: _Printer, message: ProtoMessage) -> JsonObject:
6666
elif which in ("list_val", "set_val"):
6767
# Nested collection: RepeatedValue containing Values
6868
repeated = getattr(message, which)
69-
value = [
69+
value: JsonObject = [
7070
printer._MessageToJsonObject(inner_val) for inner_val in repeated.val
7171
]
72+
elif which == "zoned_timestamp_val":
73+
# ZonedTimestamp is a message; render it as an ISO 8601 string in its
74+
# stored zone so the result is JSON-serializable (the raw message is not).
75+
from feast.feature_server_utils import _zoned_timestamp_to_str
76+
77+
value = _zoned_timestamp_to_str(message.zoned_timestamp_val)
7278
elif "_list_" in which:
7379
value = list(getattr(message, which).val)
7480
else:

0 commit comments

Comments
 (0)