During a daylight saving time transition the local clock either skips an hour or repeats one, and both break naive datetime code. On the spring-forward date the wall clock jumps from 1:59:59 AM straight to 3:00:00 AM — so every local time between 2:00 and 2:59 AM never exists that day (a nonexistent or invalid local time). On the fall-back date the clock reaches 1:59:59 AM and resets to 1:00:00 AM — so every local time between 1:00 and 1:59 AM happens twice (an ambiguous local time). The physical timeline never skips or repeats; only the human label on it does. The reliable fix across every language is the same: store the absolute instant in UTC plus the IANA zone name, and convert to local time only for display.
That's the summary an AI overview will give you. What it can't give you is the part that actually bites in production: why a job fires twice, how a "24-hour" day becomes 23 or 25 hours, which library silently corrupts the invalid hour, and the exact code to disambiguate the repeated one. Below is a timeline of both transitions, a table of every edge case with its fix, and working code.
The two transitions, on one timeline
Everything about DST bugs comes from this picture. Spring forward tears a one-hour gap out of the local timeline; fall back stitches an extra hour back in. The marker on each track shows what a clock actually does — jumping the gap in spring, doubling back in fall.
Every edge case, with the problem and the fix
Each of these is a real failure mode teams hit twice a year. The pattern in the right column is always the same idea — keep truth in UTC, treat local time as a display format.
| Edge case | What breaks | The fix |
|---|---|---|
| Nonexistent local time (spring forward) | Constructing 2:30 AM on the transition date either throws, silently shifts to 3:30, or (with naive datetimes) produces a wrong instant. | Detect it and reject, or deliberately shift forward to the first valid time. Better: never build the local time — start from UTC. |
| Ambiguous local time (fall back) | 1:30 AM occurs twice; "1:30 AM" maps to two different UTC instants and math is off by an hour. | Disambiguate with the fold attribute (PEP 495) or an explicit UTC offset. Store UTC so it's never re-ambiguated. |
| Scheduled job in the gap | A cron at 2:30 AM never runs on spring-forward day; a cron at 1:30 AM runs twice on fall-back day. | Schedule in UTC, avoid 1:00–3:00 AM local, and make jobs idempotent so a double run is harmless. |
| Duration / interval math | "Add 24 hours" lands on the wrong wall-clock time; a local day is 23 or 25 hours across a transition. | Do arithmetic on absolute UTC instants (or Unix seconds), then convert to local only for display. |
| Storing local wall time | A stored "2024-11-03 01:30 local" can't be turned back into a real instant after the fact. | Store the UTC instant and the IANA zone name (e.g. America/New_York) in separate fields. |
| Fixed UTC offset instead of zone | UTC-5 is wrong half the year; hard-coded offsets ignore DST and future rule changes. | Use a named IANA zone backed by the tz database, never a bare numeric offset. |
| Naive datetime (no tzinfo) | Has no DST knowledge, so it can't detect gap or fold and silently gives wrong results. | Always attach a real zone (zoneinfo), and keep internal values timezone-aware and in UTC. |
The ambiguous hour: disambiguate with fold
Fall back is the subtler bug because nothing errors — you just get the wrong instant by exactly one hour. In Python 3.9+ the standard-library zoneinfo module plus the PEP 495 fold attribute pins down which pass through the hour you mean. fold=0 is the first (earlier) 1:30 AM; fold=1 is the second (later) one.
from datetime import datetime
from zoneinfo import ZoneInfo
ny = ZoneInfo("America/New_York")
# 2024-11-03: clocks fall back from 2:00 AM to 1:00 AM.
first = datetime(2024, 11, 3, 1, 30, fold=0, tzinfo=ny) # earlier 1:30
second = datetime(2024, 11, 3, 1, 30, fold=1, tzinfo=ny) # later 1:30
print(first.utcoffset()) # -1 day, 20:00 -> UTC-4 (EDT, before the switch)
print(second.utcoffset()) # -1 day, 19:00 -> UTC-5 (EST, after the switch)
# They are one hour apart in real time, despite identical wall-clock labels:
print(second.astimezone(ZoneInfo("UTC")) - first.astimezone(ZoneInfo("UTC")))
# 1:00:00
Two datetimes that print the same string represent instants an hour apart. If you drop fold and store the local string, you have thrown that hour away.
The nonexistent hour: detect it, don't trust it
Spring forward is the opposite trap: the local time you asked for isn't on the clock at all. A robust check is to round-trip through UTC — if converting to UTC and back changes the wall-clock time, the input was in the gap.
from datetime import datetime
from zoneinfo import ZoneInfo
ny = ZoneInfo("America/New_York")
def is_nonexistent(dt: datetime) -> bool:
# A valid local time survives a round-trip through UTC unchanged.
utc = dt.astimezone(ZoneInfo("UTC"))
return utc.astimezone(ny).replace(tzinfo=ny) != dt.replace(tzinfo=ny)
# 2024-03-10: clocks spring forward 2:00 AM -> 3:00 AM.
gap = datetime(2024, 3, 10, 2, 30, tzinfo=ny)
print(is_nonexistent(gap)) # True — 2:30 AM never happened that day
Once you know it's invalid, make a deliberate choice: reject the input and ask the user for a real time, or shift it forward to the first valid instant (3:30 AM). What you must not do is let it slide through silently, because different libraries resolve it differently and your data becomes inconsistent.
The rule that makes all of this disappear: store UTC
Every fix in the table collapses into one habit. Keep your source of truth as an absolute instant — UTC, or a Unix timestamp — and record the user's IANA zone separately. UTC has no gaps and no folds: every instant occurs exactly once. Convert to local time only at the edge, the moment you render it for a human.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
# WRITE: capture the absolute instant, store the zone name alongside it.
event_utc = datetime.now(timezone.utc) # source of truth
event_zone = "America/New_York" # display hint, stored separately
# READ: convert to local only for display. Any transition is handled correctly.
local = event_utc.astimezone(ZoneInfo(event_zone))
print(local.isoformat())
The same discipline applies in every language: Instant + ZoneId in Java, a timestamptz column in PostgreSQL (which stores UTC internally), Temporal.Instant with a named zone in JavaScript. The mistake is universal too — storing a bare local timestamp and hoping. Compare zones and current local times with the world clock tool when you need to reason about a transition by hand.
Scheduling around the transition
Cron and other local-time schedulers inherit both bugs. A job at 30 2 * * * (2:30 AM) simply won't run on the spring-forward date, and a job at 30 1 * * * (1:30 AM) may run twice on the fall-back date. Three defenses, in order of preference:
- Schedule in UTC. Every hour exists exactly once in UTC, so there is no gap and no repeat. This is what most cloud schedulers and Kubernetes CronJobs let you do.
- Avoid the danger window. Don't schedule anything between 1:00 and 3:00 AM local. Pick 4:00 AM and the problem can't occur.
- Make jobs idempotent. If a job might run twice, ensure a second run is a harmless no-op — guard with a "last run" marker keyed on the UTC date, not the local time.
For a deeper walkthrough of cron behaviour across DST, see handling time zones and DST in cron.
The bottom line
DST edge cases aren't exotic — they're two predictable events a year, and both come from the same root: the local clock is a lossy label on an unbroken timeline. Spring forward deletes an hour (nonexistent times); fall back duplicates one (ambiguous times). Detect the gap by round-tripping through UTC, resolve the fold with fold=0/fold=1, schedule in UTC, and above all store the absolute instant plus the IANA zone name rather than a bare local time. Do that and the twice-yearly fire drill stops being your problem.