Diese Seite gibt es noch nicht auf Deutsch, deshalb steht sie hier auf Englisch. Zur englischen Seite

Named Time Zones#

Status: designed, not built (2026-10-25). — std/time has the wall clock without a zone database: Date, TimeOfDay, DateTime, UtcOffset and OffsetDateTime (FIRST-PARTY.md gap 11, slices 1 to 3, #111, #387, #388). What is missing is the last slice of that gap: a place on the map, Europe/Berlin, that knows its own offset on every day, daylight saving included. This record decides how the data gets into a program, what TimeZone is allowed to reach, what a local time in a gap or an overlap means, the shape of the API and how far the first version reaches into the future, so that the slices of section 9 build one specification. It is a design inside std/time: nothing named here exists yet, so no published contract changes.

A TimeZone is a value with its rules inside it, and a local time that is not one instant is never guessed. TimeZone.named "Europe/Berlin" reads no file and asks no machine: the data of one pinned tzdata release is part of the toolchain, so the same program answers the same on Linux, Windows and in the playground. And the two days of the year on which a wall clock is ambiguous - 2026-03-29 02:30 does not exist in Berlin, 2026-10-25 02:30 happens twice - are decided by an argument the caller has to write, Disambiguation, and by no default.

1. What is decided#

  • The data is compiled into std/time, generated as TorbScript from one pinned tzdata release by a generator in tools/; the system's zoneinfo is not read, by std at least (section 2).
  • The full history is kept, in a compact transition table of an estimated 76 KB for 447 zones (to be re-measured in slice 1), and only the module that TimeZone imports carries it (section 3).
  • TimeZone and ZonedDateTime are capability-free values like Timestamp and Duration; only reading the clock is clock (section 4).
  • Disambiguation is Earlier, Later or Reject, and it is a required argument wherever a local time becomes an instant (section 5).
  • TimeZone.named, ZonedDateTime, toTimestamp, toOffsetDateTime and RFC 9557 text are the surface (section 6).
  • The first version includes the POSIX TZ rule that continues each zone after its last stored transition: 127 of 447 zones end in one and without it every date after the last stored transition would be wrong (section 7).
  • Seed impact: none. The data is TorbScript constants and the generator is a .trb program; no syntax, native or driver flag changes (CLAUDE.md, "Seed and breaking changes").

2. Where the data comes from#

Three sources were possible: compiled into the toolchain, the zoneinfo of the machine, or both with a defined order.

SourceForAgainst
Compiled insame answer everywhere; pure; works in the playgrounda tzdata release is a std one
System zoneinfoalways the machine's newest rulesabsent on Windows and in the playground; needs files
Both, system firsta fresh rule without a std releasedepends on the machine again; named would need files

Decided: compiled in, and only that. The reasons are the two against the system's database: a language whose Date is the same on every target (RANDOM.md pins the same promise for its generator) cannot have a zone that is not, and a capability that a value constructor needs would put files on every package that parses a timestamp with a zone. tzdata is in the public domain.

Open points of this record. Two statements below are not yet settled facts. (1) The decision to build zic from the pinned tzcode is proposed in this record; the refinement of #389 did not decide it. It is open to a better way of getting TZif files (a pinned prebuilt zic, or reading the sources directly), to be confirmed in slice 1. (2) The size figures of section 3 are the refinement's; whether they include abbreviations is unconfirmed (section 3).

  • How it is generated. Like the Unicode tables (tools/fetch-unicode.sh pins every input by SHA-256 so the generated output is reproducible from them), tools/fetch-tzdata.sh downloads one tzdata release and its tzcode and checks both against tools/tzdata/SHA256SUMS. Proposed, not a refinement outcome: zic -b slim, built from the pinned tzcode with the C compiler every checkout already needs, compiles the sources to TZif files (RFC 9636), and tools/tzdata/generate.trb reads those
    • the transitions, the types and the POSIX footer - and writes the data module. The generated file is committed, a gate regenerates it and compares, as for the other generated tables.
  • The update path. A new tzdata release is new lines in SHA256SUMS, a regenerated data file and a std patch release. TimeZone.databaseVersion() answers the pinned release (2026c), so a program can print which rules it was built with. A runtime override from the system's zoneinfo is not part of std: it can come later as a package with the files capability that builds a TimeZone from a TZif file, without changing TimeZone.
  • Names. TimeZone.named knows the 598 names of the pinned release (447 distinct zones, 151 of the names are links and one name table points them at the zone index, they are not copies). A name that a later release removes stays, as tzdata keeps its backward links. Names are case-sensitive, as IANA writes them.

3. Size#

The TZif figures were measured on tzdata 2026c compiled with zic -b slim; the compact-table figures are estimates from the refinement comment of #389, not measurements of a built table (the numbers come from the refinement of #389).

FormSize
TZif, slim, full history, 447 zones235 395 bytes (52 623 deflated), 18 657 transitions
Compact table, full historyabout 76 KB (estimate)
Compact table, from 1970 onabout 51 KB (estimate)
TZif, fat (expanded to 2037), for comparison27 184 transitions, 46 % more

The compact table stores each transition as the delta to the one before in minutes as a varint plus a one-byte index into the zone's type table (offset, daylight-saving flag). 525 of the 18 657 transitions are not on a whole minute (the local mean times before standard time), so a flag marks the exceptions and seconds stay in the format; UtcOffset already holds whole seconds. The estimates count transitions and a type table of offset and daylight-saving flag. Unconfirmed: whether the 76 KB include abbreviations (CET, CEST); section 8 leaves them out of the first version. Slice 1 re-measures the real table, with and without abbreviations, and this section is corrected to the result.

Decided: the full history. From 1970 on is 25 KB smaller, and costs a cliff: a birth date in 1960 in a named zone would answer with the offset of 1970. Timestamp itself reaches from 1677 to 2262, so a smaller history has no natural edge to stop at.

Whether a program that imports only Timestamp pays for the data. It does not pay in the program: torb build seeds the entry function alone and emits nothing unreachable (compiler/src/ir/lower/lower.trb), and a module's top-level const is a static value the const evaluator makes where it is read, with no module initialization ever (docs/language/modules-and-packages/top-level-code.md, rule 5). Data that is only read through TimeZone.named is therefore in the binary and the VM's bytecode only of a program that reaches it. What every program that imports the module does pay is the parse and the check of the generated source. So the data goes to its own file, zone-data.trb, that only TimeZone imports, as functions over text (the pattern of std/html/src/entities.trb: one function with a match over the region, each arm one text of code points), because a function is only lowered where something calls it. The first slice measures torb check of a program that imports only Timestamp, before and after, in forge CI; if the check cost is noticeable, the data moves into a package of its own that std/time re-exports lazily, which changes no name of the API.

4. Capability#

TimeZone and ZonedDateTime are values and reach no capability, as Duration, Instant and Timestamp do (RELEASE.md section 7.8, "every imported name decides"). TimeZone.named is pure: the data is in the program. Reading the clock stays Clock.timestamp() and clock, so Clock.timestamp().inZone zone is where the capability is visible, at the one call that needs it.

The two names are added to the values of std/time that capabilityOfImport answers None for (compiler/src/package/archive.trb) and to the table of docs/tooling/package-capabilities.md; the registry test that compares the two keeps them agreeing. Disambiguation and TimeZoneKind (below) are values too.

5. Gaps and overlaps#

A local time is not always one instant. Where the clocks go forward (a gap) a local time does not exist, where they go back (an overlap) it happens twice. Disambiguation is an enum of Earlier, Later and Reject, and every function that turns a local time into an instant takes one, with no default value: the caller writes what a booking system, a scheduler or a log reader means.

  • Earlier: in an overlap the first of the two instants (the offset before the clocks went back); in a gap the local time read with the offset that holds after the gap, which is the earlier instant.
  • Later: in an overlap the second instant; in a gap the local time read with the offset that holds before the gap, which is the later instant.
  • Reject: a failure in both cases, a TimeParseError that names the zone and the local time and says whether it is a gap or an overlap.
  • Outside a gap or an overlap all three answer the same, and Reject never fails.

Europe/Berlin, to be copied by the conformance programs (every result is written with the suffix [Europe/Berlin], left out of the cells):

Local timeCaseEarlierLaterReject
2026-10-25 02:30overlap, +02:00 then +01:002026-10-25T02:30:00+02:002026-10-25T02:30:00+01:00fails
2026-03-29 02:30gap, +01:00 then +02:002026-03-29T01:30:00+01:002026-03-29T03:30:00+02:00fails
2026-07-01 12:00neither2026-07-01T12:00:00+02:00the samethe same

In the gap both answers move the local time by the length of the gap, one hour, and none of them keeps 02:30.

const zone = TimeZone.named("Europe/Berlin").expect("a zone")
const local = DateTime.parse("2026-10-25T02:30:00").expect("a date and time")
print local.inZone(zone, .Earlier)
print local.inZone(zone, .Later)
// prints Ok(2026-10-25T02:30:00+02:00[Europe/Berlin])
// prints Ok(2026-10-25T02:30:00+01:00[Europe/Berlin])

The infallible direction needs no argument: an instant has exactly one local time in a zone, Timestamp.inZone zone.

6. The API#

The names follow what std/time has: methods on the types, Result<_, TimeParseError> where a value can be refused, and nothing in the prelude (use TimeZone, ZonedDateTime, Disambiguation from "std/time").

  • TimeZone (Compare, Equals, Hash, Show): TimeZone.named(name: String): Result<TimeZone, TimeParseError> for the IANA names; TimeZone.utc() and TimeZone.fixed(offset: UtcOffset) for the two that are no database entry; offsetAt(instant: Timestamp): UtcOffset; name(): String; and TimeZone.databaseVersion(): String. Equality is by the name it was made with, so a zone and a link that points at it are two values that give the same offsets, and show round-trips the name that was written.
  • Disambiguation (Equals, Hash, Show): Earlier, Later, Reject (section 5).
  • ZonedDateTime (Compare, Equals, Hash, Show): an OffsetDateTime and the TimeZone it was made in, and the invariant that the offset is the zone's offset at that instant. Compare orders by the instant like OffsetDateTime; Equals also compares the zone, so two values that are the same instant in two zones are ordered equal and are not equal, which is why ZonedDateTime is not the key of an ordered set by instant alone. Members: zone(), offset(), dateTime(), date(), timeOfDay(), toTimestamp(): Result<Timestamp, TimeParseError> and toOffsetDateTime(): OffsetDateTime. It holds an OffsetDateTime and not a Timestamp, so its range is the one of OffsetDateTime (the years 0000 to 9999) and only toTimestamp can fail on the range of 1677 to 2262.
  • Making one: DateTime.inZone(zone, disambiguation): Result<ZonedDateTime, TimeParseError> (fails for Reject in a gap or overlap) and Timestamp.inZone(zone): ZonedDateTime (cannot fail inside the range of Timestamp).
  • Arithmetic has two meanings and two names. plus(duration: Duration) counts exact seconds on the instant, so it steps over a gap or an overlap and the local time jumps; plusDays(days: Int64, disambiguation: Disambiguation) keeps the wall-clock time and moves the calendar, which is the one that can land in a gap and so takes the argument. Months and years wait for a Date.plusMonths that does not exist yet; the zone adds nothing to it but the argument.
  • Text is RFC 9557: Show writes 2026-10-25T02:30:00+01:00[Europe/Berlin] (an OffsetDateTime, then the name in brackets), and ZonedDateTime.parse reads the same. The offset in the text has to be valid in the zone at that local time: in an overlap it is what chooses the instant, anywhere else a text whose offset the zone does not have at that time fails with a TimeParseError and the two offsets in the message. Critical flags ([!Europe/Berlin]) and other suffix tags are rejected until something needs them.

7. Years after the last transition: POSIX rules#

Slim TZif files list transitions only until a rule takes over, which is stored as the POSIX TZ string of the file's footer (CET-1CEST,M3.5.0,M10.5.0/3 for Berlin). Without evaluating it every date after the last stored transition (1996 for Berlin) is wrong, and expanding to 2037 instead is the fat form, which stores 46 % more transitions and is still wrong from 2038. The first version includes the rule.

  • The numbers. Of the 447 zones 127 end in a rule, with 31 distinct rule strings; the other 320 are fixed. Every rule of the data is in the Mm.w.d[/time] form (month, week 1 to 5 where 5 is the last, day of week), so the parser knows only that form; the Jn and n day-of-year forms are rejected with a clear error and are never reached by the data.
  • The time of a rule is not a clock time of a day. It may be outside 0 to 23 (/24, /-1, /2:45), up to 167 hours in either direction as RFC 9636 allows; the transition is at that many hours after midnight of the day found.
  • Negative daylight saving exists in the data (Europe/Dublin writes standard time as the summer offset): the rule's second name does not mean "later in the year the offset is larger". The evaluator works with the two offsets and the two transition instants and never with the words standard and daylight.
  • The southern hemisphere has its start after its end in the calendar year; the evaluator orders the two transitions of a year by instant.
  • One evaluation, two uses. offsetAt(instant) and the local-to-instant search of section 5 both ask the rule for the two transitions of a year, so the gap and the overlap of a future date are found by the same code that finds those of a stored transition.
  • Tests. The conformance programs compare the rule's answer with the stored transitions for every year where a zone has both (the last stored transitions of every ruled zone are generated by the same rule), plus the worked examples of section 5.

8. What stays out#

  • Leap seconds. Timestamp is Unix time, and the data is the non-"right" TZif that ignores them.
  • Abbreviations (CET, CEST) are not stored and not in the API of the first version: they are not unique and not parseable, RFC 9557 does not use them, and a later slice that needs them (a strftime-like format) adds them to the type table with its own measurement.
  • Reading the system's zoneinfo, per-user zone settings and a TimeZone.local() that asks the machine for its zone: each needs a capability (files or environment) and would be a package or a name of its own, never a change of TimeZone.named.
  • A calendar other than the Gregorian and Date.plusMonths: not zone questions; the latter is the next thing std/time needs for calendar arithmetic in a zone.

9. Slices#

One slice is one pull request that lands as one or two Conventional Commits. Each is filed as an issue when the one before it starts, not up front.

#SliceSeedNeeds
1fetch-tzdata.sh, pinned SHA256SUMS, build of zic, TZif reader generate.trb, size re-measurednone-
2Generated zone-data.trb (section 3), regeneration gate, check cost of a Timestamp-only programnone1
3TimeZone: named, utc, fixed, offsetAt, databaseVersion, POSIX rules (section 7)none2
4Disambiguation, ZonedDateTime, both inZone, conversions, plus, plusDaysnone3
5RFC 9557 Show and ZonedDateTime.parsenone4
6The capability table entries, docs/standard-library/time.md, and gap 11 of FIRST-PARTY.md closednone5

No slice changes the syntax, adds a native or a driver flag, or renames a name the compiler looks up by string, so none needs the two-commit seed refresh.

Esc