Daylight Saving Traps

Date and Time in Practice

Chapter 5 ยท Daylight Saving Traps

Chapter 4 showed that a zone's offset depends on the date. The moments when the offset changes are where most real-world date bugs live. In spring the clocks jump forward and an hour of readings never happens; in autumn they fall back and an hour of readings happens twice. This chapter shows what Python does at those moments, how to detect them, and why "tomorrow" and "24 hours from now" are different questions. Every snippet was run with Python 3.14; the comments show what was printed. In 2026 the New York changes are on 8 March and 1 November, and the London changes on 29 March and 25 October.

The Gap: A Reading That Does Not Exist

On 8 March 2026 New York clocks go from 01:59 straight to 03:00. A reading of 02:30 that day is not on any clock. Python will not stop you from writing it down:

from datetime import datetime, timedelta, timezone from zoneinfo import ZoneInfo ny = ZoneInfo("America/New_York") gap = datetime(2026, 3, 8, 2, 30, tzinfo=ny) print(gap) # 2026-03-08 02:30:00-05:00 print(gap.astimezone(timezone.utc)) # 2026-03-08 07:30:00+00:00 print(gap.astimezone(timezone.utc).astimezone(ny)) # 2026-03-08 03:30:00-04:00

No exception was raised. Python used the offset from before the jump (-05:00) and built an object whose label says 02:30, but the instant it describes is 07:30 UTC, which on a real New York clock reads 03:30. The object lies about its own wall time. A round trip through UTC is the cheap way to find out what the clock really said.

The Fold: A Reading That Happens Twice

On 1 November 2026 New York clocks go from 01:59 back to 01:00. The readings from 01:00 to 01:59 occur twice, an hour apart. Python resolves the ambiguity with the fold attribute: 0 (the default) means the first time the reading occurred, 1 the second.

first = datetime(2026, 11, 1, 1, 30, tzinfo=ny) second = first.replace(fold=1) print(first, first.tzname(), first.astimezone(timezone.utc)) # 2026-11-01 01:30:00-04:00 EDT 2026-11-01 05:30:00+00:00 print(second, second.tzname(), second.astimezone(timezone.utc)) # 2026-11-01 01:30:00-05:00 EST 2026-11-01 06:30:00+00:00

Two different instants, one hour apart, with identical wall readings. If a user types "01:30" on that night and you never ask which one they meant, you have to pick, and the default silently picks the first. Now the surprising part, which comes from a rule you may not know. When two aware datetimes share the same ZoneInfo object, Python compares and subtracts their wall readings and ignores the offsets:

print(first == second) # True print(second - first) # 0:00:00 print(first.astimezone(timezone.utc) == second.astimezone(timezone.utc)) # False print(second.astimezone(timezone.utc) - first.astimezone(timezone.utc)) # 1:00:00

Chapter 3 said that aware values compare by instant. That is true between different zones (or UTC), but inside one zone the shortcut applies, and at a fold it gives a wrong answer. The safe habit is to convert to UTC before comparing or subtracting anything that might straddle a change.

Gap (clocks forward)Fold (clocks back)
Wall readingsone hour never occursone hour occurs twice
2026, New York8 March, 02:00-02:591 November, 01:00-01:59
2026, London29 March, 01:00-01:5925 October, 01:00-01:59
Day length23 hours25 hours
What fold doespicks which offset is used for the invalid readingpicks the first or second occurrence

Detecting the Problem

A reading is ordinary when both fold values give the same offset. If they differ, the reading is special, and the order of the two offsets says which kind. In a repeated hour the first pass is on summer time, so its offset is the larger. In a gap the first (fold=0) offset is the old one, which is the smaller.

def classify(dt): first = dt.replace(fold=0).utcoffset() second = dt.replace(fold=1).utcoffset() if first == second: return "normal" return "repeated" if first > second else "gap" print(classify(datetime(2026, 3, 8, 2, 30, tzinfo=ny))) # gap print(classify(datetime(2026, 11, 1, 1, 30, tzinfo=ny))) # repeated print(classify(datetime(2026, 7, 1, 12, 0, tzinfo=ny))) # normal

A function like this lets a form reject an impossible time with a clear message, or ask the user "first or second 01:30?" instead of guessing.

Adding a Day Is Not Adding 24 Hours

Take noon on Saturday 7 March 2026 in New York, the day before the spring change. timedelta(days=1) and timedelta(hours=24) are the same length, and in an aware datetime they give the same result, because Python adds to the wall reading. That is the trap: the sum is a wall-clock sum, whatever you call the delta.

start = datetime(2026, 3, 7, 12, 0, tzinfo=ny) print(start + timedelta(days=1)) # 2026-03-08 12:00:00-04:00 print(start + timedelta(hours=24)) # 2026-03-08 12:00:00-04:00 print((start + timedelta(days=1)) - start) # 1 day, 0:00:00 utc_sum = start.astimezone(timezone.utc) + timedelta(hours=24) print(utc_sum.astimezone(ny)) # 2026-03-08 13:00:00-04:00

The first two lines both say "same clock time tomorrow", which is what most people mean by "a day later". The subtraction claims exactly one day passed, but only 23 hours really did. Doing the arithmetic in UTC gives the true moment 24 hours later, which on the New York clock is 13:00. Neither answer is wrong; they answer different questions:

You mean...Do the arithmetic in...Example
"the same time tomorrow" (a calendar day)the local zone, on the wall readinga daily reminder at 12:00
"exactly 24 hours later" (elapsed time)UTC, then convert for displaya timeout, a 24-hour token
"how long between these two"UTC, alwaysbilling, shift length
A working rule
Calendar intent (a day, a week, "same time next Monday") uses wall-clock arithmetic in the local zone, then checks the result with classify. Elapsed-time intent (a duration, a lifetime, an interval between two events) converts to UTC first. If you cannot say which one you mean, the requirement is not finished yet.

Landing in the Gap on Purpose

Wall-clock arithmetic can walk you into a gap without anyone typing a bad time. A daily job scheduled for 02:30 local time has no valid 02:30 on 8 March. Decide the policy in advance (run at 03:00, run at 03:30, skip the day) rather than letting the library choose. The check from earlier in the chapter tells you which days need the policy:

def real_wall_time(dt): """The reading a clock would actually show for dt.""" return dt.astimezone(timezone.utc).astimezone(dt.tzinfo) job = datetime(2026, 3, 8, 2, 30, tzinfo=ny) print(classify(job), real_wall_time(job)) # gap 2026-03-08 03:30:00-04:00

Here the result is the same as the "shift forward by the gap" policy that many schedulers choose. Whichever policy you pick, write it down, because the next developer will meet the same day.

Challenges

Exercise 1

In America/New_York, build 02:30 on each of 7, 8, 9 and 10 March 2026. For each, print the wall reading, its UTC instant, and the reading you get after a round trip through UTC and back, marking any line where the round trip does not return the original wall time. Predict which date is marked before you run it.

๐Ÿ“„ View solution
Exercise 2

A shift runs from 00:00 to 08:00 local time. Write hours_between(start, end) returning both the naive difference (plain subtraction) and the real elapsed hours (through UTC). Use it for shifts on 8 March and 1 November 2026 in New York, on 29 March and 25 October 2026 in London, and on 15 July 2026 in New York. Predict which rows will differ before you run it.

๐Ÿ“„ View solution
Exercise 3

Write classify(dt) returning "normal", "gap" or "repeated" using the two fold values, and test it with 02:30 and 01:30 on the New York change days (8 March, 1 November), plus 02:30 on 1 November, plus 01:30 on the two London change days (29 March, 25 October) and noon on 4 July in London. Then, for the repeated New York 01:30, print the abbreviation and UTC time for fold=0 and fold=1.

๐Ÿ“„ View solution

Quick Reference

  • Gap: a wall reading that never occurs; Python builds it anyway with the old offset
  • Fold: a wall reading that occurs twice; fold=0 is the first, fold=1 the second
  • Detect: compare utcoffset() for fold=0 and fold=1; equal means normal
  • Real reading: dt.astimezone(timezone.utc).astimezone(dt.tzinfo)
  • Same zone object: comparison and subtraction ignore offsets, which is wrong across a fold; convert to UTC first
  • Calendar intent ("tomorrow at noon"): wall-clock arithmetic in the local zone
  • Elapsed intent ("24 hours later", durations): arithmetic in UTC
  • Day length on change days: 23 or 25 hours
Coming next
Date and Time in Practice 6 moves from clocks to calendars: adding months when the target day does not exist, leap years, the calendar module and counting business days.