Skip to content

Fix shift() producing wrong results across DST transitions#1253

Open
worksbyfriday wants to merge 1 commit intoarrow-py:masterfrom
worksbyfriday:fix-shift-dst-hours
Open

Fix shift() producing wrong results across DST transitions#1253
worksbyfriday wants to merge 1 commit intoarrow-py:masterfrom
worksbyfriday:fix-shift-dst-hours

Conversation

@worksbyfriday
Copy link
Copy Markdown

Summary

shift(hours=N) across DST spring-forward and fall-back boundaries produces incorrect results — the elapsed time is off by the DST offset.

Root cause: relativedelta uses wall-clock arithmetic. Adding hours=10 means "add 10 to the local hour," not "advance 10 real hours." When crossing a DST transition, these diverge.

Example from #1209:

start = arrow.Arrow(2025, 3, 8, 18, 30, tzinfo=ZoneInfo("US/Central"))
start.shift(hours=10)
# Before: 2025-03-09T04:30:00-05:00 (9 real hours)
# After:  2025-03-09T05:30:00-05:00 (10 real hours)

Fix: Separate kwargs into calendar units (years, months, weeks, days) and absolute units (hours, minutes, seconds, microseconds). Calendar units use relativedelta (wall-clock semantics). Absolute units are added via UTC conversion (real elapsed time).

This matches the semantics users expect: shift(days=1) preserves the local time, while shift(hours=24) advances exactly 24 real hours — which may or may not preserve the local time depending on DST.

Changes

  • arrow/arrow.py: Split absolute time kwargs from calendar kwargs; apply absolute units via UTC
  • tests/test_arrow.py: Added test_shift_dst_absolute_time covering spring-forward, fall-back, and mixed calendar+absolute shifts. Updated existing test_shift_negative_imaginary and imaginary-check tests to reflect correct absolute-time expectations.
  • CHANGELOG.rst: Added entry

Test plan

  • All 226 tests in test_arrow.py pass
  • All 1905 tests across the project pass
  • Verified spring-forward (March) and fall-back (November) cases
  • Verified mixed shift(days=1, hours=3) applies calendar then absolute
  • Verified non-DST shifts unchanged
  • Verified check_imaginary still works for calendar shifts
  • Verified naive (UTC) datetimes unaffected

Fixes #1209

🤖 Generated with Claude Code

When shifting by absolute time units (hours, minutes, seconds,
microseconds) across DST boundaries, the result was off by the
DST offset because relativedelta uses wall-clock arithmetic.

For example, shift(hours=10) starting at 18:30 CST across a
spring-forward boundary gave 04:30 CDT (9 real hours) instead
of 05:30 CDT (10 real hours).

Fix: separate absolute time units from calendar units. Calendar
units (years, months, weeks, days) continue to use relativedelta
for wall-clock semantics. Absolute units are now added via UTC
conversion, ensuring they reflect real elapsed time regardless
of DST transitions.

Fixes arrow-py#1209

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Arrow shift method is not handling DST properly

1 participant