Pandas version checks

  • [x] I have checked that this issue has not already been reported.

  • [x] I have confirmed this bug exists on the latest version of pandas.

  • [ ] I have confirmed this bug exists on the main branch of pandas.

Reproducible Example

from datetime import datetime, timedelta, timezone

import pandas as pd

dt = datetime(2025, 9, 10, 23, 0, 0, tzinfo=timezone.utc)

print(dt - pd.Timedelta(hours=1))  # 2025-09-10 22:00:00+00:00
print(dt - pd.Timedelta(pd.tseries.offsets.Hour(1)))  # 2025-09-10 23:00:00+00:00


exp_result = datetime(2025, 9, 10, 22, 0, 0, tzinfo=timezone.utc)

assert dt - timedelta(hours=1) == exp_result  # works
assert dt - pd.Timedelta(hours=1) == exp_result  # works
assert pd.Timedelta(pd.tseries.offsets.Hour(1)) == timedelta(hours=1)  # works
assert dt - pd.Timedelta(pd.tseries.offsets.Hour(1)) == exp_result  # assertion error

Issue Description

Adding/subtracting pd.Timedelta constructed from an hour offset gives the wrong result. It works as expected when constructing the pd.Timedelta object using e.g. pd.Timedelta(hours=1) instead of pd.Timedelta(pd.tseries.offsets.Hour(1)).

I initially noticed this constructing a pd.Timedelta from an datetimeindex freq, e.g. pd.Timedelta(df.index.freq)

Expected Behavior

Adding/subtracting pd.Timedelta should give the same results whether the timedelta is constructed from an hour offset or using hours={int}.

Installed Versions

INSTALLED VERSIONS ------------------ commit : 4665c10899bc413b639194f6fb8665a5c70f7db5 python : 3.12.3 python-bits : 64 OS : Linux OS-release : 6.14.0-29-generic Version : #29~24.04.1-Ubuntu SMP PREEMPT_DYNAMIC Thu Aug 14 16:52:50 UTC 2 machine : x86_64 processor : x86_64 byteorder : little LC_ALL : None LANG : en_GB.UTF-8 LOCALE : en_GB.UTF-8 pandas : 2.3.2 numpy : 2.3.2 pytz : 2025.2 dateutil : 2.9.0.post0 pip : None Cython : None sphinx : None IPython : None adbc-driver-postgresql: None adbc-driver-sqlite : None bs4 : None blosc : None bottleneck : None dataframe-api-compat : None fastparquet : None fsspec : None html5lib : None hypothesis : None gcsfs : None jinja2 : None lxml.etree : None matplotlib : None numba : None numexpr : None odfpy : None openpyxl : None pandas_gbq : None psycopg2 : None pymysql : None pyarrow : 21.0.0 pyreadstat : None pytest : 8.4.1 python-calamine : None pyxlsb : None s3fs : None scipy : None sqlalchemy : None tables : None tabulate : None xarray : None xlrd : None xlsxwriter : None zstandard : None tzdata : 2025.2 qtpy : None pyqt5 : None

Comment From: christianfosli

Subtracting/adding offsets directly without converting to pd.Timedelta seems to work as expected. So now I have my work-around 😄 But this still seems like a pretty clear bug IMO

print(dt - pd.tseries.offsets.Hour(1))  # 2025-09-10 22:00:00+00:00

Comment From: skalwaghe-56

This exists on main.

Comment From: christianfosli

This also works as expected

dt - pd.tseries.offsets.Hour(1).delta   # 2025-09-10 22:00:00+00:00
# FutureWarning: Hour.delta is deprecated and will be removed in a future version. Use pd.Timedelta(obj) instead

IIRC this deprecation warning is what made me try out pd.Timedelta(offset_obj) in the first place.

Comment From: skalwaghe-56

take

Comment From: skalwaghe-56

@jbrockmendel @rhshadrach @Aniketsy Your guidance will be really valuable.

I’ve been working on fixing this, and found that the core problem is Timedelta(Tick) not handling coarse-grained offsets like Hour/Minute/Second correctly. Casting them to ns resolves the bug. However, if we cast all Tick objects to ns, existing tests break ('test_add_dt64_ndarray_non_nano') for finer-grained ticks (Milli, Micro, Nano), since they’re expected to preserve their natural resolution (ms, us, ns). Should I Update the failing tests to expect the new consistent behavior? Or should I Modify the fix to be more selective (only normalize Hour/Minute/Second, preserve ms/us/ns)? Or something else?

Comment From: skalwaghe-56

@rhshadrach Please let me know if any changes needs to be done in the PR.

Comment From: simonjayhawkins

Thanks for the report @christianfosli

if you do

dt - pd.Timedelta(pd.tseries.offsets.BDay(1))

it fails with ValueError: Value must be Timedelta, string, integer, float, timedelta or convertible, not BusinessDay

Now, reading through #47845 the takeaways are:

  1. Timedelta represents a fixed duration (e.g., 1 minute, 2 days), while Offset can encode calendar-aware logic (e.g., business days, month ends).

  2. offsets and timedeltas are fundamentally different objects, even if they seem interchangeable to users.

So i think the first thing would be to check that our tests show we support (i.e. test) passing offsets to pd.Timedelta.

The bug could be that pd.Timedelta(pd.tseries.offsets.Hour(1))) should actually be raising?

accepting some offsets and not others could be a static typing nightmare.

Comment From: jbrockmendel

It's not about the way the Timedelta is constructed. The problem is in datetime.datetime.__sub__ not handling timedelta subclasses correctly. Note that if you wrap dt in a Timestamp before operating, you get the correct result. xref #53643

Comment From: skalwaghe-56

Thanks @simonjayhawkins and @jbrockmendel for the inputs.

So, from my understanding Timedelta is for fixed durations, but offsets are calendar-aware so they both don't quite fit well together. And the cause of the subtraction issue datetime.sub not handling TimeDelta properly? While the timestamp does?

And, my question was should we allow the changes in my PR #62321 or should we be raising?

Comment From: jbrockmendel

Please don't use AI for interactions here.

Comment From: skalwaghe-56

Sry @jbrockmendel, I am used to refine my replies everywhere. I'm extremely sorry sir!

Comment From: skalwaghe-56

@simonjayhawkins @jbrockmendel Please let me know your thoughts on this! How would we like to proceed?

Comment From: simonjayhawkins

It's not about the way the Timedelta is constructed. The problem is in datetime.datetime.__sub__ not handling timedelta subclasses correctly. Note that if you wrap dt in a Timestamp before operating, you get the correct result. xref #53643

Thanks @jbrockmendel for the explanation and link to known issue.

I was hinting towards the ValueError for pd.Timedelta(pd.tseries.offsets.BDay(1)) not being explicit about offsets, If we do allow offsets with fixed deltas then I perhaps would have expected a TypeError and mention of a fixed offset subclass. But ValueError: Value must be Timedelta, string, integer, float, timedelta or convertible, not BusinessDay doesn't give any indication that some offsets are supported.

Comment From: jbrockmendel

Timedelta(BDay()) should raise. But BDay isn't mentioned in the OP, so i'm thinking AI slop introduced it to the conversation.

Comment From: skalwaghe-56

Understood @jbrockmendel, thx! Let's keep aside Bday since it isn't part of the OG issue :). My PR is here about hour/minute/second behaving properly when they are passed to TimeDelta.

Thanks!

Comment From: simonjayhawkins

@jbrockmendel if a fix for https://github.com/pandas-dev/pandas/issues/53643 would also fix the code in the OP then it would be fair to close this as a duplicate

Comment From: jbrockmendel

The fix to #53643 is upstream in the stdlib.

Comment From: christianfosli

Thanks guys, I wasn't aware of #53643. Feel free to close as a dupe