Skip to content

Commit 888d598

Browse files
jbrockmendelclaude
andcommitted
BUG: Raise OutOfBoundsDatetime instead of silently upcasting in replace (pandas-dev#61671)
The previous fix silently widened datetime64[ns] to datetime64[us], which could truncate sub-microsecond data. Instead, raise OutOfBoundsDatetime with a message telling the user to explicitly cast before operating. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b11d3c6 commit 888d598

6 files changed

Lines changed: 19 additions & 25 deletions

File tree

doc/source/whatsnew/v3.1.0.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ Datetimelike
168168
- Bug in :func:`date_range` where ``inclusive`` parameter failed to filter endpoints when only ``start`` and ``periods`` or ``end`` and ``periods`` were specified (:issue:`46331`)
169169
- Bug in :func:`to_datetime` and :func:`to_timedelta` on ARM platforms where round ``float`` values outside the int64 domain (e.g. ``float(2**63)``) could silently produce incorrect results instead of raising (:issue:`64619`)
170170
- Bug in :func:`to_datetime` and :func:`to_timedelta` where ``uint64`` values greater than ``int64`` max silently overflowed instead of raising :class:`OutOfBoundsDatetime` or :class:`OutOfBoundsTimedelta` (:issue:`60677`)
171-
- Bug in :meth:`DataFrame.replace` and :meth:`Series.replace` raising ``AssertionError`` when replacing with a ``datetime`` value outside the ``datetime64[ns]`` range (:issue:`61671`)
171+
- Bug in :meth:`DataFrame.replace` and :meth:`Series.replace` raising ``AssertionError`` instead of :class:`OutOfBoundsDatetime` when replacing with a ``datetime`` value outside the ``datetime64[ns]`` range (:issue:`61671`)
172172
- Bug in :meth:`DatetimeArray.isin` and :meth:`TimedeltaArray.isin` where mismatched resolutions could silently truncate finer-resolution values, leading to false matches (:issue:`64545`)
173173
- Bug in adding non-nano :class:`DatetimeIndex` with non-vectorized offsets (e.g. :class:`CustomBusinessDay`, :class:`CustomBusinessMonthEnd`) having a sub-unit ``offset`` parameter incorrectly truncating the result or raising ``AttributeError`` (:issue:`56586`)
174174

pandas/core/dtypes/cast.py

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1206,20 +1206,6 @@ def find_result_type(left_dtype: DtypeObj, right: Any) -> DtypeObj:
12061206
dtype, _ = infer_dtype_from(right)
12071207
new_dtype = find_common_type([left_dtype, dtype])
12081208

1209-
# GH#61671: for datetime64/timedelta64, find_common_type picks the
1210-
# highest resolution (e.g. ns over us), but this may have too narrow
1211-
# a range for the value. If the inferred dtype has lower resolution
1212-
# (wider range), prefer it so the value can be represented.
1213-
if (
1214-
new_dtype == left_dtype
1215-
and isinstance(new_dtype, np.dtype)
1216-
and new_dtype.kind in "mM"
1217-
and isinstance(dtype, np.dtype)
1218-
and dtype.kind == new_dtype.kind
1219-
and dtype != new_dtype
1220-
):
1221-
new_dtype = dtype
1222-
12231209
return new_dtype
12241210

12251211

pandas/core/internals/blocks.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,15 @@ def coerce_to_target_dtype(self, other, raise_on_upcast: bool) -> Block:
437437
"""
438438
new_dtype = find_result_type(self.values.dtype, other)
439439
if new_dtype == self.dtype:
440+
if lib.is_np_dtype(new_dtype, "mM"):
441+
# GH#61671 e.g. datetime64[ns] column and a replacement value
442+
# outside ns range. find_common_type picked the highest
443+
# resolution which can't represent the value.
444+
raise OutOfBoundsDatetime(
445+
f"Incompatible (high-resolution) value for "
446+
f"dtype='{self.dtype}'. "
447+
"Explicitly cast before operating."
448+
)
440449
# GH#52927 avoid RecursionError
441450
raise AssertionError(
442451
"Something has gone wrong, please report a bug at "

pandas/tests/dtypes/test_inference.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2158,10 +2158,9 @@ def test_find_result_type_floats(right, result):
21582158
@pytest.mark.parametrize(
21592159
"right,result",
21602160
[
2161-
# GH#61671 - out-of-bounds for ns, needs wider range (us)
2162-
(datetime(3000, 1, 1), np.dtype("datetime64[us]")),
2163-
# in-range value still infers as us (Python datetime resolution)
2164-
(datetime(2020, 1, 1), np.dtype("datetime64[us]")),
2161+
# GH#61671 - find_common_type picks highest resolution (ns)
2162+
(datetime(3000, 1, 1), np.dtype("datetime64[ns]")),
2163+
(datetime(2020, 1, 1), np.dtype("datetime64[ns]")),
21652164
# np.datetime64 with explicit ns resolution stays ns
21662165
(np.datetime64("2020-01-01", "ns"), np.dtype("datetime64[ns]")),
21672166
],

pandas/tests/frame/methods/test_replace.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import numpy as np
77
import pytest
88

9+
from pandas.errors import OutOfBoundsDatetime
910
import pandas.util._test_decorators as td
1011

1112
import pandas as pd
@@ -1473,9 +1474,8 @@ def test_replace_nan_nullable_ints(self, dtype, using_nan_is_na):
14731474
def test_replace_datetime_out_of_bounds_for_ns(self):
14741475
# GH#61671
14751476
df = DataFrame([np.nan], dtype="datetime64[ns]")
1476-
result = df.replace(np.nan, datetime(3000, 1, 1))
1477-
expected = DataFrame([Timestamp("3000-01-01")], dtype="datetime64[us]")
1478-
tm.assert_frame_equal(result, expected)
1477+
with pytest.raises(OutOfBoundsDatetime, match="Explicitly cast"):
1478+
df.replace(np.nan, datetime(3000, 1, 1))
14791479

14801480

14811481
class TestDataFrameReplaceRegex:

pandas/tests/series/methods/test_replace.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import numpy as np
55
import pytest
66

7+
from pandas.errors import OutOfBoundsDatetime
78
import pandas.util._test_decorators as td
89

910
import pandas as pd
@@ -745,6 +746,5 @@ def test_replace_from_index():
745746
def test_replace_datetime_out_of_bounds_for_ns():
746747
# GH#61671
747748
ser = pd.Series([np.nan], dtype="datetime64[ns]")
748-
result = ser.replace(np.nan, datetime(3000, 1, 1))
749-
expected = pd.Series([pd.Timestamp("3000-01-01")], dtype="datetime64[us]")
750-
tm.assert_series_equal(result, expected)
749+
with pytest.raises(OutOfBoundsDatetime, match="Explicitly cast"):
750+
ser.replace(np.nan, datetime(3000, 1, 1))

0 commit comments

Comments
 (0)