Time zone
An offset such as +03:00, from Date and time, describes a clock at one moment. It does not describe a place. Kyiv keeps +02:00 in winter and +03:00 in summer; New York keeps -05:00 and -04:00, and changes on different dates. To say "09:00 in Kyiv" on a date you have not looked up yet, you need the place's rules — a time zone.
A TimeZone turns a moment into the local time there, and a local time back into a moment. The second direction is the interesting one, because once a year a local time happens twice, and once a year it does not happen at all:
import TimeZone::{ LocalTimeKind, TimeZone, TimeZoneError };
Where a zone comes from
| Function | Builds a zone from |
|---|---|
TimeZone::Utc | nothing — UTC never changes |
TimeZone::FromRule | a POSIX TZ rule, such as EET-2EEST,M3.5.0/3,M10.5.0/4 |
TimeZone::Load | the tz database, by name, such as Europe/Kyiv |
TimeZone::Local | the computer's own setting |
TimeZone::FromTzif | the bytes of a tz database file you read yourself |
Load is what most programs on Linux and macOS use, since the tz database holds every change a zone has ever made. But Windows keeps no tz database, so there Load finds nothing unless the program points the TZDIR environment variable at a copy. This lesson builds its zones from rules, which work the same everywhere, and leaves Local out because its answer depends on the machine.
Each of them allocates and can fail, so each returns TimeZone ! TimeZoneError, and each takes an allocator:
let utc = TimeZone::Utc(allocator) catch { else => return 1 };
let kyiv = TimeZone::FromRule(allocator, "Kyiv", "EET-2EEST,M3.5.0/3,M10.5.0/4") catch {
else => return 1
};
A zone owns that memory, so it is move-only, and the functions in this program borrow it as &TimeZone.
Reading a POSIX rule
A rule packs a whole year into one line. Kyiv's reads:
| Part | Means |
|---|---|
EET | the standard time's abbreviation |
-2 | its offset — minus means east of UTC, so this is +02:00 |
EEST | the daylight time's abbreviation, an hour ahead unless an offset follows it |
M3.5.0/3 | daylight time starts in month 3, week 5 (the last), on day 0 (Sunday), at 3:00 |
M10.5.0/4 | and ends in month 10, on the last Sunday, at 4:00 daylight time |
New York's EST5EDT,M3.2.0,M11.1.0 reads the same way: five hours west, with daylight time from the second Sunday in March to the first Sunday in November, at the default 2:00. A malformed rule, such as X5, whose name is too short, fails with TimeZoneError::InvalidRule.
From a moment to local time
StateAt says what the zone keeps at a moment — its offset, its abbreviation and whether it is daylight time — and ToLocal gives the local date and time as an OffsetDateTime:
func ShowMoment(zone: &TimeZone, moment: Timestamp) {
let state = zone.StateAt(moment);
PrintLine(" {:<8} {:<4} daylight {:<5} {}", zone.Name(), state.abbreviation, state.daylight,
zone.ToLocal(moment));
}
This direction always has exactly one answer. The program reads noon UTC on 15 January and on 1 July on three clocks: Kyiv moves from +02:00 to +03:00, New York from -05:00 to -04:00, and UTC stays where it is.
From local time to a moment
Going back is where clocks get awkward. On the last Sunday of March, Kyiv's clocks jump from 03:00 straight to 04:00; on the last Sunday of October they go back from 04:00 to 03:00:
flowchart LR
q["a local time<br/>in Kyiv"] --> r{"`Resolve`"}
r -- "an ordinary day" --> u["`Unique`:<br/>one moment"]
r -- "29 March, 03:30" --> s["`Skipped`:<br/>no moment"]
r -- "25 October, 03:30" --> a["`Ambiguous`:<br/>two moments"]So Resolve does not return one Timestamp. It returns a LocalTime: a kind, which is a LocalTimeKind, and two candidate moments, earlier and later:
let found = zone.Resolve(local);
match found.kind {
LocalTimeKind::Unique => PrintLine(" {} happens once: {}", text,
zone.ToLocal(found.earlier)),
LocalTimeKind::Ambiguous => PrintLine(" {} happens twice: {} then {}", text,
zone.ToLocal(found.earlier), zone.ToLocal(found.later)),
LocalTimeKind::Skipped => PrintLine(" {} never happens: taken as {}", text,
zone.ToLocal(found.later))
}
| Kind | earlier | later |
|---|---|---|
Unique | the moment | the same moment |
Ambiguous | the first time, still in summer time | the second time, an hour later |
Skipped | 03:30 at the new offset: 02:30 on the clock | 03:30 at the old offset: 04:30 on the clock |
For a skipped time, later is what most programs want: an alarm set for 03:30 rings at 04:30, an hour after 02:30, just as it would have without the change. For a repeated time there is no right answer in general — a nightly job should usually run only on the first, a meeting may mean either — so the choice is left to you.
The program
The whole lesson is one package in the Examples repository. Its comments explain every step.
// An offset such as +03:00 says how far a clock is from UTC at one moment. A time zone says which
// offset a place uses at every moment: Kyiv keeps +02:00 in winter and +03:00 in summer, and
// moves between them on dates its rules fix. So a time zone turns a moment into the local time
// there, and back.
//
// `TimeZone::FromRule` builds a zone from a POSIX `TZ` rule, a short text that names the
// standard time, its offset, the daylight time and when it starts and ends. Kyiv's is
// `EET-2EEST,M3.5.0/3,M10.5.0/4`: EET, two hours east of UTC (POSIX writes east as minus), and
// EEST from 03:00 on the last Sunday of March (M3.5.0/3) until 04:00 on the last Sunday of
// October (M10.5.0/4). `TimeZone::Load` reads a zone's whole history from the tz database by
// name instead, but Windows has no such database, so this program builds its zones from rules.
//
// `StateAt` gives the offset, the abbreviation and whether it is daylight time at a moment, and
// `ToLocal` the local date and time. `Resolve` goes the other way, from a local date and time to
// the moment, and says whether that local time happened once, twice as clocks fell back over it,
// or never as they sprang forward over it.
import Allocator::{ Allocator, SystemAllocator };
import Io::PrintLine;
import Time::{ ParseDateTime, ParseRfc3339, Timestamp };
import TimeZone::{ LocalTimeKind, TimeZone, TimeZoneError };
func Reason(error: TimeZoneError) -> char8[..] {
return match error {
TimeZoneError::NotFound => "no zone of that name",
TimeZoneError::InvalidName => "not a zone name",
TimeZoneError::InvalidData => "not a tz database file",
TimeZoneError::InvalidRule => "not a POSIX TZ rule",
TimeZoneError::Unreadable => "the zone file could not be read",
TimeZoneError::OutOfMemory => "out of memory"
};
}
// The local time in `zone` at `moment`, with what the zone calls it.
func ShowMoment(zone: &TimeZone, moment: Timestamp) {
let state = zone.StateAt(moment);
PrintLine(" {:<8} {:<4} daylight {:<5} {}", zone.Name(), state.abbreviation, state.daylight,
zone.ToLocal(moment));
}
// Which moments the local date and time `text` stands for in `zone`.
func ShowLocal(zone: &TimeZone, text: char8[..]) {
let local = ParseDateTime(text) catch {
else => {
PrintLine("{} is not a date and time", text);
return;
}
};
let found = zone.Resolve(local);
match found.kind {
LocalTimeKind::Unique => PrintLine(" {} happens once: {}", text,
zone.ToLocal(found.earlier)),
LocalTimeKind::Ambiguous => PrintLine(" {} happens twice: {} then {}", text,
zone.ToLocal(found.earlier), zone.ToLocal(found.later)),
LocalTimeKind::Skipped => PrintLine(" {} never happens: taken as {}", text,
zone.ToLocal(found.later))
}
}
func Main() -> int {
var system = SystemAllocator();
let allocator: Allocator = system;
// Building a zone allocates, and a rule can be malformed, so each returns
// `TimeZone ! TimeZoneError`. A zone owns that memory, so it is never copied by accident:
//
// let copy = kyiv;
// error: move-only value 'kyiv' requires an explicit '<-' in initialization
//
// The functions below borrow it instead, as `&TimeZone`.
let utc = TimeZone::Utc(allocator) catch { else => return 1 };
let kyiv = TimeZone::FromRule(allocator, "Kyiv", "EET-2EEST,M3.5.0/3,M10.5.0/4") catch {
else => return 1
};
let newYork = TimeZone::FromRule(allocator, "New York", "EST5EDT,M3.2.0,M11.1.0") catch {
else => return 1
};
// One moment in winter and one in summer, each read on three clocks. The offsets change with
// the season, and by a different amount on different dates in each place.
let winter = ParseRfc3339("2026-01-15T12:00:00Z") catch { else => return 1 };
let summer = ParseRfc3339("2026-07-01T12:00:00Z") catch { else => return 1 };
PrintLine("{}", winter);
ShowMoment(utc, winter.ToTimestamp());
ShowMoment(kyiv, winter.ToTimestamp());
ShowMoment(newYork, winter.ToTimestamp());
PrintLine("{}", summer);
ShowMoment(utc, summer.ToTimestamp());
ShowMoment(kyiv, summer.ToTimestamp());
ShowMoment(newYork, summer.ToTimestamp());
// From local time back to a moment. On 29 March 2026 Kyiv's clocks jump from 03:00 to 04:00,
// so 03:30 never happens; on 25 October they go back from 04:00 to 03:00, so 03:30 happens
// twice, an hour apart. That is why `Resolve` returns a `LocalTime`, with the kind and both
// candidate moments, and not one `Timestamp`:
//
// let moment: Timestamp = kyiv.Resolve(local);
// error: cannot assign 'LocalTime' to 'Timestamp'
PrintLine("Kyiv local times:");
ShowLocal(kyiv, "2026-07-01T15:00:00");
ShowLocal(kyiv, "2026-03-29T03:30:00");
ShowLocal(kyiv, "2026-10-25T03:30:00");
// A rule must name its standard time with at least three letters, then give its offset.
match TimeZone::FromRule(allocator, "Nowhere", "X5") {
.Success(_) => PrintLine("rule X5 accepted"),
.Failure(error) => PrintLine("rule X5 refused: {}", Reason(error))
}
return 0;
}
Besides Io, its Rux.toml lists Allocator, Time and TimeZone under [Dependencies].
Run it
cd Examples/Utilities/TimeZone
rux run
2026-01-15T12:00:00Z
UTC UTC daylight false 2026-01-15T12:00:00Z
Kyiv EET daylight false 2026-01-15T14:00:00+02:00
New York EST daylight false 2026-01-15T07:00:00-05:00
2026-07-01T12:00:00Z
UTC UTC daylight false 2026-07-01T12:00:00Z
Kyiv EEST daylight true 2026-07-01T15:00:00+03:00
New York EDT daylight true 2026-07-01T08:00:00-04:00
Kyiv local times:
2026-07-01T15:00:00 happens once: 2026-07-01T15:00:00+03:00
2026-03-29T03:30:00 never happens: taken as 2026-03-29T04:30:00+03:00
2026-10-25T03:30:00 happens twice: 2026-10-25T03:30:00+03:00 then 2026-10-25T03:30:00+02:00
rule X5 refused: not a POSIX TZ rule
Common mistakes
Try it yourself
- Resolve
2026-03-08T02:30:00and2026-11-01T01:30:00in New York. Which kind is each? - Add Sydney,
AEST-10AEDT,M10.1.0,M4.1.0/3, and read both moments on its clock. Which of them is in daylight time, and why? - For the skipped time, also print
found.earlieron Kyiv's clock. What does it read? - Print how many seconds apart the two moments of the repeated 03:30 are, with
UnixSeconds().
Learn more
- TimeZone API — every function, including
Load,LocalandFromTzif - Date and time —
DateTime,UtcOffset,OffsetDateTimeand RFC 3339 - Enum — matching the cases of
LocalTimeKind - Move — why a zone is passed by reference
- Age — a checkpoint project that works out an age from two calendar dates
- Enums and Copy and move in the Rux Reference
20.10 Nano ID
Make short, URL-safe random identifiers with NanoId::Generate, read them back strictly with NanoId::Parse, and fill a buffer of your own length from a custom alphabet with GenerateNanoId.
Overview
Read and write JSON and TOML, write bytes as Base64 and its relatives, take URLs apart and put them together, and compress data: eight lessons on the formats programs exchange.