Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Time References

The store records instants. A time_reference records what those instants were written as, so a series comes back the way it went in instead of being relabeled UTC at every boundary.

SpellingMeaning
utcAn instant, written as UTC.
-07:00An instant, written at a fixed offset from UTC.
America/DenverAn instant, written in a named IANA zone. Held opaquely.
zonelessA wall clock. Names no instant; the store holds it as if UTC.
unsetUnspecified — not a claim the timestamps were written as UTC.

Three of the four name an instant; zoneless does not, and most rules below split on that binary rather than on the four spellings. An unset reference groups with the zoned ones.

Each binding infers the spelling from the input type, so nothing takes a new required argument:

Bindingutcfixed offsetnamed zonezoneless
Pythontimezone.utcfixed-offset tzinfotzinfo exposing a key (ZoneInfo)naive datetime
JuliaUTC ZonedDateTimeFixedTimeZoneVariableTimeZone, by its namebare DateTime
CLIZ in text, or the flag-07:00 in text or flag--assume-timezone America/Denverbare timestamp, --zoneless
RustDateTime<Utc>declare itdeclare itdeclare it — no naive type

ZoneInfo("UTC") records the zone UTC, not the literal utc. The two render identically forever; the difference shows up only in what the catalog reports back, which is the point of recording a spelling at all.

A spelling is not a grid

A reference records how timestamps were written. It does not change how the grid is stepped: resolution and interval are durations, so an hourly series has hourly instants whatever its reference says. Rendering an hourly America/Denver series across the November fall-back gives 01:00-06:00, 01:00-07:00, 02:00-07:00 — two identical wall clocks, two distinct instants, correctly ordered.

That is the difference between two things "store this in Denver time" can mean:

  • Instants, displayed in Denver. Storage is untouched — UTC instants plus a label. This is what a named zone means here.
  • A local-clock grid — hourly by the clock, so a 23-hour day in March and a 25-hour one in November. This is inexpressible in SingleTimeSeries and the dense forecasts, whose grid is a Period: a fixed count of milliseconds. Use NonSequentialTimeSeries, which carries an explicit instant per value, so the caller derives those days and the data records them rather than arithmetic implying them.

The store now checks, when you give it something to check

Asserting initial_timestamp + resolution is a claim the store cannot verify — the vector it describes is never supplied. Two rules close that:

  • A calendar-scale period on a named zone is refused. A period of a day or more is a span of instants, so on a zone that observes DST it drifts away from the local clock it looks like: a P1D series stepped from local midnight in Denver lands on Nov 3 23:00 after the November transition, on the same calendar day as its predecessor, and stays an hour off forever. P1M is worse — it steps the UTC calendar. Both are InvalidParameter at the write.
  • Sub-daily periods stay legal, and that is not a compromise. DST moves the offset, not the length of an hour: Denver has 8784 hours in 2024, exactly as UTC does, every gap exactly one hour, and the 23- and 25-hour days fall out of instant stepping on their own. An hourly grid in a DST zone is the local clock. Refusing it would push callers onto a fixed offset, which is silently wrong for half the year.

A forecast's horizon is exempt: it is a window length, only ever divided (H = horizon / resolution) and never added to an instant, so horizon = P1D — the canonical day-ahead shape — stays legal in any zone. resolution and interval do step, and are covered.

The constructive half is from_timestamps: hand over the timeline you actually have and the store infers the period and proves the instants lie on it, or refuses naming the entry that broke the pattern. This is how a local-clock timeline reaches the store — you materialize it in your own date library, where the policy for a nonexistent or ambiguous wall clock belongs, and the store records what you have rather than what a resolution implies.

Someone with 8760 naive Denver timestamps who localizes only the first and passes resolution = 1h still gets labels shifted by an hour after each transition if their file was not a local-clock walk, and nothing in the values distinguishes that from a correct series — so hand the vector over rather than assert the step.

Months step on the UTC calendar

Period::Months is calendar arithmetic, so unlike a fixed period it has to be told which calendar. It uses the stored UTC one, and the reference does not redirect it. TimeZones.jl steps the local clock instead, so the two disagree by an hour at every DST transition and by up to a day at a month boundary.

Local-frame stepping is refused for three independent reasons: it is the local → instant direction the store deliberately never runs (below); it would let a spelling decide which instants a series contains; and it would need a time-zone database in the core, which would make a stored series' instants depend on which IANA release built the reader. A calendar period on a named zone is therefore refused on write, and on a fixed offset — which has no DST to drift against, only a month boundary — it is warned about. A caller who wants months on a local calendar wants a local-clock grid, and the answer is the one above: NonSequentialTimeSeries, or from_timestamps, which picks between the two for you.

Why a named zone is safe

The ambiguity a named zone is feared for lives in the local → instant direction, and the core never runs it.

  • On input that direction has already happened, in the caller's own datetime library. Julia refuses an ambiguous local time outright; Python resolves it through fold. Either way the binding is handed a value that already names one definite instant. The CLI is the exception, because it is handed text — see below.
  • On output the store runs only instant → local, which is total and single-valued: one instant maps to exactly one wall clock in a named zone, and converting it back yields the same instant.

So a year-long Denver series stamped -07:00 renders every timestamp after the March transition an hour wrong, while the same series stamped America/Denver renders all of them correctly. Recording "the offset in effect at initial_timestamp" is the one option that is quietly incorrect, which is why it is not among the four spellings.

Two caveats belong here rather than in the type. Rendering a named zone is tz-database-dependent, so a retroactive rule change moves the displayed local time of an already-stored instant — the store records the instant, and the label is a rendering hint. And a zone name's existence is audited, never gated: the core checks only that a name is shaped like an IANA name and cannot be read as an offset or as either literal. Every layer that has a database — the CLI via chrono-tz, Python via zoneinfo, Julia via TimeZones — warns on a name it does not recognize and stores it anyway, and infrastore store-info reports the catalog's distinct spellings with unrecognized zones flagged. Gating would turn a rare read-time error into a write-time error coupled to our release cadence: when IANA adds a zone, a caller whose own database already has it would be refused until they upgraded.

The CLI is where local → instant actually happens

Every other binding is handed an already-resolved datetime. The CLI is handed text, so --assume-timezone America/Denver over a zoneless column is the one place in the system that runs local → instant itself, and chrono-tz answers in three values — each with its own behavior, per row:

ResultMeaningCLI behavior
a single instantthe ordinary caseingest it
two candidatesthe repeated fall-back hourerror, naming the row and both candidates
nonethe skipped spring-forward hourerror, naming the row

Rejecting loudly, per row, with both candidates named is what makes a named zone acceptable here; silently picking one is not. Reading is unaffected: rendering a stored instant in a named zone is the total direction, so --assume-timezone plays no part in it.

Query bounds and mixed selections

A bound must be spelled the way the series is, and a mismatch is refused rather than coerced:

Series referenceWall-clock boundInstant bound
utc / offset / zoneerroraccept — any offset names the same instant
zonelessaccepterror
unseterroraccept

An off-grid bound still names an unambiguous instant, so flooring it is well-defined — that is why time_range snaps. A wall-clock bound against a series that records instants is a category error: there is no defined mapping to fall back on. Bounds stay unconstrained in precision, though: a sub-millisecond bound names a real instant even though a stored one may not.

The same partition drives two rejections and one filter:

  1. A ranged bulk read over a selection spanning both groups is refused — no single bound is valid for all of it. An unranged one is unaffected: without a bound there is nothing to disagree about, and each series carries its own spelling back.
  2. A StaticReader materializes one timestamp axis, so a mixed cohort is refused at build time, where the error can name the series that disagree. Mixing utc, an offset, and a named zone in one cohort is fine — all three name instants, and the axis is spelled with the cohort's reference when every member agrees and utc when they merely agree on naming instants.
  3. ListFilter::zoneless is the constructive half: true selects the wall-clock series, false selects everything that accepts an instant bound — the three zoned spellings and the rows that left the reference unset. It is a binary predicate rather than a match on a specific spelling because an exact match cannot name that second group at all (the trap component_field documents), and here those rows are a coherence group rather than an oversight.