Temporality¶
A model's temporality is whether its unit of work has a time axis: TEMPORAL or TIMELESS. The temporality decides its unit of work, how many state rows it has when state tracking is on, and how a downstream contract checks it.
temporality defaults to Temporality.TEMPORAL (the common case). Set it explicitly from the Temporality enum when a model is timeless.
| temporality | unit of work | state rows |
|---|---|---|
Temporality.TEMPORAL |
a time window — or, unbatched, the whole [begin, end] range |
one per window (batched) · one (unbatched) |
Temporality.TIMELESS |
the whole thing | one |
A TEMPORAL model has a Contract time window. With batching it splits that window into chunks (one state row per window); without batching it loads its whole [begin, end] range in one run (one state row spanning the range). A TIMELESS model has no time axis — no batching, and no begin/end.
State is optional¶
Temporality does not require state — you never need state=State(...) just to set a temporality. They're independent:
- Without
statethe model simply runs its unit(s) every time — the window(s) of a temporal model, or the whole thing for a timeless one — recording nothing and resuming nothing. The "state rows" column above is what would be recorded if state were on. - With
state=State(...)bollhav writes those rows (per window / one-shot), so reruns skip already-appliedunits and you get status, errors, and resumability. See State.
The one case where state becomes required is gating: a Source that carries an UpstreamContract is enforced by the state machine, so a gated upstream without state is a construction error. Setting a temporality on its own never forces it.
View vs table¶
Materialization is a separate choice from the time axis — the materialization enum (Materialization.TABLE by default, or Materialization.VIEW). A VIEW is created with CREATE OR REPLACE VIEW from the model's defining query (its SELECT body, which lives on the model). Note: the query is the definition; materialization is how it's realized — a query alone does not make a model a view.
Model(temporality=Temporality.TEMPORAL, batching=Batch(...), ...) # a windowed table
Model(temporality=Temporality.TIMELESS, ...) # a whole-table load
Model(temporality=Temporality.TIMELESS, # a timeless view
materialization=Materialization.VIEW, query="SELECT ...")
Model(temporality=Temporality.TEMPORAL, # a temporal view —
materialization=Materialization.VIEW, query="SELECT ...",
contract=Contract(begin=..., end=...), ...) # its Contract is the range it covers
A view can't be batched — it's one CREATE VIEW, not materialized per-window. Its temporality is otherwise free: TEMPORAL, where its Contract begin/end declares the range it covers (recorded as a single state row a downstream can gate ENCAPSULATE against), or TIMELESS (existence only).
Validation¶
The temporality and its companions must agree, caught at construction:
Temporality.TIMELESSwithbatchingraises — a timeless model isn't windowed.Temporality.TIMELESSwith aContractbegin/endraises — no time to bound.materialization=Materialization.VIEWwithbatchingraises — a view isn't materialized per-window.materialization=Materialization.VIEWwithout aqueryraises — a view is created from its SELECT body.
Units per run¶
run.intervals (on the ModelRun from @load_models) yields one window per unit for a batched temporal model; a single None for a timeless model (or an unbatched temporal model with no declared range); and one interval spanning [begin, end] for an unbatched temporal model with a contract range — so the same loop runs the unit of work the right number of times.
Combinations¶
Two independent questions decide a model's shape — has a time axis? (TEMPORAL / TIMELESS) and materialized as a table or a view? (view=) — plus, for temporal tables only, chunk it? (batching).
flowchart TD
Start([New model]) --> Q1{Does its work<br/>reference time?}
Q1 -->|no| TL[temporality = TIMELESS]
Q1 -->|yes| TP[temporality = TEMPORAL]
TL --> Q2{Materialized?}
Q2 -->|table| T1["Timeless table<br/>1 whole-table row"]
Q2 -->|view| V1["Timeless view<br/>1 existence row"]
TP --> Q3{Process in<br/>time chunks?}
Q3 -->|yes · batching| B1["Temporal batched table<br/>1 row per window"]
Q3 -->|no · one-shot| Q4{Materialized?}
Q4 -->|table| O1["Temporal one-shot table<br/>1 row spanning begin..end"]
Q4 -->|view| O2["Temporal view<br/>1 row spanning begin..end"]
X1["TIMELESS + batching — rejected<br/>no time to window"]:::bad
X2["view + batching — rejected<br/>a view isn't materialized per-window"]:::bad
TL -.->X1
Q4 -.->X2
classDef bad fill:#7a1f1f,stroke:#c0392b,color:#fff
| Combo | Declare | State | Gate it with | Use when |
|---|---|---|---|---|
| Temporal batched table | temporality=TEMPORAL, batching=Batch(...) |
one row per window | ENCAPSULATE / THROUGH / WHOLE / EXISTS |
The default for incremental fact/event tables — daily/hourly loads, backfills, anything processed and resumed per time window. |
| Temporal one-shot table | temporality=TEMPORAL, contract=Contract(begin,end) (no batching) |
one row spanning [begin,end] | ENCAPSULATE (window ⊆ range) / WHOLE / EXISTS |
A time-bounded table loaded in a single pass — small enough not to chunk, or the source only does a whole-range read — but downstreams still ask "is my window covered." |
| Timeless table | temporality=TIMELESS |
one whole-table row | WHOLE / EXISTS (not ENCAPSULATE) |
Dimensions, reference / lookup tables, config — no time axis, reloaded wholesale. The only question is "is it loaded." |
| Temporal view | temporality=TEMPORAL, materialization=VIEW, query=..., contract=Contract(begin,end) |
one row spanning [begin,end] (no data) | ENCAPSULATE (window ⊆ range) / WHOLE / EXISTS |
A SQL view over time-ranged data where consumers care "is this view current through my window." Declare the range; enforce it by gating the view's own source ENCAPSULATE. |
| Timeless view | temporality=TIMELESS, materialization=VIEW, query=... |
one existence row (no data) | WHOLE / EXISTS |
A plain view — renames, joins, projections, lookups — where the only question is "does it exist." |
The two rejected corners each follow from one rule: timeless can't batch (batching chops a time window, and there's no time axis), and a view can't batch (batching is per-window materialization, and a view materializes nothing — its only way to carry time is the one-shot [begin, end] row).