Historical datasets
build_historical_dataset returns how feature values evolved: one row
per entity per period during which its values did not change. Every result on these slides
was executed on Vantage 20, on a feature store built from the package's own demo notebooks.
Navigate with ← → or Space · press L for list view
What this covers
A snapshot holds one instant. A history holds every period.
build_dataset answers "what were the values at time t?".
build_historical_dataset answers "how did they change?".
The daily category features of the demo store, rolled out over business dates, for Clothing:
| ValidStart | ValidEnd | sum | max | mean | median |
|---|---|---|---|---|---|
| 2023-01-01 | 2023-05-11 | 4877.36 | 298.05 | 168.18 | 144.81 |
| 2023-05-11 | 2023-05-12 | 4334.25 | 299.90 | 154.79 | 133.42 |
| 2023-05-12 | 2023-05-13 | 4306.97 | 280.23 | 148.52 | 137.25 |
| 2023-05-13 | 2023-05-14 | 6352.94 | 294.98 | 158.82 | 147.62 |
| 2023-05-14 | 2023-05-15 | 5584.32 | 299.44 | 150.93 | 162.76 |
| 2023-05-15 | now | 4948.17 | 278.53 | 154.63 | 173.42 |
A snapshot at 2023-05-13 12:00 is the fourth row. The last period is still open in the store; it ends at the window's end, here the moment of the query.
Same arguments as build_dataset, plus a window
from tdfs4ds import build_historical_dataset
from tdfs4ds.feature_store.feature_query_retrieval import get_feature_versions
tdfs4ds.DATA_DOMAIN = "Customer Transaction Analytics Time Management"
selection = get_feature_versions(
entity_name='Category',
features=['sum_Transaction_Amount', 'mean_Transaction_Amount',
'max_Transaction_Amount', 'median_Transaction_Amount'])
history = build_historical_dataset('Category', selection, 'HIST_CATEGORY')
Output columns, in order
- entity
- the grain, as in a snapshot (
Category) - ValidPeriod
PERIOD(TIMESTAMP(0) WITH TIME ZONE), forCONTAINS,OVERLAPS,P_INTERSECT- ValidStart
- included
- ValidEnd
- excluded
- features
- one column per selected feature
Nothing is materialised. New ingestions show up at once, and a relative window moves by itself.
Fixed bounds, or relative to now
Half-open [start, end). now is the current valid time
when the view is queried, so a window without a fixed end slides on its own.
| Arguments | Window | Slides |
|---|---|---|
| none | [earliest, now) | yes |
delay | [earliest, now − delay) | yes |
duration | [now − duration, now) | yes |
duration + delay | [now − delay − duration, now − delay) | yes |
start | [start, now) | end only |
end | [earliest, end) | no |
start + end | [start, end) | no |
start + duration | [start, start + duration) | no |
end + duration | [end − duration, end) | no |
The end always exists: an open period ends at the window's end, never at 9999-12-31.
Durations become Teradata date arithmetic
Accepted: '3 months', '90 days', '1 year 2 months',
'2 days 6 hours', ISO 8601 'P1Y2M', (3, 'months'), a timedelta.
build_historical_dataset(..., duration='12 months', delay='1 day')
-- end and start, evaluated each time the view is queried
(CAST(CURRENT_TIMESTAMP(0) AS TIMESTAMP(0) WITH TIME ZONE) - INTERVAL '1 00:00:00' DAY TO SECOND)
ADD_MONTHS(<end>, -12)
Months use ADD_MONTHS
It clamps month-ends: 31 May minus 3 months gave 29 Feb (measured).
- INTERVAL 'n' MONTH raises an error on such dates.
The rest uses DAY TO SECOND
Literal written without a precision: DAY(4) TO SECOND inside a literal is
Error 3706 (measured). 9999 days is accepted.
Measured on real data: duration='40 years', delay='3 hours' ended the window three
hours before the query and excluded a run written that morning.
NORMALIZE merges equal neighbours. The join type decides which periods exist.
INNER
A period exists only where every feature has a value. An entity's history starts when its last feature appears.
FULL OUTER
A period exists where at least one feature has a value, with NULL elsewhere.
Real case: sum_Transaction_Amount under three processes. Two were rolled out over
2023 (FILTERED, WITH_CLONE), one ran in real time today (HYBRID).
Restaurant:
| ValidStart | ValidEnd | HYBRID | FILTERED | WITH_CLONE | FULL | INNER |
|---|---|---|---|---|---|---|
| 2023-01-01 | 2023-05-21 | NULL | 1514.00 | 1514.00 | ✓ | – |
| 2023-05-21 | 2023-05-22 | NULL | 2086.89 | 1514.00 | ✓ | – |
| … | NULL | … | 1514.00 | ✓ | – | |
| today 08:03:13 | 08:03:46 | 1328.46 | 1872.26 | 1514.00 | ✓ | ✓ |
| 08:03:46 | 08:03:47 | 1111.84 | 1872.26 | 1514.00 | ✓ | ✓ |
All six categories: FULL OUTER 112 rows, INNER 75. The rows both keep are identical.
At any instant, the history equals the snapshot
For every t in the window, the rows whose ValidPeriod contains
t are exactly build_dataset(..., as_of=t) with the same join type.
SELECT ... FROM HIST_CUSTOMER WHERE ValidPeriod CONTAINS TIMESTAMP '2023-05-14 00:00:00'
-- compared with
build_dataset(..., as_of='2023-05-14 00:00:00', join_type=...)
| Instant | Join type | Snapshot rows | History rows | Identical |
|---|---|---|---|---|
| 2023-05-12 | INNER / FULL OUTER | 538 | 538 | yes / yes |
| 2023-05-14 | INNER / FULL OUTER | 884 | 884 | yes / yes |
| 2023-05-15 | INNER / FULL OUTER | 1,079 | 1,079 | yes / yes |
Customer features of the demo store, compared value by value.
INNER uses Teradata's own sequenced join
SEQUENCED VALIDTIME intersects the valid periods itself. The window is a predicate
and a P_INTERSECT, then NORMALIZE, then the final columns.
SELECT z.Category, z.TDFS_VP AS ValidPeriod, BEGIN(z.TDFS_VP) AS ValidStart, ...
FROM ( SELECT NORMALIZE ON MEETS OR OVERLAPS ...
FROM ( SELECT x.*, x.TDFS_VT P_INTERSECT PERIOD(<start>, <end>) AS TDFS_VP
FROM ( SEQUENCED VALIDTIME
SELECT B1.Category, B1.FEATURE_VALUE AS sum_Transaction_Amount, ...
FROM FS_T_... B1 INNER JOIN FS_T_... B2 ON B1.Category = B2.Category ...
WHERE (B1.FEATURE_ID = 18 AND B1.FEATURE_VERSION = '...') ...
) x (..., TDFS_VT)
WHERE x.TDFS_VT OVERLAPS PERIOD(<start>, <end>) ) y ) z
What EXPLAIN shows (customer history, 4 features)
- 9 steps; each storage table read once, from a single partition
- rowkey-based merge joins, built locally, no redistribution
- the sliding window folded into the join step as constants:
TIMESTAMP '2026-10-06 07:50:41+00:00'
FULL OUTER cuts each timeline at every change
A sequenced outer join is refused: Error 9347, in a derived table and at the top level.
Change points first read each feature twice (starts, ends). One read now emits both bounds through a two-row expansion. Same rows, measured on the real category history:
| Change points | Storage reads | Redistributions | Rows |
|---|---|---|---|
| starts and ends read separately | 8 | 10 | 36 |
| one read, two-row expansion (shipped) | 3 | 2 | 36 |
FULL OUTER stays about twice as slow as INNER: each table is still read twice.
Why NORMALIZE, not TD_NORMALIZE_OVERLAP_MEET
TD_NORMALIZE_OVERLAP_MEET was tested first. Fed by a derived table, the input became
a second FROM item and was product-joined with the output:
SELECT n.* FROM (SELECT ... 3 rows ...) d,
TABLE (TD_SYSFNLIB.TD_NORMALIZE_OVERLAP_MEET(NEW VARIANT_TYPE(d.e, d.v), d.p)
RETURNS (e INTEGER, v INTEGER, p PERIOD(...), nrm_count INTEGER)
HASH BY e LOCAL ORDER BY e, v, p) AS n
-- 3 rows in, 6 rows out: every result row duplicated
The function
- correct only behind
WITH RETURNSmust match input types exactly (Error 9134)- an extra redistribution and sort
SELECT NORMALIZE ON MEETS OR OVERLAPS
- same rows on the same input
- a plain SELECT, valid in a view
- one aggregation step, no type list
Three behaviours of the store that a history makes visible
Unchanged values do not split periods
A process re-run on unchanged data, in real time or at an explicit valid time, left the stored
periods intact. On the demo store NORMALIZE had nothing to merge. It keeps "consecutive rows
differ" true for data written some other way.
"Unchanged" means bit-identical
A re-run summed floats in another order: 16038.699999999999 became 16038.7,
and the store opened a new period. The history shows two rows that print alike. Round such
features in the process view.
Real-time runs stamp each feature separately
Without FEATURE_STORE_TIME, each feature is merged by its own statement: one run
changed sum at 08:03:46 and max at 08:03:47. The history shows that second as a
period. TimeManager and roll_out runs share one valid start.
Registered, rebuilt and migrated like any dataset
The catalog records TIME_MODE='HISTORY' and the window. The view's own DDL is read back,
so nothing is re-resolved against the feature catalog.
# a snapshot rebuilt as a history
tdfs4ds.rebuild_dataset('DATASET_CUSTOMER', target_view_name='DATASET_CUSTOMER_HIST', history=True)
# a history's window moved
tdfs4ds.rebuild_dataset('HIST_CATEGORY', duration='6 months', replace=True)
# back to a snapshot
tdfs4ds.rebuild_dataset('HIST_CATEGORY', target_view_name='CATEGORY_NOW', current=True)
How it was verified
The demo notebooks 00 and 02–18 were run against a fresh demo_user on Vantage 20,
producing three data domains with rolled-out, filtered and real-time history. Every view was
compared with a reference computed in Python from the raw storage rows, value by value.
| Case | Join | Rows | Steps | Single-partition reads | Elapsed (warm) | Reference |
|---|---|---|---|---|---|---|
| Customer, 4 features | INNER | 1,079 | 9 | 4 | 0.13–0.17 s | identical |
| Customer, 4 features | FULL OUTER | 1,079 | 22 | 8 | 0.22–0.25 s | identical |
| Category, 4 features | INNER | 39 | 8 | 4 | 0.07 s | identical |
| Category, 4 features | FULL OUTER | 39 | 20 | 5 | 0.09–0.11 s | identical |
| Category, 2 × 3 versions | INNER | 75 | 9 | 6 | 0.07 s | identical |
| Category, 2 × 3 versions | FULL OUTER | 112 | 24 | 9 | 0.14–0.28 s | identical |
Four more windows (fixed, start + duration, end − duration, sliding with delay) × both join types: 8 of 8 identical. Offline: 114 tests for the window, both shapes and the DDL round trip.
Small data: read the plan columns, not the times, as the scaling indicator.