…orward at midnight
_start_of_day resets to midnight via self.at(0, 0, 0, 0), which propagates the
receiver's fold. On a timezone that springs forward exactly at midnight (e.g.
Chile/Continental, where 2025-09-07 00:00:00 does not exist), reaching that day
through .add(days=1) yields fold=0. For a nonexistent (skipped) local time with
fold=0, the timezone resolver shifts the instant backward, so midnight lands at
23:00 on the previous day and start_of("day") is wrong and non-idempotent.
After resetting to midnight, if the calendar day changed then midnight was
skipped, so re-create the day's first valid instant resolving forward (fold=1).
The guard fires only when midnight actually moved to the previous day, so all
other zones/dates are byte-identical (verified against UTC, America/New_York,
Asia/Tokyo, Europe/London, Europe/Vienna's 02:00 gap, and America/Sao_Paulo's
midnight fall-back). start_of("week") is covered via delegation.
Fixes python-pendulum#915.
Pull Request Check List
Fixes #915.
Problem
On a timezone that springs forward exactly at midnight (e.g. Chile/Continental, where 2025-09-07 00:00:00 does not exist), start_of("day") returns an instant on the previous calendar day and is not idempotent:
Expected the first valid instant of Sep 7, 2025-09-07 01:00:00-03:00. Zones whose transition is not at midnight (e.g. Europe/Vienna at 02:00) are unaffected.
Root cause
_start_of_day resets to midnight via self.at(0, 0, 0, 0), which propagates the receiver's fold. Reaching Sep 7 through .add(days=1) yields fold=0. For a nonexistent (skipped) local time with fold=0, the timezone resolver (tz/timezone.py) shifts the instant backward (offset_before - offset_after), so midnight lands at 23:00 on the previous day. start_of is therefore wrong and non-idempotent for that day.
Fix
Scoped to _start_of_day: after resetting to midnight, if the calendar day changed then midnight was skipped, so re-create the day's first instant resolving forward (fold=1). The guard fires only when midnight actually moved to the previous day, so all other zones/dates are byte-identical (verified against UTC, America/New_York, Asia/Tokyo, Europe/London, Europe/Vienna's 02:00 gap, and America/Sao_Paulo's midnight fall-back). Because _start_of_week delegates to start_of("day"), this also covers start_of("week").
Note: the same latent midnight-gap could in principle affect _start_of_month/year/decade/century; this PR deliberately keeps the change minimal and scoped to _start_of_day (the path reported in #915).
Tests
Added test_start_of_day_when_midnight_does_not_exist. Red without the fix (assert 6 == 7, result was 2025-09-06 23:00), green with it. Full suite: 1459 passed, 3 skipped (tests/datetime tests/tz tests/date).
This change was prepared with AI assistance and reviewed by me before submission.