How to work with timezones
Short answer: the standard library has one timezone, and it is UTC. SystemTime::now() counts seconds since 1970-01-01 UTC no matter where the machine sits, and there is no local-time API anywhere in std. Working with timezones in std-only Rust is therefore two moves: store everything in UTC, and leave the conversion to whatever sits at the edge of your program. This page shows the proof that the TZ environment variable really has no effect, what a timezone actually changes, and the DST trap that breaks 86,400-second day arithmetic.
From a fresh project — cargo new tz-demo, then replace src/main.rs:
use std::time::{Duration, UNIX_EPOCH};
fn main() { let stored: u64 = 1_793_000_000; let moment = UNIX_EPOCH + Duration::from_secs(stored); println!("timestamp: {}", moment.duration_since(UNIX_EPOCH).unwrap().as_secs());}Now run it twice, once claiming to be in Tokyo and once in New York:
TZ=Asia/Tokyo cargo run -qTZ=America/New_York cargo run -qtimestamp: 1793000000timestamp: 1793000000(That is the combined output of the two runs — one line each.)
The TZ variable is what every tool around Rust consults: date, ls -l, most log libraries read it to decide how to print times. Rust’s standard library never reads it. The same timestamp prints the same number in Tokyo and in New York, because the number does not describe a wall clock — it describes an instant. This is a design decision, not an oversight: a “local time” needs a timezone database (which offset applies here, when daylight saving switches, all of it subject to change by law), and std does not ship one. So the clock it hands you is UTC by construction.
What a timezone actually changes
Section titled “What a timezone actually changes”A timezone is two things: an offset from UTC, plus a set of daylight-saving rules. The instant stays the same; the wall clock moves. Your shell already knows the rules, so the fastest way to see the gap is to ask it about the same instant — date -r N takes epoch seconds on macOS and BSD:
TZ=America/New_York date -r 1772884800TZ=Asia/Tokyo date -r 1772884800TZ=Europe/Berlin date -r 1772884800Sat Mar 7 07:00:00 EST 2026Sat Mar 7 21:00:00 JST 2026Sat Mar 7 13:00:00 CET 2026One instant — 1772884800 is 2026-03-07 12:00 UTC — three wall clocks. New York is 5 hours behind UTC, Tokyo 9 ahead, Berlin 1 ahead. Nothing in std can produce any of those three lines, and that is exactly the division of labor the design pushes you toward: instants and arithmetic belong to the standard library; turning an instant into a wall clock belongs to the display layer, which is where the timezone database lives.
The DST trap
Section titled “The DST trap”Adding 86,400 seconds is the natural way to write “this time tomorrow”, and on most days it is even right. The days it is wrong are the DST transition days. In the United States, clocks jump forward at 2 a.m. local time on the second Sunday of March — in 2026, that is March 8, and New York moves from EST (UTC−5) to EDT (UTC−4):
use std::time::{Duration, UNIX_EPOCH};
fn main() { // 1772884800 = 2026-03-07 12:00 UTC = 7:00 a.m. in New York (EST) // 1772967600 = 2026-03-08 11:00 UTC = 7:00 a.m. in New York (EDT) let sat_morning = UNIX_EPOCH + Duration::from_secs(1_772_884_800); let next_instant = sat_morning + Duration::from_secs(86_400); let next_wall = UNIX_EPOCH + Duration::from_secs(1_772_967_600);
println!("86,400 s later: {}", next_instant.duration_since(UNIX_EPOCH).unwrap().as_secs()); println!("same 7 am again: {}", next_wall.duration_since(UNIX_EPOCH).unwrap().as_secs()); println!("difference: {} s", next_instant.duration_since(next_wall).unwrap().as_secs());}86,400 s later: 1772971200same 7 am again: 1772967600difference: 3600 sThe shell confirms what those numbers mean in New York:
TZ=America/New_York date -r 1772884800TZ=America/New_York date -r 1772971200TZ=America/New_York date -r 1772967600Sat Mar 7 07:00:00 EST 2026Sun Mar 8 08:00:00 EDT 2026Sun Mar 8 07:00:00 EDT 2026Read the three lines together. sat_morning + 86_400 s is the same instant one day later, and one day later that instant reads 08:00 — New York sprang forward overnight, so the wall clock slid an hour. The instant that does read 07:00 the next morning is only 82,800 seconds away, and the 3,600-second gap between the two is exactly the hour the clock skipped. std does the instant side of this perfectly — arithmetic on SystemTime cannot be corrupted by DST, because DST never touches it. What it cannot do is the wall-clock side: deciding “when is 7 a.m. in New York” needs the rules, and that is a date library’s job.
Two footnotes before you pick a habit: Europe switches on a different schedule (last Sunday of March — 2026-03-29), so “the DST day” is not one global date; and some zones have no DST at all — China stays at UTC+8 all year, which is why 86,400-second arithmetic never breaks there.
The pattern: UTC everywhere, convert at the edge
Section titled “The pattern: UTC everywhere, convert at the edge”The design of std is also the convention worth copying:
- Store instants. A
u64of epoch seconds (orSystemTimeitself) is timezone-free by construction — the value cannot be misread by a machine in another zone. - Do arithmetic in UTC. Expiry checks, ordering, durations: all instant math, all DST-proof.
- Convert only at the edge. The moment a time is displayed, entered by a user, or scheduled against a wall clock, a timezone enters the picture — and at that point you need the rules, which
stddoes not have.chrono’s local time and formatting picks up there, reading the same zone database the shell’sdatejust did.
- The previous article — the conversions between those UTC timestamps and the moments they represent.
- Working with dates and time — the rest of the chapter.