Developer Tools

DST Transition Edge Cases: The Lost Hour and the Repeated Hour

Twice a year the local clock lies. Spring forward skips an hour (2:30 AM never happens) and fall back repeats one (1:30 AM happens twice). Here is exactly what breaks — nonexistent times, ambiguous times, jobs that fire twice or never — and the store-UTC fixes that make it stop.

By Inventive HQ Team

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.

Spring-forward gap and fall-back overlap on the local clock Two timelines. On spring forward the clock jumps from 1:59 to 3:00, leaving the 2:00 to 3:00 hour nonexistent. On fall back the clock reaches 2:00, resets to 1:00, and the 1:00 to 2:00 hour is lived through twice. What the local clock actually does twice a year

Spring forward — the lost hour clock jumps 1:59 → 3:00 · the 2:00–3:00 hour never happens

1:00 1:59 3:00 4:00 2:00–2:59 does not exist

Fall back — the repeated hour clock reaches 2:00, resets to 1:00 · the 1:00–2:00 hour is lived twice

12:59 2:00 1:00–1:59 happens twice (fold 0, then fold 1)

The instant is unbroken in UTC. Store UTC + zone name and neither gap nor overlap can bite you.

Advertisement

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 caseWhat breaksThe 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 gapA 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 timeA 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 zoneUTC-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.

Loading interactive tool...

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:

  1. 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.
  2. 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.
  3. 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.

Frequently Asked Questions

What happens to the clock during a DST transition?

Twice a year the local wall clock jumps. On the spring-forward date the clock skips an hour: in US zones it goes straight from 1:59:59 AM to 3:00:00 AM, so every local time from 2:00 to 2:59 that day never exists. On the fall-back date the clock repeats an hour: it reaches 1:59:59 AM, then resets to 1:00:00 AM, so every local time from 1:00 to 1:59 happens twice. The underlying physical time never skips or repeats — only the local label does, which is exactly why storing UTC avoids the whole problem.

What is a nonexistent (invalid) local time?

A nonexistent local time is a wall-clock time that is skipped during spring forward — for example 2:30 AM on the transition date in a US zone, because the clock jumps from 1:59 to 3:00. If your code tries to construct that timestamp, different libraries do different things: some raise an error, some silently shift it forward to 3:30, and naive datetime objects accept it without complaint and produce a wrong instant. Always decide explicitly: reject it, or shift it to the next valid time.

What is an ambiguous local time and how do I resolve it?

An ambiguous local time is one that occurs twice during fall back — for example 1:30 AM, which happens once before the clock rolls back and once after. "1:30 AM" alone does not identify a single instant. Python resolves this with the fold attribute introduced by PEP 495: fold=0 means the first (earlier) occurrence, fold=1 means the second (later) occurrence. Other ecosystems use an explicit UTC offset (for example -04:00 versus -05:00) to pin down which side of the fold you mean.

What is the fold attribute in Python?

fold is an attribute on datetime objects added by PEP 495 (Python 3.6+) to disambiguate the repeated hour during fall back. Its value is 0 or 1: 0 selects the earlier of the two identical local readings, 1 selects the later. It only matters for ambiguous times inside the fall-back fold; for every other timestamp fold is 0 and has no effect. Combined with the zoneinfo module, fold lets you convert an ambiguous local time to the exact UTC instant you intended.

Why does my scheduled job run twice or not at all on DST days?

Because the local clock skips or repeats an hour. A job scheduled for 2:30 AM local never fires on the spring-forward date — that time does not exist. A job scheduled for 1:30 AM local can fire twice on the fall-back date, once in each pass through the hour. The fix is to schedule in UTC where every hour occurs exactly once, avoid scheduling anything between roughly 1:00 and 3:00 AM local, and make jobs idempotent so a double run is harmless.

How should I store timestamps to avoid DST bugs?

Store the absolute instant in UTC (or a Unix timestamp), and store the user's IANA time zone name — for example America/New_York — as a separate field. Never store a bare local wall-clock time as your source of truth, because after a transition it can be ambiguous or invalid and you cannot reconstruct the real instant. Convert UTC to local time only at the moment you display it, using an up-to-date time-zone database.

Is a day always 24 hours long?

No. On the spring-forward date the local day is 23 hours long because an hour is skipped; on the fall-back date it is 25 hours long because an hour repeats. This breaks any code that assumes "add 24 hours" equals "same wall-clock time tomorrow." Do duration and interval arithmetic on absolute UTC instants, then convert to local time for display — never add a fixed number of seconds to a local timestamp and expect the wall clock to match.

Why shouldn't I use a fixed UTC offset instead of a time zone name?

Because a fixed offset like UTC-5 does not know when DST starts and ends. America/New_York is UTC-5 in winter and UTC-4 in summer, and the switch dates change by law over the years. Only a named IANA zone backed by the tz database applies the correct offset for a given date. Store the zone name, not a number, so past and future conversions stay correct even as the rules change.

daylight saving timetimezonesdatetime