DST transitions, gaps and folds

datetime · Advanced · 12 min

Schedulers and billing code break twice a year if they ignore DST transitions.

When clocks spring forward, a range of local times doesn't exist (a gap): in New York on 2025-03-09, 2:00 to 2:59 never happens. When clocks fall back, an hour happens twice (a fold): 1:30 on 2025-11-02 occurs once in EDT and again in EST.

Python represents the ambiguity with the fold attribute (PEP 495): fold=0 means the first occurrence, fold=1 the second. ZoneInfo uses it to pick the offset. For a time in a gap, ZoneInfo applies the offset from before the transition, and converting to UTC and back moves it to a valid time (2:30 becomes 3:30 EDT).

Arithmetic semantics matter:

  • Adding a timedelta to an aware datetime is wall-clock arithmetic: 9:00 + 1 day is 9:00 the next day, even if that day was only 23 hours long. That's usually what you want for "same time tomorrow".
  • For elapsed time, convert to UTC first, add, and convert back. Subtracting two datetimes in the same zone also ignores DST; subtract their UTC versions to get real elapsed time.

For recurring events, store the local time and zone name ("9:00 Europe/Berlin"), not a UTC instant, so the event stays at 9:00 after DST changes. For log records and durations, store UTC.

Example

from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo

ny = ZoneInfo("America/New_York")
before = datetime(2025, 3, 8, 12, 0, tzinfo=ny)     # day before spring forward
same_time = before + timedelta(days=1)              # wall-clock arithmetic
print(same_time)                     # 2025-03-09 12:00:00-04:00
print(same_time - before)            # 1 day, 0:00:00
print(same_time.astimezone(UTC) - before.astimezone(UTC))   # 23:00:00

gap = datetime(2025, 3, 9, 2, 30, tzinfo=ny)        # doesn't exist
print(gap.astimezone(UTC).astimezone(ny))           # 2025-03-09 03:30:00-04:00

first = datetime(2025, 11, 2, 1, 30, tzinfo=ny)     # happens twice
second = first.replace(fold=1)
print(first.tzname(), second.tzname())              # EDT EST
print(second.astimezone(UTC) - first.astimezone(UTC))   # 1:00:00

Remember

  • Spring-forward creates nonexistent times; fall-back creates ambiguous ones
  • fold=0/1 selects the first or second occurrence of an ambiguous time
  • Same-zone arithmetic is wall-clock; convert to UTC for elapsed time

Watch out for

  • Computing durations by subtracting two local datetimes across a DST change and being an hour off.

Check yourself

  1. A job runs at 2025-03-08 12:00 America/New_York; you schedule the next run with + timedelta(hours=24). When does it run in local time?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/library/zoneinfo.html

DST transitions, gaps and folds · datetime · Topolearn