Slate Setter.

engineering

The Two Days a Year Your Calendar Is Wrong

Daylight saving removes an hour and repeats one. Software that stores instants instead of local times is wrong on those days, and silent about it.

Twice a year, in jurisdictions that observe it, local time does something a clock cannot represent. One day has an hour that does not exist. Another has an hour that happens twice.

Calendars that store appointments as absolute instants are wrong on those two days, in both directions, and they are wrong quietly.

The two failures

Spring forward. Local clocks jump from 1:59am to 3:00am. There is no 2:30am. An appointment stored as "2:30am local" on that date refers to nothing. Software either skips it, moves it to 3:30am, or throws, depending on which library and which code path, and the three behaviours are all defensible and all different.

That exact time is rare for appointments and common for scheduled jobs: the nightly run at 2:30am is the one that either does not fire or fires twice a year.

Fall back. Clocks go from 1:59am back to 1:00am. There are two 1:30am moments. An appointment at 1:30am local is ambiguous, and a recurring job at that hour runs twice.

The rarer and more expensive version of this does not involve the small hours at all. A recurring weekly appointment at 9am, stored as the UTC instant that was 9am when it was created, is 8am or 10am for half the year. Nobody notices in week one. Everybody notices on the morning the crew turns up an hour early.

Store the intent, not the instant

The rule is short: for anything recurring or human-scheduled, store the local wall-clock time and the time zone identifier. Resolve to an instant at the point of use, never before.

"Tuesdays at 9am, America/Denver" survives a daylight saving transition because it says what was meant. The equivalent UTC timestamp does not, because the information about what was intended has already been discarded.

The zone identifier has to be an IANA identifier, from the tz database: "America/Denver", not "MST" and not an offset. Abbreviations are ambiguous across regions and offsets change with the rules. The database is updated several times a year, which is itself the argument for using it rather than hardcoding anything: when a jurisdiction changes its rules, you want a data update, not a release.

The exception is a timestamp recording something that already happened. A log entry, a payment, an audit record: those are instants and belong in UTC, because they are facts about a moment rather than intentions about a future one.

What the standards say

RFC 5545, which defines iCalendar, gets this right and is worth reading if you are implementing anything calendar-shaped. It distinguishes a floating local time, a UTC time, and a local time with a time zone reference, and it carries recurrence rules that expand against local time rather than against elapsed seconds. That distinction is exactly the one above, standardised thirty years ago.

More recently, RFC 9557 extends the familiar timestamp format to carry a time zone identifier alongside the offset, which is the piece that was always missing: an offset tells you what the clock read, and the identifier tells you which rules to apply next time.

If your system exports calendar files at all, matching iCalendar's model internally saves a translation layer that is otherwise written twice and subtly differently.

Where the bugs actually show up

Not in the storage. In the edges around it.

Reminders. "Send 24 hours before" resolved as an elapsed duration is 23 or 25 hours of local time across a transition. Usually harmless, occasionally the difference between the evening before and two evenings before.

Availability windows. Business hours of 9 to 5 are local, so the UTC window moves twice a year. A booking page that hardcodes the UTC equivalent silently opens at 8 or closes at 4 for half the year.

Reporting. "Bookings today" needs a definition of today, and on transition days one local day is 23 or 25 hours long. Daily totals that use a fixed 24-hour window are wrong on those two days, which is tolerable, and wrong in a way that is never explained, which is how somebody ends up debugging a revenue report in April.

Travel. A customer who books while away, in a browser reporting a different zone, should get their appointment in the business's zone with the zone named. Showing them a time in their current local zone is defensible, and showing a time with no zone at all is the thing that produces the missed appointment.

The test

Pick the next transition date for your main market. Create a recurring weekly appointment that crosses it, a scheduled job at 2:30am on it, and a one-off appointment at 1:30am on the autumn date.

Then look at what the system does. Not what the documentation says it does.

Those three cases take ten minutes to set up and they find essentially every date bug a scheduling system can have. The alternative is finding them on a Sunday in March, with a crew, at a customer's house, an hour early.

Sources

More on this