Skip to content

ZoneInfo mishandles TZ footer rules spanning a year boundary #159715

Description

@pganssle

Bug report

import datetime
import io
import struct
import zoneinfo
from zoneinfo import _zoneinfo


def tzif(footer: bytes) -> io.BytesIO:
    # A TZif v3 file with no explicit transitions.
    header = b"TZif3" + b"\0" * 15 + struct.pack(">6l", 0, 0, 0, 0, 1, 4)
    body = struct.pack(">lbb", 0, 0, 0) + b"STD\0"
    return io.BytesIO(header + body + header + body + b"\n" + footer + b"\n")


for implementation in (zoneinfo, _zoneinfo):
    tz = implementation.ZoneInfo.from_file(tzif(b"STD-2DST-1,J1/0,J365/23"))
    utc = datetime.datetime(2100, 12, 31, 22, 59, 59, tzinfo=datetime.UTC)
    local = utc.astimezone(tz)
    print(implementation.__name__)
    print(utc)
    print(local)
    print(local.astimezone(datetime.UTC))

Output:

zoneinfo
2100-12-31 22:59:59+00:00
2101-01-01 00:59:59+01:00
2100-12-31 23:59:59+00:00
zoneinfo._zoneinfo
2100-12-31 22:59:59+00:00
2101-01-01 00:59:59+01:00
2100-12-31 23:59:59+00:00

I would expect the local time to be 2100-12-31 23:59:59+01:00 and the conversion back to UTC to preserve the original instant. Instead, both implementations produce the wrong wall time and move the instant forward by an hour on the round trip.

This footer describes permanent DST at UTC+01, with a dummy standard offset of UTC+02, using the all-year DST convention described in RFC 9636, section 3.3.1. The end of DST for 2100 is December 31 at 23:00 DST, or 22:00 UTC. The start of DST for 2101 is January 1 at 00:00 standard time, which is the same instant. There is no intervening period of standard time.

The pure-Python UTC lookup and the C UTC lookup only evaluate the rules for the UTC year. At 22:59:59 UTC, that year's DST period has ended, but the next year's period has already started. The lookup nevertheless selects the dummy UTC+02 offset and adds two hours. When the result's offset is subsequently queried using its local year, it gets UTC+01, which accounts for the inconsistent result above.

The original example was the XXX-2<+01>-1,0/0,J365/23 footer used for Africa/Casablanca in rearguard data. This reproducer uses J1 instead of 0 so it does not depend on the separate off-by-one bug in zero-based day rules, and constructs the file directly so no particular tzdata installation is required. I think the lookup needs to account for adjacent years' rules near a year boundary.

Linked PRs

Activity

  1. added 3 commits that reference this issue on Oct 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    extension-modulesC modules in the Modules dirtype-bugAn unexpected behavior, bug, or error

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions