cat posts/2024-10-08-timezones-are-weirder-than-you-think.md
Timezones are weirder than you think
At 2 a.m. on Sunday, the clocks on Lord Howe Island, a small island in the Tasman Sea that’s part of New South Wales, went forward to 2:30. Not an hour, half an hour. Lord Howe is on UTC+10:30 in the winter and UTC+11 in the summer, so for half the year it’s thirty minutes ahead of Sydney and for the other half it’s the same as Sydney, which moved its own clocks forward an hour the same night. A few hundred people live there. Its zone in the tz database is Australia/Lord_Howe, and if your code assumes that a daylight saving change is always an hour, that’s the zone that will prove you wrong.
I grew up with exactly one time zone. India is on UTC+5:30 everywhere, all year, with no daylight saving, and I don’t think I’d ever really thought about time zones as a programming problem until I moved to the US in October 2007. That turned out to be the first year of the new US rules, where DST started three weeks earlier, in March, and ended a week later, in November, than it used to, and one of the first things I remember hearing about at work was devices that had changed their clocks on the old date. I still find the whole thing faintly absurd.
Last month I mentioned a timezone bug I’d used to try out OpenAI’s o1-preview. This is the write-up, and a tour of what you need to know to not write it.
The bug
Customers can schedule a recurring report: “every day at 9:00 a.m.”. When they saved the schedule, our code converted 9:00 a.m. in the customer’s time zone to UTC, stored that, and after each run computed the next one by adding 24 hours.
That’s correct until the clocks change. For a customer in New York who set it up in the summer, 9:00 a.m. EDT is 13:00 UTC. After the clocks go back in November, 13:00 UTC is 8:00 a.m. EST, so the report starts arriving an hour early and keeps doing it all winter. In March it fixes itself. We’d been doing this for years, and the reason nobody had complained, as far as we can tell, is that most people don’t notice an emailed report arriving an hour early, and the ones who noticed assumed it was them. The person who finally reported it had a report that was meant to land after an overnight batch job of theirs finished at 8:30, and over the winter it started arriving before the batch, with the previous day’s numbers. Her ticket was closed as “can’t reproduce” in the spring, because by the time anyone looked, the clocks had changed and the report was back at 9:00. A support engineer remembered it in August and reopened it, which is the only reason we found it before this November.
The fix is to store what the customer actually asked for, “09:00 in America/New_York”, and compute each next run from that, in local time, converting to UTC only at the end. To get to why that works, you need to separate three things that people (and APIs) blur together.
Instants, local times and zones
An instant is a point on the global timeline. 13:00 UTC on 4 November 2024 is one instant, and it’s the same instant everywhere. Unix timestamps are instants. So is a Postgres timestamptz, despite the name; it stores the instant in UTC and converts to the session’s time zone for display, and it does not store a time zone.
A local time is what a clock on a wall shows: 9:00 a.m. on 4 November. On its own it doesn’t pick out a point on the timeline: 9:00 a.m. is a different instant in every zone, and, as the next section shows, it can be zero instants or two in the same zone.
An offset is the difference between local time and UTC at a particular moment, like −05:00. A zone is something else entirely: a named set of rules, with a history, for what the offset is at any instant. America/New_York is −05:00 for part of the year, −04:00 for the rest, with rules about when it switches, and a record of every time those rules have changed since 1883. “EST” is not a zone, it’s an abbreviation for one of New York’s offsets, and abbreviations are ambiguous (IST is India, Ireland and Israel). If you store “−05:00” thinking you’ve stored a time zone, you’ve stored one moment’s worth of it.
So for our reports, “13:00 UTC” was an instant, which was correct for exactly one day. What the customer meant was a local time plus a zone, and the instant has to be recomputed for each run, because the offset changes.
Gaps and overlaps
Daylight saving transitions make local time non-continuous, in two different ways.
In March, at 2:00 a.m., New York jumps to 3:00. Local times from 2:00 to 2:59 that morning don’t exist. If a customer schedules something for 2:30 a.m. daily, your code has to decide what to do on that day: run at 3:00? Run at 3:30? Skip it? There’s no right answer, but there has to be an answer, chosen on purpose. Most libraries pick one for you, and they don’t all pick the same one.
In November, at 2:00 a.m. EDT, the clocks go back to 1:00 EST. Every local time from 1:00 to 1:59 happens twice, an hour apart. “1:30 a.m. on 3 November” in New York is two instants. If you’re parsing a local timestamp from a log, or a customer typed it into a form, you can’t know which one they meant without more information, an offset or a flag. If you’re running a job at 1:30 a.m. daily, you need to make sure it doesn’t run twice.
None of this is exotic, it’s every DST zone, twice a year. For our reports at 9 a.m., neither gap nor overlap ever comes up, which is why storing local time and zone fixed it completely. For the handful of customers with schedules between 1 and 3 a.m., we picked a rule (in a gap, run at the first valid time after; in an overlap, run once, at the first occurrence) and wrote it down.
The tz database
The rules for every zone live in the tz database, sometimes called the Olson database after Arthur David Olson, who started it in the 1980s. It’s been maintained for many years by Paul Eggert, and IANA has hosted it since 2011. It’s a set of plain text files, and if you’ve never read them, do, because the comments are wonderful: historical notes, citations to newspaper archives and government decrees, and arguments about what some town in Indiana did in 1957.
The important thing about it for programmers is that it changes, several times a year, because governments change their rules, often with very little notice. A few recent examples:
- Kazakhstan moved its eastern half from UTC+6 to UTC+5 on 1 March this year. The tz release with the change, 2024a, came out on 1 February, four weeks ahead.
- Mexico abolished daylight saving in most of the country in late 2022, with the law passed only weeks before the fall change.
- Brazil abolished it in 2019. Samoa skipped 30 December 2011 entirely, jumping from one side of the date line to the other.
- Morocco is on UTC+1 most of the year and drops back an hour during Ramadan, which moves every year.
The latest release, 2024b, came out last month and is mostly corrections to history, with old Mexican and Portuguese transitions revised from a close reading of decrees going back a hundred years.
This is why you can’t store future local events as UTC instants. If a customer in Almaty saved a meeting for 9:00 a.m. this past July, and you converted it to UTC when they saved it last year, under the old rules, the government has since moved your customer’s meeting by an hour, and your database doesn’t know. Store the local time and the zone, and convert when you need the instant, using rules that are current at the time you do the conversion. Past events are the opposite: they happened at an instant, so store the instant.
It also means your tz data is a dependency that needs updating. On Linux it usually comes from the system’s tzdata package, which in a container image means it’s as current as the image. Java ships its own copy. .NET on Windows uses Windows’ own time zone data, with its own names (the zone called “Eastern Standard Time” there also covers daylight time, which I think is my favorite naming decision in all of Windows), and on Linux it uses the IANA files. Since .NET 6 you can look up zones by either kind of name on either platform, which has made our code a lot less full of if (OperatingSystem.IsWindows()).
Fixing it
Mostly what’s above. The schedule table now has a local time and an IANA zone name instead of a UTC timestamp. Computing the next run happens in local time, using TimeZoneInfo in the .NET service that runs the jobs and ActiveSupport::TimeZone on the Rails side where schedules are created, then converts to an instant. We migrated existing schedules by converting the stored UTC time back to local time in each account’s zone, using the date each schedule was created, so a schedule made in July turned back into “9:00 a.m.” and not “8:00 a.m.”.
And the tests now include, for every function that does date arithmetic, a run across New York’s March and November transitions, one across a Southern Hemisphere change (Sydney, which goes the other way), one in a zone with a :30 offset (India) and one across Lord Howe’s half-hour shift. Lord Howe caught a second bug, in a “time until next run” display that assumed transitions were whole hours.
The US clocks go back on 3 November. I’ve put a reminder in my calendar to check the report timings that Monday morning, which feels like the least I can do for the customer with the 8:30 batch.