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
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:
-
Timedelta represents a fixed duration (e.g., 1 minute, 2 days), while Offset can encode calendar-aware logic (e.g., business days, month ends).
-
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 wrapdt
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