Skip to content

PERF: improve performance with ZoneInfo timezones (get internal transitions/deltas to avoid slow python path) - #64379

Merged
jorisvandenbossche merged 31 commits into
pandas-dev:mainfrom
kjmin622:issue64363
May 6, 2026
Merged

jorisvandenbossche merged 31 commits into
pandas-dev:mainfrom
kjmin622:issue64363

Conversation

@kjmin622

@kjmin622 kjmin622 commented Mar 3, 2026 •

Copy link
Copy Markdown
Contributor

@kjmin622
kjmin622 marked this pull request as draft March 3, 2026 08:26
Comment thread pandas/_libs/tslibs/tzconversion.pyx Outdated
self.deltas = deltas

if typ != "pytz" and typ != "dateutil":
if typ not in ("pytz", "dateutil", "zoneinfo"):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: is this going to create an unnecessary python tuple object?

Comment thread pandas/_libs/tslibs/timezones.pyx Outdated
# Daylight Savings


cdef object _get_zoneinfo_trans_and_deltas(tzinfo tz):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we re-use some of this in the treat_tz_as_dateutil path?

@jbrockmendel

Copy link
Copy Markdown
Member

Couple of test failures to figure out, but this looks like the right approach.

@kjmin622
kjmin622 marked this pull request as ready for review March 4, 2026 08:38
@kjmin622

kjmin622 commented Mar 4, 2026

Copy link
Copy Markdown
Contributor Author

@jbrockmendel Thank you for your review! I've applied your feedback.
The tests failed because ZoneInfo and dateutil interpret offsets differently before the first transition (LMT era). For example, Africa/Lusaka shows a 2-second difference between the two libraries.
To address this, I modified the logic to use the original approach before the first transition, and the optimized approach after. (I've tried several approaches, but it seems impossible to fully resolve the performance issue for historical dates (before ~1900) where discrepancies occur between ZoneInfo and dateutil.)

Comment thread pandas/_libs/tslibs/timezones.pyx Resolved
Comment thread pandas/_libs/tslibs/timezones.pyx Resolved
@jbrockmendel

Copy link
Copy Markdown
Member

ZoneInfo and dateutil interpret offsets differently before the first transition (LMT era)

This surprises me. Is there a minimal example?

Comment thread pandas/_libs/tslibs/timezones.pyx Outdated
dateutil_tz : tzinfo
A dateutil timezone object with _trans_list and _trans_idx attributes.
first_offset_seconds : int64_t
The UTC offset in seconds for the period before the first transition.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is needed to address the dateutil-vs-zoneinfo discrepancy?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, this is just extracting common logic into a single function. The discrepancy is addressed by the fallback logic in tzconversion.pyx (the part you asked me to add comments to).

Comment thread pandas/_libs/tslibs/tzconversion.pyx Resolved
@kjmin622

kjmin622 commented Mar 5, 2026

Copy link
Copy Markdown
Contributor Author

ZoneInfo and dateutil interpret offsets differently before the first transition (LMT era)

This surprises me. Is there a minimal example?

@jbrockmendel
Here's a minimal example from my environment:

from zoneinfo import ZoneInfo
from dateutil.tz import gettz
from datetime import datetime

dt = datetime(1900, 1, 1)

# Africa/Lusaka: 2-second difference
print(ZoneInfo("Africa/Lusaka").utcoffset(dt))  # -> 2:10:18
print(gettz("Africa/Lusaka").utcoffset(dt))     # -> 2:10:20

# Africa/Sao_Tome: much larger difference
print(ZoneInfo("Africa/Sao_Tome").utcoffset(dt))  # -> -1 day, 23:23:15
print(gettz("Africa/Sao_Tome").utcoffset(dt))     # -> 0:26:56

Note that this discrepancy may not occur in all environments. Looking at the CI failure results, macOS passes while Ubuntu and Windows fail.
The tests pass when using the original logic (ZoneInfo.utcoffset()) for timestamps before the first transition.

@jbrockmendel

Copy link
Copy Markdown
Member

@pganssle thoughts here on how to explain/handle the discrepancy?

@jbrockmendel jbrockmendel added Performance Memory or execution speed performance Timezones Timezone data dtype labels Mar 6, 2026
@jbrockmendel

Copy link
Copy Markdown
Member

I suspect this would also close #58962

Comment thread pandas/_libs/tslibs/timezones.pyx Outdated
return _get_trans_and_deltas_from_dateutil_tz(
dateutil_tz, first_offset_seconds
)
else:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does is_fixed_offset handle/catch this case correctly?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jbrockmendel The Etc/GMT* timezones (where _trans_list exists but is empty) were not being handled correctly. I've fixed this and committed the change.

@jorisvandenbossche

Copy link
Copy Markdown
Member

@kjmin622 see also the discussion on the issue #64363 for a possible alternative approach

@pganssle

pganssle commented Mar 15, 2026 •

Copy link
Copy Markdown
Contributor

@pganssle thoughts here on how to explain/handle the discrepancy?

I haven't looked into the details, but I suspect that it might be down to the fact that dateutil still only uses the V1 portion of the TZif data, so it doesn't properly handle transitions that occur outside of ±2**32 seconds of the unix epoch. If there's a discrepancy between the two outside of that range, zoneinfo is almost certainly correct.

That will be fixed in dateutil 3.0.0 when this PR lands. That PR has been in flight for several years, so the conservative bet is that that will ~never happen but in the past month or so I managed to get everything checked off. I think a few more tests started failing and I need some reviews, but I'm optimistic about landing it before May this year.

FWIW, my preferred mechanism to handle all of this stuff (not that I would recommend blocking PRs like this one on it) would be for someone to write a new "vectorized time zone" library for pandas to use instead of zoneinfo, pytz or dateutil. Under the hood pandas is already doing a lot of this, but just with a bunch of weird stuff on top of it, it would be nice to move it into its own library for use across the ecosystem.

I'd be happy to champion whatever changes are necessary in zoneinfo to expose an API for using the same time zone data search path that CPython uses so that it's at least standardized across all libraries where to look for the data.

@jorisvandenbossche jorisvandenbossche left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taking a closer look at that fixed offset detection, some questions, see inline comment

Comment thread pandas/_libs/tslibs/timezones.pyx Outdated
return tz.utcoffset(obj)


cdef set _get_zoneinfo_offset_seconds(object tz_py):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
cdef set _get_zoneinfo_offset_seconds(object tz_py):
cdef set _get_zoneinfo_fixed_offset(object tz_py):

And let it return something like is_fixed, fixed_offset (bool, int) ?

At the moment this function gets all offsets, while in practice (where this helper is used) we only need the single offset if there is a single one (if it is a fixed offset).

For checking non-fixed zones, this will now needlessly iterate over all the offsets.

Comment thread pandas/_libs/tslibs/timezones.pyx Outdated
Comment on lines +237 to +253
if hasattr(tz_py, "_ttinfos"):
for info in tz_py._ttinfos:
offsets.add(int(info.utcoff.total_seconds()))
if hasattr(tz_py, "_tti_before"):
offsets.add(int(tz_py._tti_before.utcoff.total_seconds()))
if hasattr(tz_py, "_tz_after") and tz_py._tz_after is not None:
tz_after = tz_py._tz_after
if hasattr(tz_after, "std"):
offsets.add(int(tz_after.std.utcoff.total_seconds()))
offsets.add(int(tz_after.dst.utcoff.total_seconds()))
elif hasattr(tz_after, "utcoff"):
offsets.add(int(tz_after.utcoff.total_seconds()))
if hasattr(tz_after, "dstoff"):
offsets.add(
int((tz_after.utcoff + tz_after.dstoff).total_seconds())
)
return offsets

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This whole part was added (originally) in a commit mentioning to fix the 32bit tests (062dc27). And I see that indeed the fixed offset test was failing only for the 32bit linux.

But can you give a bit more explanation of why you did this fix?
The timezone database on that machine has different entries that didn't get caught by the previous check for fixed offsets? (which was only checking if len(tz_py._trans_utc) == 0 and not has_future_dst)

And also, now there is a more elaborate check, is that second check still needed?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another alternative, since we are relying on the private API of zoneinfo anyway now, is to use tz_py._fixed_offset. That would simplify this a lot

(and then the offset can be gotten from tz_py._tz_after.utcoff, I think)

@jorisvandenbossche

Copy link
Copy Markdown
Member

@kjmin622 small ping here (I would like to get this in for 3.0.3). Do you have time to take a look at my last comments?

@jorisvandenbossche jorisvandenbossche changed the title PERF: datetime index getters functions are 10 times slower with ZoneInfo vs pytz timezone PERF: improve performance with ZoneInfo timezones (get internal transitions/deltas to avoid slow python path) Apr 28, 2026
@kjmin622

Copy link
Copy Markdown
Contributor Author

@jorisvandenbossche I've had a lot on my schedule lately and couldn’t check. Would it be okay to apply it by May 3?

@kjmin622

kjmin622 commented May 6, 2026

Copy link
Copy Markdown
Contributor Author

@jorisvandenbossche I applied your review! Thank you.

@jorisvandenbossche

Copy link
Copy Markdown
Member

@kjmin622 thanks for the update! I just pushed one small additional simplication (the two small helper functions were essentially only used once now, so I inlined them)

@jorisvandenbossche jorisvandenbossche added this to the 3.0.3 milestone May 6, 2026

@jorisvandenbossche jorisvandenbossche left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @kjmin622!

Let's get this in. There is still a whatsnew missing, but I'll do that in a follow-up (and I have a follow-up PR for some other code paths as well)

@jorisvandenbossche
jorisvandenbossche merged commit 757fa84 into pandas-dev:main May 6, 2026
47 checks passed
jorisvandenbossche added a commit that referenced this pull request May 7, 2026
…neInfo timezones (get internal transitions/deltas to avoid slow python path)) (#65480)

Co-authored-by: Jeongmin Gil <[email protected]>
Co-authored-by: Joris Van den Bossche <[email protected]>
@kjmin622
kjmin622 deleted the issue64363 branch May 8, 2026 01:20
assert tz == timezone(-timedelta(hours=2, minutes=45))


def test_zoneinfo_utc_to_local_post_2037():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I have not read the full history of this PR. Is it expected that this test would fail if we used dates in 2100 instead of 2040? It seems to be the case for me locally. I am wondering if it is related to the round tripping failure we are seeing in the xarray test suite (e.g. in this CI run).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes, that is expected at the moment. At some point in the PR review I asked about the arbitrary cut-off of 2100, but then forgot to follow-up on that.

We should probably at least go up to 2262 on the short term, i.e. the max range for when we used nanosecond unit by default (that has now changed, so it is now possible to have data beyond that, but should be less common).
And then ideally have some solution that takes into account the range of the actual data being processed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All good, thanks @jorisvandenbossche that sounds reasonable to me.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, I would have expected this test to also fail in the past (before 3.0), because then we were using pytz by default, and then it stopped using DST transitions around 2037 (only with 3.0.0-3.0.2, it will have used DST transitions for all years).
But I assume this is because it is a hypothesis test, that you might not have run into that before?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Performance Memory or execution speed performance Timezones Timezone data dtype

Projects

None yet

6 participants