Repository: pandas-dev/pandas
Pull request #55979 (2023-11-15 23:25:16)

BUG: dt64 astype silent overflows
- [ ] closes #xxxx (Replace xxxx with the GitHub issue number)
- [x] [Tests added and passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#writing-tests) if fixing a bug or adding a new feature
- [ ] All [code checks passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#pre-commit).
- [x] Added [type annotations](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#type-hints) to new arguments/methods/functions.
- [x] Added an entry in the latest `doc/source/whatsnew/vX.X.X.rst` file if fixing a bug or adding a new feature.

Surfaced while implementing #55564


Patch:
diff --git a/doc/source/whatsnew/v2.2.0.rst b/doc/source/whatsnew/v2.2.0.rst
index 48aee18c90..90534174a7 100644
--- a/doc/source/whatsnew/v2.2.0.rst
+++ b/doc/source/whatsnew/v2.2.0.rst
@@ -347,6 +347,7 @@ Datetimelike
 - Bug in :meth:`Index.is_monotonic_increasing` and :meth:`Index.is_monotonic_decreasing` always caching :meth:`Index.is_unique` as ``True`` when first value in index is ``NaT`` (:issue:`55755`)
 - Bug in :meth:`Index.view` to a datetime64 dtype with non-supported resolution incorrectly raising (:issue:`55710`)
 - Bug in :meth:`Tick.delta` with very large ticks raising ``OverflowError`` instead of ``OutOfBoundsTimedelta`` (:issue:`55503`)
+- Bug in ``.astype`` converting from a higher-resolution ``datetime64`` dtype to a lower-resolution ``datetime64`` dtype (e.g. ``datetime64[us]->datetim64[ms]``) silently overflowing with values near the lower implementation bound (:issue:`55979`)
 - Bug in adding or subtracting a :class:`Week` offset to a ``datetime64`` :class:`Series`, :class:`Index`, or :class:`DataFrame` column with non-nanosecond resolution returning incorrect results (:issue:`55583`)
 - Bug in addition or subtraction of :class:`BusinessDay` offset with ``offset`` attribute to non-nanosecond :class:`Index`, :class:`Series`, or :class:`DataFrame` column giving incorrect results (:issue:`55608`)
 - Bug in addition or subtraction of :class:`DateOffset` objects with microsecond components to ``datetime64`` :class:`Index`, :class:`Series`, or :class:`DataFrame` columns with non-nanosecond resolution (:issue:`55595`)
diff --git a/pandas/_libs/tslibs/conversion.pyx b/pandas/_libs/tslibs/conversion.pyx
index 4f14782d9e..89b420b18a 100644
--- a/pandas/_libs/tslibs/conversion.pyx
+++ b/pandas/_libs/tslibs/conversion.pyx
@@ -716,7 +716,7 @@ cdef int64_t parse_pydatetime(
         result = _ts.value
     else:
         if isinstance(val, _Timestamp):
-            result = (<_Timestamp>val)._as_creso(creso, round_ok=False)._value
+            result = (<_Timestamp>val)._as_creso(creso, round_ok=True)._value
         else:
             result = pydatetime_to_dt64(val, dts, reso=creso)
     return result
diff --git a/pandas/_libs/tslibs/np_datetime.pyx b/pandas/_libs/tslibs/np_datetime.pyx
index 71a194177b..fa9b82fe46 100644
--- a/pandas/_libs/tslibs/np_datetime.pyx
+++ b/pandas/_libs/tslibs/np_datetime.pyx
@@ -365,13 +365,10 @@ cpdef ndarray astype_overflowsafe(
         return values
 
     elif from_unit > to_unit:
-        if round_ok:
-            # e.g. ns -> us, so there is no risk of overflow, so we can use
-            #  numpy's astype safely. Note there _is_ risk of truncation.
-            return values.astype(dtype)
-        else:
-            iresult2 = astype_round_check(values.view("i8"), from_unit, to_unit)
-            return iresult2.view(dtype)
+        iresult2 = _astype_overflowsafe_to_smaller_unit(
+            values.view("i8"), from_unit, to_unit, round_ok=round_ok
+        )
+        return iresult2.view(dtype)
 
     if (<object>values).dtype.byteorder == ">":
         # GH#29684 we incorrectly get OutOfBoundsDatetime if we dont swap
@@ -502,13 +499,20 @@ cdef int op_to_op_code(op):
         return Py_GT
 
 
-cdef ndarray astype_round_check(
+cdef ndarray _astype_overflowsafe_to_smaller_unit(
     ndarray i8values,
     NPY_DATETIMEUNIT from_unit,
-    NPY_DATETIMEUNIT to_unit
+    NPY_DATETIMEUNIT to_unit,
+    bint round_ok,
 ):
-    # cases with from_unit > to_unit, e.g. ns->us, raise if the conversion
-    #  involves truncation, e.g. 1500ns->1us
+    """
+    Overflow-safe conversion for cases with from_unit > to_unit, e.g. ns->us.
+    In addition for checking for overflows (which can occur near the lower
+    implementation bound, see numpy#22346), this checks for truncation,
+    e.g. 1500ns->1us.
+    """
+    # e.g. test_astype_ns_to_ms_near_bounds is a case with round_ok=True where
+    #  just using numpy's astype silently fails
     cdef:
         Py_ssize_t i, N = i8values.size
 
@@ -531,7 +535,7 @@ cdef ndarray astype_round_check(
             new_value = NPY_DATETIME_NAT
         else:
             new_value, mod = divmod(value, mult)
-            if mod != 0:
+            if not round_ok and mod != 0:
                 # TODO: avoid runtime import
                 from pandas._libs.tslibs.dtypes import npy_unit_to_abbrev
                 from_abbrev = npy_unit_to_abbrev(from_unit)


Test patch:
diff --git a/pandas/tests/arrays/test_datetimes.py b/pandas/tests/arrays/test_datetimes.py
index 566117899c..770f19d9f1 100644
--- a/pandas/tests/arrays/test_datetimes.py
+++ b/pandas/tests/arrays/test_datetimes.py
@@ -309,6 +309,22 @@ class TestDatetimeArrayComparisons:
 
 
 class TestDatetimeArray:
+    def test_astype_ns_to_ms_near_bounds(self):
+        # GH#55979
+        ts = pd.Timestamp("1677-09-21 00:12:43.145225")
+        target = ts.as_unit("ms")
+
+        dta = DatetimeArray._from_sequence([ts], dtype="M8[ns]")
+        assert (dta.view("i8") == ts.as_unit("ns").value).all()
+
+        result = dta.astype("M8[ms]")
+        assert result[0] == target
+
+        expected = DatetimeArray._from_sequence([ts], dtype="M8[ms]")
+        assert (expected.view("i8") == target._value).all()
+
+        tm.assert_datetime_array_equal(result, expected)
+
     def test_astype_non_nano_tznaive(self):
         dti = pd.date_range("2016-01-01", periods=3)