Daylight Saving Time Bugs and How to Avoid Them
· bugstimezones
The missing hour and the repeated hour cause skipped jobs, double charges and impossible timestamps. All of it is preventable by scheduling in UTC.
Daylight saving time creates two discontinuities in local wall-clock time each year. Both break naive assumptions, and both are entirely predictable.
The missing hour
When clocks spring forward, local time jumps straight from 01:59:59 to 03:00:00. In America/New_York on the second Sunday of March, 02:30 does not exist.
Consequences:
- A job scheduled at 02:30 local does not run that day.
- Parsing
"2024-03-10 02:30:00"in that zone is a request to convert a time that never occurred. Libraries disagree on what to do: Python’szoneinfoshifts it, Java throws or adjusts depending on the resolver, JavaScript silently produces something. - A user entering a birth time or appointment inside the gap has entered an impossible value.
The repeated hour
When clocks fall back, local time replays 01:00:00 to 01:59:59. In America/New_York on the first Sunday of November, 01:30 happens twice — once at UTC−04:00 and again an hour later at UTC−05:00.
Consequences:
- A job scheduled at 01:30 local runs twice. This is how duplicate invoices, double notifications and double charges happen.
"2024-11-03 01:30:00"is genuinely ambiguous: two different instants match.- Local timestamps stop being sortable. An event at 01:45 EDT precedes one at 01:15 EST, despite the larger clock reading.
Real failure patterns
Cron running twice or not at all. Cron works in local time. A 30 2 * * * entry silently skips a day in spring and doubles in autumn. See cron and time zones.
Duration arithmetic across a boundary. “Add 24 hours” and “add one day” differ on a transition date — one gives 23 or 25 hours of wall-clock movement, the other gives the same clock time tomorrow. Both are correct answers to different questions; pick deliberately.
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
d = datetime(2024, 3, 9, 12, 0, tzinfo=ZoneInfo("America/New_York"))
d + timedelta(days=1) # 2024-03-10 12:00 EDT — 23 real hours later
Recurring meetings shifting. A weekly 09:00 London meeting is 04:00 in New York for most of the year, but for the three weeks in March when the UK and US have already diverged, it is 05:00. Cross-zone recurring events must be stored as a local time plus a zone, never as a fixed offset.
Reports missing or duplicating an hour. A daily aggregation querying BETWEEN midnight AND midnight + 24 hours in local time covers 23 or 25 hours on transition days.
The rules that prevent all of it
1. Schedule in UTC. If a job must run every 24 hours, express it in UTC. There is no missing or repeated hour in UTC, ever. Accept that the local time drifts by an hour twice a year — that is almost always preferable to skipping or double-running.
2. Store instants as UTC or epoch. Local wall-clock time is a display concern. See storing timestamps in databases.
3. Store future local events as local time plus IANA zone. “9 a.m. on 3 November in New York” must be stored as ("2024-11-03T09:00", "America/New_York"), not as a pre-computed UTC instant. If the zone’s rules change before the date — and rules change several times a year somewhere in the world — the pre-computed instant becomes wrong while the local-plus-zone pair stays correct.
4. Handle ambiguity explicitly. Good libraries let you say what you mean:
from datetime import datetime
from zoneinfo import ZoneInfo
# fold=0 -> the first (EDT) occurrence; fold=1 -> the second (EST)
dt = datetime(2024, 11, 3, 1, 30, tzinfo=ZoneInfo("America/New_York"), fold=1)
// chrono returns a LocalResult that forces you to handle all three cases
match tz.with_ymd_and_hms(2024, 11, 3, 1, 30, 0) {
LocalResult::Single(t) => { /* unambiguous */ }
LocalResult::Ambiguous(a, b) => { /* fell back — pick one */ }
LocalResult::None => { /* sprang forward — does not exist */ }
}
5. Never hard-code an offset. UTC-5 is Eastern time for part of the year only. Use America/New_York. See time zones vs UTC offsets.
6. Keep tzdata current. Zone rules change by political decision, often with weeks of notice. Your OS, language runtime, database and any bundled tzdata copy all need updating. A container built two years ago has two-year-old rules.
Testing
Add these to your test suite as fixed dates — they are the cases that break:
- A timestamp inside the spring-forward gap
- A timestamp inside the autumn repeat, both occurrences
- Adding one day across each transition
- A zone that does not observe DST at all (
Asia/Kolkata,UTC) - A southern-hemisphere zone, where the transitions are reversed (
Australia/Sydney) Asia/Kathmandu, at UTC+05:45, for anything assuming whole-hour offsets
The time zone pages list this year’s transition dates for every zone, which is a convenient source of fixture values.