skip to content

Why does a datetime for 02:30 on a spring-forward date not raise in Python?

level: middleimportance: should knowfreq 32%

answer

  1. One local hour is deleted each spring
  2. The constructor never asks the zone
  3. Conversion, not construction, is where it shows
  4. Fold picks the pre- or post-transition offset
  5. Round-trip through UTC to detect it

basics

~10 s

Nothing validates a local time against the zone's transitions. The constructor only range-checks the fields and stores the tzinfo, so an hour the clock skipped builds fine and misbehaves later, when something converts it.

solid answer

~40 s

On the March transition in `America/New_York` the clock jumps from 01:59:59 to 03:00:00, so no wall time in the 02:00 hour exists that day. Python still constructs it: a `datetime` is fields plus a rule for interpreting them, and the zone is consulted lazily by `utcoffset()`, `astimezone()` and `timestamp()`. PEP 495 defines the interpretation rather than rejecting it — for an imaginary time, `fold=0` uses the offset in force *before* the transition and `fold=1` the offset *after* it. With the default `fold=0`, 02:30 converts to 07:30 UTC, which renders back in the same zone as 03:30, so the value silently slides forward. Detect it with a round trip: `dt != dt.astimezone(timezone.utc).astimezone(dt.tzinfo)`.

code

python · 9 lines
python
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

ny = ZoneInfo("America/New_York")
gap = datetime(2025, 3, 9, 2, 30, tzinfo=ny)

print(gap.utcoffset(), gap.astimezone(timezone.utc))
print(gap.astimezone(timezone.utc).astimezone(ny))
print(gap.replace(fold=1).astimezone(timezone.utc).astimezone(ny))

go deeper

for a junior

Know that clocks skip an hour each spring, so some local times simply never occur that day, and that code which builds times from user input has to cope with it.

for a middle

Explain that construction only range-checks fields and the zone is consulted lazily on conversion, and show the round-trip test that reveals a wall time which does not exist.

for a senior

Show the policy decision — reject, shift forward, or skip the occurrence — chosen per use case and logged, plus tests pinned to the actual transition dates so the path is exercised in CI.

for a principal

Set the contract for where local wall times are allowed to enter the system at all, and require that any resolution of a gap or an ambiguity is recorded as data rather than left implicit in a default.

## The hour that never happens Daylight saving time cuts both ways. Where the autumn transition repeats an hour, the spring transition deletes one: in `America/New_York` on the second Sunday of March the clock goes straight from 01:59:59 standard time to 03:00:00 daylight time. Every wall time from 02:00:00 to 02:59:59 is *imaginary* on that date — no clock in that zone ever displayed it, and no instant maps onto it. Python nevertheless builds it without complaint. `datetime(2025, 3, 9, 2, 30, tzinfo=ZoneInfo("America/New_York"))` is an ordinary object. The constructor validates field ranges — month 1–12, hour 0–23 — and stores a reference to the `tzinfo`; it never asks the zone whether the combination is realisable, and it cannot cheaply do so, because the whole design of PEP 495 is that a `datetime` is a set of fields plus a rule for interpreting them, not a resolved instant. The zone is consulted lazily, the first time something calls `utcoffset()`, `dst()`, `tzname()`, `astimezone()` or `timestamp()`. ### What the interpretation produces PEP 495 gives the gap a definition rather than an error. For an imaginary wall time, `fold=0` means "interpret with the offset in force **before** the transition" and `fold=1` means "interpret with the offset in force **after** it". Both are total functions, so conversion always yields *some* instant — just not one that renders back as the wall time you typed. Concretely, with `fold=0` (the default) 02:30 is read as 02:30 `-05:00`, which is 07:30 UTC, which renders back in the same zone as **03:30 `-04:00`**: the value jumped forward past the gap. With `fold=1` it is read as 02:30 `-04:00`, which is 06:30 UTC, rendering back as **01:30 `-05:00`**: the value jumped backwards, to before the transition. The default therefore has a convenient property for schedulers — an imaginary start time slides forward by one offset step rather than backwards — but it is a silent slide, and if the code prints its own input back the shift never appears. ### Detecting it The canonical test is a round trip through UTC: ```python def is_imaginary(dt): return dt != dt.astimezone(timezone.utc).astimezone(dt.tzinfo) ``` The comparison here is an intra-zone one, so it compares wall-clock fields; if converting out to an absolute instant and back changes the wall time, the original wall time does not exist. A companion check, `dt.replace(fold=not dt.fold).utcoffset() != dt.utcoffset()`, is true for both imaginary and ambiguous times — the offset only depends on fold near a transition — so the pair together classify a value: offset-differs and round-trips cleanly means *ambiguous*; offset-differs and does not round-trip means *imaginary*; neither means the wall time is unique and unremarkable. ### Where imaginary times come from They almost never come from an instant; they come from *construction*. A user types a local time into a form. A config file names a nightly wall-clock hour. `datetime.strptime` parses a local string, returning a naive value with `fold=0`, and someone attaches a zone with `replace(tzinfo=...)`. A recurrence rule adds `timedelta(days=1)` to yesterday's local start. All four produce field combinations that were never checked against the zone's transition table. The fix is a policy applied at that construction point, not a global setting. Three defensible policies exist and the right one is domain-specific: **reject** (raise, and make the user pick another time — right for a booking that a human will keep); **shift forward** to the instant the clock jumped to, which is what `fold=0` plus `astimezone()` already gives you (right for a batch job that just needs to run once); or **skip** the occurrence entirely (right for a recurrence whose whole point is the wall-clock hour, where running an hour off is worse than not running). Whatever you choose, log that the resolution happened. The reason imaginary times are a classic interview question is that every one of the three is silent by default, and the wrong one shows up months later as a job that ran at the wrong hour on exactly one day of the year.

  • With the default fold, which way does an imaginary local time move when you convert it?
    Forward. `fold=0` interprets the wall time with the offset in force before the transition, so 02:30 in a zone that jumps 02:00 to 03:00 becomes the instant that renders as 03:30 local. `fold=1` uses the post-transition offset and moves it backwards, to 01:30. Forward is usually what a batch job wants, but it happens silently, so flag the case rather than relying on the default being benign.
  • How do you tell an imaginary local time apart from an ambiguous one?
    Two predicates. Flipping the fold and comparing offsets is true for both cases, because the offset only depends on fold near a transition. A round trip out to UTC and back distinguishes them: an ambiguous wall time survives it unchanged, an imaginary one comes back as a different wall time. Offset-differs plus clean round trip means ambiguous; offset-differs plus a changed value means imaginary.
  • Where do imaginary local times usually come from in a real codebase?
    From construction, never from an instant. A user types a local time, a config file names a nightly wall-clock hour, `strptime` parses a local string into a naive value that someone attaches a zone to, or a recurrence adds a day to yesterday's local start. All four produce field combinations nobody checked against the zone's transition table, which is why the validation belongs at that edge.

It is like writing an address on a street where the numbers jump from 1 to 3: the envelope is accepted and posted, and only the courier discovers there is no such house and delivers next door.

saying these in an interview costs you the question

  • Expects the datetime constructor to reject a skipped hour
  • Thinks conversion of an imaginary time raises an exception
  • Believes fold only matters for the autumn transition
  • Says the value is silently rounded to the nearest valid minute
  • Assumes a naive parsed string is safe from the gap

context