Skip to content

BUG: fix undefined behavior flagged by UBSAN - #65831

Merged
WillAyd merged 12 commits into
pandas-dev:mainfrom
Alvaro-Kothe:fix/undefined-behavior
Jul 13, 2026
Merged

WillAyd merged 12 commits into
pandas-dev:mainfrom
Alvaro-Kothe:fix/undefined-behavior

Conversation

@Alvaro-Kothe

Copy link
Copy Markdown
Member

stacked on top of #65812.

@Alvaro-Kothe Alvaro-Kothe mentioned this pull request Jun 8, 2026
3 of 7 tasks
@Alvaro-Kothe
Alvaro-Kothe marked this pull request as ready for review June 8, 2026 15:38
@Alvaro-Kothe
Alvaro-Kothe requested a review from mroeschke as a code owner June 8, 2026 15:38
Comment thread pandas/_libs/tslibs/tzconversion.pyx Outdated
return <int64_t>(<uint64_t>a - <uint64_t>b)


cdef int64_t wrap_add(int64_t a, int64_t b) noexcept:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't fix anything - it just suppresses what UBSAN is warning about. Can we properly raise when bounds are exceeded?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we properly raise when bounds are exceeded?

I'll be honest with you, until now I don't understand what would be the correct way to manage the overflow in this module, so I asked gemini to look at the call stack to decide on how it should be handled.

Here is its raw message on why not raise an error directly or assign an invalid value:

The choice to use wrapping arithmetic rather than immediate errors or NaT assignments is driven by the existing architecture of the pandas timestamp library and the
  requirement to maintain specific error behaviors.

  1. Preserving the "Wrap-and-Catch" Pattern
  The current implementation of tz_localize and tz_convert follows a specific pattern:
   1. Localize: Perform the arithmetic to add/subtract the timezone offset.
   2. Validate: Call check_overflows (in conversion.pyx) on the result.

  If the arithmetic overflows and wraps around (e.g., a date in 1677 wraps to 2262), the check_overflows function detects that the resulting year is physically impossible for
  that timestamp and raises a highly specific OutOfBoundsDatetime error with a formatted message (e.g., "Converting 1677-09-21 00:12:43 underflows past 1677-09-21
  00:12:43.145224193").

  2. Why not immediate errors?
  Raising the error immediately inside _get_utc_bounds would require:
   - Duplicating complex error-formatting logic into a low-level Cython function.
   - Ensuring the error message perfectly matches what users and existing tests expect.
   - Changing a design pattern that is used consistently across the tslibs module.

  3. Why not NaT?
  Assigning NaT (or NPY_NAT) would actually be a behavior change:
   - Expectation: Localization that pushes a value out of bounds is required to raise OutOfBoundsDatetime in pandas. Returning NaT would silently swallow the error.
   - Ambiguity: NaT is also used for "non-existent" times (like the hour skipped during DST). If we returned NaT for overflows, we would lose the ability to distinguish
     between a "missing hour" and a "value out of bounds."

  4. Satisfying UBSAN
  Signed integer overflow is Undefined Behavior (UB) in C. Even if it "works" on most CPUs by wrapping, compilers are allowed to optimize based on the assumption that it
  never happens, which can lead to subtle bugs.

  By using unsigned casts for wrapping, we transform Undefined Behavior into Defined Behavior. The result is identical at the bit level, but it tells the compiler (and UBSAN)
  that the wrap-around is intentional. This fixes the sanitizer warning while keeping the library's logic intact and performant.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cython has an overflowcheck directive you can add as a wrapper that should do the right thing and raise.

In older versions of Cython you could argue this was "slow" but I think in the past few years they implemented it using the compiler built-in's, so its negligible overhead

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIU, we shouldn't be raising in this function. For example, in this test:

pac = Timestamp.min.tz_localize(pytz.timezone("US/Pacific"))
assert pac._value > Timestamp.min._value

It undeflows in _get_utc_bounds on val - ppd when handling the left side

isl = bisect_right_i8(tdata, val - ppd, ntrans) - 1
if isl < 0:
isl = 0
v_left = val - deltas[isl]
pos_left = bisect_right_i8(tdata, v_left, ntrans) - 1
# timestamp falls to the left side of the DST transition
if v_left + deltas[pos_left] == val:
result_a[i] = v_left
# TODO: be careful of overflow in val+ppd
isr = bisect_right_i8(tdata, val + ppd, ntrans) - 1
if isr < 0:
isr = 0
v_right = val - deltas[isr]
pos_right = bisect_right_i8(tdata, v_right, ntrans) - 1
# timestamp falls to the right side of the DST transition
if v_right + deltas[pos_right] == val:
result_b[i] = v_right

but computes correct results for the right side, and seems to rely on it, but I need to give it a better look.

I think that wrapping is unintentional and should be changed, but raising an error seems the wrong way to go.

@WillAyd WillAyd Jun 9, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good. Open to refactors; ideally we never have UB as it is not something we can "rely on"

@Alvaro-Kothe Alvaro-Kothe Jun 11, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if it's correct, but with the last modification I changed the behavior on overflow to the following:

  • For the val ± ppd, I assign isl or isr to one of the extremas.
  • for the val ± delta, I assign an status code indicating overflow/underflow. If it happens on both sides, I raise an error. If at least one of the sides remain valid, then the other side will be NPY_NAT and the valid side will be used here:

elif left != NPY_NAT:
result[i] = left
elif right != NPY_NAT:
# TODO: test with non-nano
result[i] = right

BE_OVERFLOW


cdef void raise_out_of_bounds(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think with cdef void you pay a performance penalty because you have to check the global Python error indicator. For high performance code I'd stick to the convention of returning an int where a non-zero value represents an error

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that performance matters here. This is only called when an overflow happens on both sides and the goal is to raise a Python exception.

Comment thread pandas/_libs/tslibs/tzconversion.pyx Outdated

delta_l = deltas[isl]
if delta_l > 0 and val < NPY_NAT + 1 + delta_l:
v_l_err = BE_UNDERFLOW

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the case of UNDERFLOW / OVERFLOW should we just return early?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other side can be valid, so it's not correct to return early.

Comment thread pandas/_libs/tslibs/tzconversion.pyx Outdated
return str(ts)


cdef enum BoundaryError:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
cdef enum BoundaryError:
cdef enum BoundaryStatus:

Semantics nit but I guess BE_OK isn't an error

Comment thread pandas/_libs/tslibs/tzconversion.pyx Outdated
isr = 0

delta_r = deltas[isr]
if delta_r > 0 and val < NPY_NAT + 1 + delta_r:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have to hard-code all of these conditions or can we not just have the built-in overflow detection? The latter would definitely be easier to maintain and in many cases is more performant than hand-rolled checks

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found a little bit convoluted and misleading to use function-like macros in Cython. I am not sure if they provide a built-in check that won't cause a Python exception.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm yea that's kind of challenging to express in Cython. Any chance this macro syntax to accept variadic arguments helps? Seems like we could at the very least call the generic macros we already have in portable.h (?)

cython/cython#3264 (comment)

Comment thread pandas/_libs/groupby.pyx
accum[lab, j] *= val
if int64float_t is int64_t:
# Use uint64_t to avoid UB on signed integer overflow.
accum[lab, j] = <int64_t>(<uint64_t>accum[lab, j] *

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I understand this change - this seems to just mask an issue via a cast (?)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrapping on overflow is intentional in the groupby.

def test_groupby_cumprod_overflow():
# GH#37493 if we overflow we return garbage consistent with numpy
df = DataFrame({"key": ["b"] * 4, "value": 100_000})
actual = df.groupby("key")["value"].cumprod()
expected = Series(
[100_000, 10_000_000_000, 1_000_000_000_000_000, 7766279631452241920],
name="value",
)
tm.assert_series_equal(actual, expected)
numpy_result = df.groupby("key", group_keys=False)["value"].apply(
lambda x: x.cumprod()
)
numpy_result.name = "value"
tm.assert_series_equal(actual, numpy_result)

Comment thread pandas/_libs/lib.pyx Resolved
Comment thread pandas/_libs/lib.pyx
newarr = np.asarray(arr, dtype=object)
for i in range(n):
val = newarr[i]
val = arr[i]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What UB is this avoiding? This seems like it should be a separate change / performance boost to elide a cast to object (?)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

misaligned load

pandas/_libs/lib.cpython-314-x86_64-linux-gnu.so.p/pandas/_libs/lib.pyx.c:30057:30: runtime error: load of misaligned address 0x7c513cf51815 for type 'PyObject *' (aka 'struct _object *'), which requires 8 byte alignment
0x7c513cf51815: note: pointer points here
 00 00 00 00 10 a1 13  3d 71 7b 00 00 00 d8 d2  05 bf d8 d2 05 bf 01 00  00 00 02 00 00 00 00 00  00

@Alvaro-Kothe
Alvaro-Kothe force-pushed the fix/undefined-behavior branch from 45261d6 to 491f6ba Compare June 29, 2026 20:20
@Alvaro-Kothe
Alvaro-Kothe force-pushed the fix/undefined-behavior branch from 976a631 to 166af4c Compare July 11, 2026 23:05
@Alvaro-Kothe
Alvaro-Kothe requested a review from WillAyd July 11, 2026 23:07
@WillAyd
WillAyd merged commit 800872f into pandas-dev:main Jul 13, 2026
49 checks passed
@WillAyd

WillAyd commented Jul 13, 2026

Copy link
Copy Markdown
Member

Thanks @Alvaro-Kothe

Sharl0tteIsTaken added a commit to Sharl0tteIsTaken/pandas that referenced this pull request Aug 5, 2026
…-comparison

* upstream/main: (137 commits)
  DOC: add inline examples for Multiindexing/Levels section (pandas-dev#64867)
  PERF: read_csv integer fast path using fast_float's digit helpers (pandas-dev#66487)
  PERF: single-pass composite-key factorization for multi-key merges (pandas-dev#66124)
  PERF: merge_asof with by (pandas-dev#66121)
  CI: remove pyarrow 24.0 restriction for typing (pandas-dev#66563)
  PERF: read_csv string columns, cutting per-column-chunk GIL work (pandas-dev#66619)
  CI: enable python3.15 job (pandas-dev#66473)
  BUG: read_csv parallel default ignored CPU affinity and cgroup limits (pandas-dev#66613)
  PERF: fix performance regression in string construction from pandas-devGH-65831 (pandas-dev#66351)
  DEPR: Series.dt.freq returning a string instead of DateOffset (pandas-dev#64701)
  DOC: Improve pull request guideline wording (pandas-dev#66526)
  DOC: Fix Series.str.zfill docstring incorrectly stating it differs from str.zfill (pandas-dev#66460)
  BUG: IntervalArray/IntervalIndex only validated left endpoint dtype, allowing dtype.subtype to disagree with right.dtype (pandas-dev#66530)
  PERF: add an Arrow/NumPy hybrid groupby path for decimal and string types. (pandas-dev#63416)
  TST: speed up test_string_fast_path_matches_object_path (pandas-dev#66610)
  CLN: remove unreachable ArrowDtype exclusion in _factorize_keys (pandas-dev#66337)
  BUG: skew/kurt on ArrowDtype returned the biased statistic; kurt raised (pandas-dev#66336)
  PERF: recover read_csv string-column speed after pandas-devGH-66415 (pandas-dev#66426)
  BUG: datetimelike arithmetic landing on the NaT sentinel returned NaT (GH#66549) (pandas-dev#66602)
  CI: Update pixi.lock (pandas-dev#66598)
  ...
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants