Two timezone-aware timestamps can subtract to a surprising duration when they share a regional timezone object. If you want elapsed seconds, convert both endpoints to UTC before subtracting. First decide what the number should mean: time passed between two instants, or the difference between the clock labels on a local calendar.
A spring transition makes the distinction visible. The clock can jump from 01:59:59 to 03:00:00 while only one second passes. A local clock-label subtraction and an elapsed-time calculation answer different questions. Changing the timezone label with replace() is not a conversion and can make the answer worse.
Use a helper with an explicit input contract
The helper below accepts already interpreted, aware datetimes. It rejects naive values instead of guessing the machine's local timezone. Its arguments are ordered start, end; reversing them intentionally produces a negative duration. The fixture uses New York's regional rules and requires an installed IANA timezone database.
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
def elapsed(start, end):
for value in (start, end):
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError('Both endpoints must be aware')
return (end.astimezone(timezone.utc)
- start.astimezone(timezone.utc))
ny = ZoneInfo('America/New_York')
cases = [
('spring', datetime(2017, 3, 12, 1, 59, 59, tzinfo=ny),
datetime(2017, 3, 12, 3, 0, 0, tzinfo=ny), 3601, 1),
('autumn', datetime(2026, 11, 1, 0, 30, tzinfo=ny),
datetime(2026, 11, 1, 2, 30, tzinfo=ny), 7200, 10800),
('fold', datetime(2026, 11, 1, 1, 30, tzinfo=ny, fold=0),
datetime(2026, 11, 1, 1, 30, tzinfo=ny, fold=1), 0, 3600),
('ordinary', datetime(2026, 11, 2, 1, 30, tzinfo=ny),
datetime(2026, 11, 2, 2, 30, tzinfo=ny), 3600, 3600),
]
for name, start, end, wall, seconds in cases:
assert start.tzinfo is end.tzinfo
assert (end - start).total_seconds() == wall
assert elapsed(start, end).total_seconds() == seconds
assert elapsed(end, start).total_seconds() == -seconds
print(name, 'wall:', wall, 'elapsed:', seconds)
try:
elapsed(datetime(2026, 1, 1), cases[0][1])
except ValueError:
print('Naive input rejected')
else:
raise AssertionError('Expected naive input rejection')
long_duration = timedelta(days=1, seconds=2)
negative_duration = timedelta(seconds=-1)
assert long_duration.seconds == 2
assert long_duration.total_seconds() == 86402
assert negative_duration.seconds == 86399
assert negative_duration.total_seconds() == -1
print('Signed totals:', long_duration.total_seconds(),
negative_duration.total_seconds())The printed pairs are spring 3601/1, autumn 7200/10800, fold 0/3600, and ordinary 3600/3600. These are reproducible fixture results, not a measurement of a user's application. The same complete block was checked with local Python 3.11.6, 3.13.2 and 3.14.3.
Diagnose the shared timezone branch
Python's documented subtraction rule ignores timezone adjustments when the endpoints share tzinfo. That explains the wall-clock results above. The fold example is especially useful: the same 01:30 label occurs twice, so local fields alone cannot represent the elapsed hour. Converting the endpoints preserves their distinct UTC instants.
When inspecting an existing application, log each full timestamp with its offset, the timezone name, and whether the endpoints share the same timezone object. An offset abbreviation by itself is poor evidence for a regional rule. Do not fix the symptom by creating different timezone objects to force a different arithmetic branch; make the elapsed-time intent explicit in the calculation instead.
Read the signed total, not one timedelta component
The final assertions demonstrate a separate output trap. The seconds component is not the complete duration. A report that uses it can lose whole days or turn a negative interval into a large positive component. For an elapsed-seconds column, use total_seconds() and document whether fractional seconds and negative values are allowed. Avoid silently rounding before the caller decides its reporting policy.
Resolve ambiguous inputs before measuring them
UTC conversion cannot repair a wrongly interpreted input. An ambiguous autumn time needs the intended occurrence; a nonexistent spring time needs an explicit application policy. Attaching a regional zone does not by itself validate that a local clock label occurred. The local wall-time and UTC guide covers that earlier input boundary.
This fixture constructs known endpoints. It does not parse external timestamp strings, validate a scheduling system, reproduce a third-party dateutil application, or verify every timezone database release. A 2023 CPython reader report motivates the problem; it does not establish current search demand. Keep the deployed timezone data and original timestamp interpretation in the diagnosis.
For a job that must run every morning at a particular local clock time, define that calendar recurrence separately. Reusing an elapsed-hour interval as a recurrence rule can move the local execution time across a clock change. For measuring a running process, a monotonic clock addresses another requirement: wall-clock corrections must not alter the measured interval. Choose the clock and arithmetic that match the caller's question.