Skip to content

Commit d4776c9

Browse files
jbrockmendelclaude
andcommitted
BUG: reject datetimes landing on the NaT sentinel (GH#66510)
NPY_NAT is INT64_MIN, one below Timestamp.min. A datetime that renders or tz-shifts onto that value is not NaT, but is indistinguishable from it downstream: it reprs as an ordinary Timestamp and isna() reports False, yet it reads back as NaT once stored in a datetime64 array. Reject it with OutOfBoundsDatetime at the construction sites, in both the scalar and vectorized paths, so that Timestamp, to_datetime and read_csv agree. The check runs after any shift to UTC rather than before, since a wall time that renders onto the sentinel can still shift into range. Messages name the sub-second digits via a new dts_to_iso_string_ns, since those digits are what put the value out of bounds and a seconds-truncated message would name a representable value. Also convert the bare OverflowError from the UTC-offset subtraction in convert_datetime_to_tsobject into OutOfBoundsDatetime, via the checked_sub extern already used in that file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 644979a commit d4776c9

11 files changed

Lines changed: 313 additions & 2 deletions

File tree

doc/source/whatsnew/v3.1.0.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,9 +334,11 @@ Datetimelike
334334
- Bug in :class:`ArrowExtensionArray` where adding a :class:`DateOffset` to a ``date32[pyarrow]`` or ``date64[pyarrow]`` Series raised an ``ArrowTypeError`` (:issue:`57168`)
335335
- Bug in :class:`DatetimeIndex` constructor raising ``ValueError`` when passing equivalent but not equal frequencies (e.g. ``QS-FEB`` vs ``QS-MAY``) (:issue:`61086`)
336336
- Bug in :class:`DatetimeIndex` raising ``AttributeError`` when comparing against Arrow date types (date32, date64) (:issue:`62051`)
337+
- Bug in :class:`Timestamp` constructor where a timezone-aware ``datetime`` near the implementation bounds raised ``OverflowError`` instead of :class:`OutOfBoundsDatetime` when shifted to UTC (:issue:`66510`)
337338
- Bug in :class:`Timestamp` constructor where passing ``np.str_`` objects would fail in Cython string parsing (:issue:`48974`)
338339
- Bug in :class:`Timestamp` constructor where strings with a negative year of fewer than 4 digits (e.g. ``"-111-01-01"``) silently dropped the leading ``"-"`` and were parsed as a positive year; BC dates with 1-4 digit years now parse correctly, matching :class:`numpy.datetime64` (:issue:`55954`)
339340
- Bug in :class:`Timestamp` constructor, :class:`Timedelta` constructor, :func:`to_datetime`, and :func:`to_timedelta` with non-round ``float`` input and ``unit`` failing to raise when the value is just outside the representable bounds (:issue:`57366`)
341+
- Bug in :class:`Timestamp` constructor, :meth:`Timestamp.replace`, :func:`to_datetime`, and :func:`read_csv` with ``parse_dates`` where a datetime landing on the ``NaT`` sentinel, either directly or after the shift to UTC, silently became ``NaT`` instead of raising :class:`OutOfBoundsDatetime` (:issue:`66510`)
340342
- Bug in :func:`api.types.infer_dtype` returning ``"date"`` or ``"mixed"`` instead of ``"datetime"`` / ``"timedelta"`` for lists of :class:`Timestamp`/:class:`Timedelta` values mixed with ``pd.NA`` (:issue:`53023`)
341343
- Bug in :func:`date_range` where ``inclusive="left"`` and ``inclusive="right"`` returned a single-element result instead of empty when ``start`` equals ``end`` (:issue:`55293`)
342344
- 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`)

pandas/_libs/parsers.pyx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2402,6 +2402,13 @@ cdef _datetime_box_utf8(parser_t *parser, int64_t col,
24022402
break
24032403

24042404
iresult[i] = npy_datetimestruct_to_datetime(creso, &dts)
2405+
if iresult[i] == NPY_NAT:
2406+
# GH#66510 NA rows already `continue`d above, so this is a
2407+
# real value that rendered onto the NaT sentinel and would be
2408+
# indistinguishable from NA. Defer to the object path like
2409+
# any other date this fastpath cannot represent.
2410+
fallback = True
2411+
break
24052412
except OverflowError:
24062413
return None, 0
24072414

pandas/_libs/tslibs/conversion.pyx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,10 @@ from pandas._libs.tslibs.np_datetime cimport (
4747
NPY_FR_us,
4848
astype_overflowsafe,
4949
check_dts_bounds,
50+
check_nat_sentinel,
5051
convert_reso,
5152
dts_to_iso_string,
53+
dts_to_iso_string_ns,
5254
get_conversion_factor,
5355
get_datetime64_unit,
5456
get_implementation_bounds,
@@ -492,6 +494,8 @@ cdef _TSObject convert_to_tsobject(object ts, tzinfo tz, str unit,
492494
obj.value = tz_localize_to_utc_single(
493495
obj.value, tz, ambiguous="raise", nonexistent=None, creso=reso
494496
)
497+
# GH#66510 the shift to UTC must not land on the NaT sentinel
498+
check_nat_sentinel(obj.value, &obj.dts, reso)
495499
elif is_integer_object(ts) or (is_float_object(ts) and ts.is_integer()):
496500
try:
497501
ts = <int64_t>ts
@@ -584,7 +588,7 @@ cdef _TSObject convert_datetime_to_tsobject(
584588
"""
585589
cdef:
586590
_TSObject obj = _TSObject()
587-
int64_t pps
591+
int64_t pps, offset_val
588592

589593
obj.creso = reso
590594
obj.fold = ts.fold
@@ -622,8 +626,18 @@ cdef _TSObject convert_datetime_to_tsobject(
622626
if obj.tzinfo is not None and not is_utc(obj.tzinfo):
623627
offset = get_utcoffset(obj.tzinfo, ts)
624628
pps = periods_per_second(reso)
625-
obj.value -= int(offset.total_seconds() * pps)
629+
# utcoffset is bounded by +/-24h, so this cannot itself overflow
630+
offset_val = int(offset.total_seconds() * pps)
631+
# GH#66510 the shift to UTC must not wrap int64 silently
632+
if checked_sub(obj.value, offset_val, &obj.value):
633+
attrname = npy_unit_to_attrname[reso]
634+
raise OutOfBoundsDatetime(
635+
f"Out of bounds {attrname} timestamp: {dts_to_iso_string_ns(&obj.dts)}"
636+
)
626637

638+
# GH#66510. NB: after the shift rather than before, since a wall time that
639+
# renders onto the sentinel can still shift to a representable UTC value.
640+
check_nat_sentinel(obj.value, &obj.dts, reso)
627641
check_overflows(obj, reso)
628642
return obj
629643

@@ -749,6 +763,8 @@ cdef _TSObject convert_str_to_tsobject(str ts, tzinfo tz,
749763
raise OutOfBoundsDatetime(
750764
f"Out of bounds {attrname} timestamp: {ts}"
751765
)
766+
# GH#66510 nor may it land on the NaT sentinel
767+
check_nat_sentinel(obj.value, &dts, reso)
752768
if tz is None:
753769
check_overflows(obj, reso)
754770
return obj
@@ -760,6 +776,8 @@ cdef _TSObject convert_str_to_tsobject(str ts, tzinfo tz,
760776
ival = tz_localize_to_utc_single(
761777
ival, tz, ambiguous="raise", nonexistent=None, creso=reso
762778
)
779+
# GH#66510 the shift must not land on the NaT sentinel
780+
check_nat_sentinel(ival, &dts, reso)
763781
obj.value = ival
764782
maybe_localize_tso(obj, tz, obj.creso)
765783
return obj

pandas/_libs/tslibs/np_datetime.pxd

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,22 @@ cdef inline void import_pandas_datetime() noexcept:
7171
cdef bint cmp_scalar(int64_t lhs, int64_t rhs, int op) except -1
7272

7373
cdef str dts_to_iso_string(npy_datetimestruct *dts)
74+
cdef str dts_to_iso_string_ns(npy_datetimestruct *dts)
7475

7576
cdef check_dts_bounds(npy_datetimestruct *dts, NPY_DATETIMEUNIT unit=?)
7677

78+
cdef _raise_nat_sentinel(npy_datetimestruct *dts, NPY_DATETIMEUNIT unit)
79+
80+
81+
# Inline so callers pay a compare and a not-taken branch; a cimported cdef would
82+
# instead be an indirect call, which the compiler cannot see through.
83+
cdef inline int check_nat_sentinel(
84+
int64_t value, npy_datetimestruct *dts, NPY_DATETIMEUNIT unit
85+
) except -1:
86+
if value == NPY_DATETIME_NAT:
87+
_raise_nat_sentinel(dts, unit)
88+
return 0
89+
7790
cdef int64_t pydatetime_to_dt64(
7891
datetime val, npy_datetimestruct *dts, NPY_DATETIMEUNIT reso=?
7992
) except? -1

pandas/_libs/tslibs/np_datetime.pyx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,40 @@ cdef str dts_to_iso_string(npy_datetimestruct *dts):
247247
f"{dts.hour:02d}:{dts.min:02d}:{dts.sec:02d}")
248248

249249

250+
cdef str dts_to_iso_string_ns(npy_datetimestruct *dts):
251+
"""
252+
Render `dts`, including its sub-second digits if it has any.
253+
254+
For callers where the sub-second digits are what put the value out of
255+
bounds, so that truncating to seconds would name a representable value.
256+
Trailing zeros are dropped so that a coarser-than-nanosecond `dts` is not
257+
given precision it does not have; the digits shown are always exact.
258+
"""
259+
cdef:
260+
int64_t nanos = dts.us * 1000 + dts.ps // 1000
261+
str digits
262+
263+
if nanos == 0:
264+
return dts_to_iso_string(dts)
265+
digits = f"{nanos:09d}".rstrip("0")
266+
return f"{dts_to_iso_string(dts)}.{digits}"
267+
268+
269+
cdef _raise_nat_sentinel(npy_datetimestruct *dts, NPY_DATETIMEUNIT unit):
270+
"""
271+
Out-of-line raise for check_nat_sentinel (see np_datetime.pxd).
272+
273+
NPY_NAT is INT64_MIN, so a rendered or tz-shifted value that lands on it is
274+
not NaT but is indistinguishable from it downstream: it reads back as NaT as
275+
soon as it is stored in a datetime64 array, and wraps if converted to another
276+
unit. (GH#66510)
277+
"""
278+
attrname = npy_unit_to_attrname[unit]
279+
raise OutOfBoundsDatetime(
280+
f"Out of bounds {attrname} timestamp: {dts_to_iso_string_ns(dts)}"
281+
)
282+
283+
250284
cdef check_dts_bounds(npy_datetimestruct *dts, NPY_DATETIMEUNIT unit=NPY_FR_ns):
251285
"""Raises OutOfBoundsDatetime if the given date is outside the range that
252286
can be represented by nanosecond-resolution 64-bit integers."""

pandas/_libs/tslibs/strptime.pyx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ from pandas._libs.tslibs.nattype cimport (
6666
from pandas._libs.tslibs.np_datetime cimport (
6767
NPY_DATETIMEUNIT,
6868
NPY_FR_ns,
69+
check_nat_sentinel,
6970
get_datetime64_unit,
7071
import_pandas_datetime,
7172
npy_datetimestruct,
@@ -533,10 +534,15 @@ def array_strptime(
533534
f"Out of bounds {npy_unit_to_attrname[creso]} "
534535
f"timestamp: {val}"
535536
)
537+
# GH#66510 nor may it land on the NaT sentinel
538+
check_nat_sentinel(value, &dts, creso)
536539
else:
537540
tz = None
538541
state.out_tzoffset_vals.add("naive")
539542
state.found_naive_str = True
543+
# GH#66510 nothing shifts this value afterwards, so a
544+
# rendering onto the sentinel is final
545+
check_nat_sentinel(value, &dts, creso)
540546
iresult[i] = value
541547
continue
542548

@@ -588,11 +594,23 @@ def array_strptime(
588594
f"Out of bounds {npy_unit_to_attrname[creso]} "
589595
f"timestamp: {val}"
590596
)
597+
# GH#66510 nor may it land on the NaT sentinel
598+
check_nat_sentinel(ival, &dts, creso)
591599
iresult[i] = ival
592600
else:
593601
iresult[i] = tz_localize_to_utc_single(
594602
ival, tz, ambiguous="raise", nonexistent=None, creso=creso
595603
)
604+
# GH#66510 the shift must not land on the NaT sentinel.
605+
# Only meaningful when ival was not already the sentinel:
606+
# tz_localize_to_utc_single returns such a value untouched,
607+
# so we would be rejecting the *wall* time, which a westward
608+
# offset can legitimately shift into range. That leaves a
609+
# sentinel wall time under a named zone still reading back
610+
# as NaT, as it does today; the offset is not recoverable
611+
# here because the localizer refuses the input.
612+
if ival != NPY_NAT:
613+
check_nat_sentinel(iresult[i], &dts, creso)
596614
nsecs = (ival - iresult[i])
597615
if creso == NPY_FR_ns:
598616
nsecs = nsecs // 10**9
@@ -604,6 +622,9 @@ def array_strptime(
604622
state.out_tzoffset_vals.add(nsecs)
605623
state.found_aware_str = True
606624
else:
625+
# GH#66510 nothing shifts this value afterwards, so a
626+
# rendering onto the sentinel is final
627+
check_nat_sentinel(iresult[i], &dts, creso)
607628
state.found_naive_str = True
608629
tz = None
609630
state.out_tzoffset_vals.add("naive")

pandas/_libs/tslibs/timestamps.pyx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ from pandas._libs.tslibs.nattype cimport (
8585
from pandas._libs.tslibs.np_datetime cimport (
8686
NPY_DATETIMEUNIT,
8787
NPY_FR_ns,
88+
check_nat_sentinel,
8889
cmp_dtstructs,
8990
cmp_scalar,
9091
convert_reso,
@@ -3691,6 +3692,9 @@ default 'raise'
36913692
raise OutOfBoundsDatetime(
36923693
f"Out of bounds timestamp: {fmt} with frequency '{self.unit}'"
36933694
) from err
3695+
# GH#66510 the tz-aware legs below go through
3696+
# convert_datetime_to_tsobject, which checks this for us
3697+
check_nat_sentinel(ts.value, &dts, creso)
36943698
ts.dts = dts
36953699
ts.creso = creso
36963700
ts.fold = fold

pandas/tests/io/parser/test_parse_dates.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -998,6 +998,37 @@ def test_parse_dates_c_fastpath_out_of_bounds_offset(low_memory):
998998
assert result.iloc[0] == "2262-04-11T20:00:00.000000000-11:00"
999999

10001000

1001+
@pytest.mark.parametrize("low_memory", [False, True])
1002+
def test_parse_dates_c_fastpath_nat_sentinel(low_memory):
1003+
# GH#66510 this renders onto iNaT, which the fastpath cannot tell apart from
1004+
# a missing value. It falls back to the raw strings like any other date the
1005+
# fastpath cannot represent, rather than emitting a bogus NaT.
1006+
data = "a\n1677-09-21 00:12:43.145224192\n"
1007+
result = read_csv(StringIO(data), parse_dates=["a"], low_memory=low_memory)["a"]
1008+
expected = read_csv(StringIO(data), parse_dates=["a"], engine="python")["a"]
1009+
tm.assert_series_equal(result, expected)
1010+
assert result.iloc[0] == "1677-09-21 00:12:43.145224192"
1011+
1012+
1013+
@pytest.mark.parametrize("low_memory", [False, True])
1014+
def test_parse_dates_c_fastpath_nat_sentinel_shifts_into_range(low_memory):
1015+
# GH#66510 the wall time renders onto iNaT but the westward shift moves it
1016+
# back in bounds, so the column still parses
1017+
data = "a\n1677-09-21 00:12:43.145224192-01:00\n"
1018+
result = read_csv(StringIO(data), parse_dates=["a"], low_memory=low_memory)["a"]
1019+
expected = read_csv(StringIO(data), parse_dates=["a"], engine="python")["a"]
1020+
tm.assert_series_equal(result, expected)
1021+
assert result.array.asi8[0] == -(2**63) + 3600 * 10**9
1022+
1023+
1024+
@pytest.mark.parametrize("low_memory", [False, True])
1025+
def test_parse_dates_c_fastpath_nat_sentinel_neighbour(low_memory):
1026+
# GH#66510 one nanosecond later is representable and must still parse
1027+
data = "a\n1677-09-21 00:12:43.145224193\n"
1028+
result = read_csv(StringIO(data), parse_dates=["a"], low_memory=low_memory)["a"]
1029+
assert result.iloc[0] == Timestamp("1677-09-21 00:12:43.145224193")
1030+
1031+
10011032
def _multichunk_csv(date_strings):
10021033
# wide enough that 20k rows span several low_memory chunks
10031034
num_extra_cols = 63

pandas/tests/scalar/timestamp/methods/test_replace.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from datetime import datetime
2+
import re
23
import zoneinfo
34

45
from dateutil.tz import gettz
@@ -209,3 +210,16 @@ def test_replace_updates_unit(self):
209210
result = ts3.replace(microsecond=ts2.microsecond)
210211
assert result.unit == "us"
211212
assert result == ts2
213+
214+
215+
@pytest.mark.parametrize("tz", [None, "UTC"])
216+
def test_replace_hits_nat_sentinel(tz):
217+
# GH#66510 the replaced value renders onto iNaT, which is not NaT but is
218+
# indistinguishable from it once stored in a datetime64 array
219+
ts = Timestamp("1677-09-21 00:12:43.145224193", tz=tz)
220+
msg = re.escape("Out of bounds nanosecond timestamp: 1677-09-21 00:12:43.145224192")
221+
with pytest.raises(OutOfBoundsDatetime, match=msg):
222+
ts.replace(nanosecond=192)
223+
224+
# the neighbouring value is fine
225+
assert ts.replace(nanosecond=194)._value == ts._value + 1

pandas/tests/scalar/timestamp/test_constructors.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
timedelta,
77
timezone,
88
)
9+
import re
910
import zoneinfo
1011

1112
import dateutil.tz
@@ -1192,3 +1193,75 @@ def test_timestamp_iso8601_offset_out_of_bounds(tz):
11921193
msg = "Out of bounds nanosecond timestamp"
11931194
with pytest.raises(OutOfBoundsDatetime, match=msg):
11941195
Timestamp("2262-04-11T20:00:00.000000000-11:00", tz=tz)
1196+
1197+
1198+
# GH#66510 iNaT == INT64_MIN, one below Timestamp.min. A value that renders or
1199+
# shifts onto it is not NaT, but is indistinguishable from NaT once stored in a
1200+
# datetime64 array, so it has to be rejected instead.
1201+
SENTINEL_UTC = "1677-09-21 00:12:43.145224192"
1202+
SENTINEL_PLUS_1H = "1677-09-21 01:12:43.145224192"
1203+
PLUS_1H = timezone(timedelta(hours=1))
1204+
MINUS_1H = timezone(timedelta(hours=-1))
1205+
1206+
1207+
def test_constructor_naive_datetime_renders_nat_sentinel():
1208+
# GH#66510 dtstruct -> int64 rendering lands on iNaT
1209+
msg = re.escape(f"Out of bounds nanosecond timestamp: {SENTINEL_UTC}")
1210+
with pytest.raises(OutOfBoundsDatetime, match=msg):
1211+
Timestamp(datetime(1677, 9, 21, 0, 12, 43, 145224), nanosecond=192)
1212+
1213+
1214+
def test_constructor_datetime64_tz_shift_hits_nat_sentinel():
1215+
# GH#66510 np.datetime64 is treated as a wall time; localizing it lands on iNaT
1216+
msg = re.escape(f"Out of bounds nanosecond timestamp: {SENTINEL_PLUS_1H}")
1217+
with pytest.raises(OutOfBoundsDatetime, match=msg):
1218+
Timestamp(np.datetime64(SENTINEL_PLUS_1H.replace(" ", "T")), tz=PLUS_1H)
1219+
1220+
1221+
def test_constructor_aware_datetime_offset_hits_nat_sentinel():
1222+
# GH#66510 subtracting the UTC offset lands on iNaT
1223+
msg = re.escape(f"Out of bounds nanosecond timestamp: {SENTINEL_PLUS_1H}")
1224+
with pytest.raises(OutOfBoundsDatetime, match=msg):
1225+
Timestamp(
1226+
datetime(1677, 9, 21, 1, 12, 43, 145224, tzinfo=PLUS_1H), nanosecond=192
1227+
)
1228+
1229+
1230+
@pytest.mark.parametrize(
1231+
"arg, kwargs",
1232+
[
1233+
# the tz keyword shifts via tz_localize_to_utc_single
1234+
(SENTINEL_PLUS_1H, {"tz": PLUS_1H}),
1235+
# an embedded offset shifts via the checked_sub fastpath
1236+
(f"{SENTINEL_PLUS_1H}+01:00", {}),
1237+
],
1238+
)
1239+
def test_constructor_str_shift_hits_nat_sentinel(arg, kwargs):
1240+
# GH#66510 both shift paths in convert_str_to_tsobject
1241+
msg = re.escape(f"Out of bounds nanosecond timestamp: {SENTINEL_PLUS_1H}")
1242+
with pytest.raises(OutOfBoundsDatetime, match=msg):
1243+
Timestamp(arg, **kwargs)
1244+
1245+
1246+
def test_constructor_aware_datetime_offset_overflow():
1247+
# GH#66510 the offset subtraction used to raise a bare OverflowError
1248+
msg = re.escape("Out of bounds nanosecond timestamp: 2262-04-11 23:47:16.854775807")
1249+
tz = timezone(timedelta(hours=-23, minutes=-59))
1250+
with pytest.raises(OutOfBoundsDatetime, match=msg):
1251+
Timestamp(datetime(2262, 4, 11, 23, 47, 16, 854775, tzinfo=tz), nanosecond=807)
1252+
1253+
1254+
def test_constructor_offset_shifts_off_nat_sentinel():
1255+
# GH#66510 the wall time renders onto iNaT but the shift to UTC moves it back
1256+
# in bounds, so the rejection has to happen after the shift, not before
1257+
ts = Timestamp(
1258+
datetime(1677, 9, 21, 0, 12, 43, 145224, tzinfo=MINUS_1H), nanosecond=192
1259+
)
1260+
assert ts._value == -(2**63) + 3600 * 10**9
1261+
1262+
1263+
def test_constructor_nat_sentinel_neighbours():
1264+
# GH#66510 only the sentinel itself is out of bounds
1265+
assert Timestamp("1677-09-21 00:12:43.145224193")._value == -(2**63) + 1
1266+
assert Timestamp.min._value == -(2**63) + 1
1267+
assert Timestamp(NaT) is NaT

0 commit comments

Comments
 (0)