Skip to content

fix(str): reject a negative width or length in zfill, pad_start and pad_end - #3770

Open
mkzung wants to merge 7 commits into
narwhals-dev:mainfrom
mkzung:fix/zfill-nonpositive-width
Open

fix(str): reject a negative width or length in zfill, pad_start and pad_end#3770
mkzung wants to merge 7 commits into
narwhals-dev:mainfrom
mkzung:fix/zfill-nonpositive-width

Conversation

@mkzung

@mkzung mkzung commented Jul 10, 2026

Copy link
Copy Markdown

Description

A negative width or length reached the backends and each one did something
different with it. pandas returned the string unchanged, Polars raised
InvalidOperationError: conversion from 'i128' to 'u64' failed ... [-1], and
PyArrow raised ArrowInvalid: Negative buffer resize: -21. pad_start and
pad_end do the same, since the argument reaches the same backend calls.

This started as a PyArrow-only fix for zfill. @camriddell raised the question of
whether the check belonged further up in expr_str.py instead, so it raises early
and every backend reports the same thing. That is what it now does. The PyArrow
backend keeps only its width == 0 short-circuit, which is an evaluation-order
problem rather than an argument one.

The check is in both expr_str.py and series_str.py because Series.str.zfill
calls the compliant series directly rather than going through an expression, so
expr_str.py alone would not cover it. Removing either set turns tests red.

pc.case_when is why PyArrow crashed rather than returned: it evaluates every
branch eagerly, so pc.utf8_lpad(remaining_chars, width - 1) ran with a negative
pad length before any row was selected.

The tests pin the message and not just the exception type, which matters here more
than it usually would. Polars already raised InvalidOperationError for this, just
with its internal cast message, so without the message pinned the Polars case
passes against unfixed code. InvalidOperationError is also what the library
raises elsewhere for an argument rejected before anything is computed.

Tests run on every backend through the existing fixtures. The expression cases use
constructor rather than constructor_eager, so the lazy backends are covered as
well, where the check raises at select() rather than at collect().

Reproduction, before this change:

import narwhals as nw, pyarrow as pa
s = nw.from_native(pa.table({"a": ["1", "-5", "abc"]}), eager_only=True)["a"]
s.str.zfill(0)   # ArrowInvalid: Negative buffer resize: -9
s.str.zfill(-1)  # ArrowInvalid: Negative buffer resize: -21

What type of PR is this? (check all applicable)

  • 💾 Refactor
  • ✨ Feature
  • 🐛 Bug Fix
  • 🔧 Optimization
  • 📝 Documentation
  • ✅ Test
  • 🐳 Other

Related issues

  • No existing issue; found while exercising the string namespace on the PyArrow backend.

AI assistance

  • No AI tools were used for this PR.
  • AI tools were used.

I used an AI coding assistant to help locate the root cause and draft the regression
test. I have read, run, and verified every line, and I take full responsibility for
the change.

Checklist

  • Code follows style guide (ruff)
  • Tests added
  • Documented the changes (no doc change needed; behaviour matches the documented str.zfill)

@MarcoGorelli

Copy link
Copy Markdown
Member

thanks @mkzung !

If you would rather the Arrow backend raise on a negative width to match Polars
instead of returning unchanged, I am happy to switch.

i think i'd prefer that, could we do that instead please? thanks 🙏

@mkzung
mkzung force-pushed the fix/zfill-nonpositive-width branch from ce7c603 to 0363a10 Compare July 10, 2026 21:30
@mkzung mkzung changed the title fix(pyarrow): don't crash on str.zfill with non-positive width fix(pyarrow): handle non-positive width in str.zfill Jul 10, 2026
@mkzung

mkzung commented Jul 10, 2026

Copy link
Copy Markdown
Author

Done. The Arrow backend now raises InvalidOperationError on a negative width, matching Polars, instead of returning unchanged, and width 0 stays a no-op. I also moved the regression tests to pyarrow only, since Polars' own zfill(0) raises on the older CI versions.

Comment thread src/narwhals/_arrow/series_str.py Outdated
# Match that here rather than letting pyarrow's utf8_lpad(width - 1)
# crash later with a negative pad width.
msg = f"zfill does not support a negative width, got {width}."
raise InvalidOperationError(msg)

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.

@MarcoGorelli should we push this check up to narwhals/expr_str.py. If we do that then

  1. The error can be raised as early as possible for all backends
  2. All implementations will have a uniform error message

If not, then I'm happy with the implementation here.

@mkzung

mkzung commented Jul 14, 2026

Copy link
Copy Markdown
Author

Worth adding that the inconsistency is wider than zfill. With a negative width today, pandas returns the string unchanged, Polars raises conversion from 'i128' to 'u64' failed, and pyarrow crashes inside utf8_lpad. pad_start and pad_end do the same thing - on pyarrow they leak ArrowInvalid: Negative buffer resize.

So moving the check up to expr_str/series_str fixes three methods at once and gives every backend the same error. I have it on a branch: validation in Expr.str and Series.str, the Arrow negative-width raise removed (the width == 0 no-op stays, that one is a genuine pyarrow quirk), tests parametrised across backends, full suite green.

mkzung/narwhals@main...mkzung:narwhals:fix/zfill-validate-in-public-layer

Happy to fold it into this PR, or to leave it until you and @MarcoGorelli have settled where the check belongs.

@mkzung mkzung changed the title fix(pyarrow): handle non-positive width in str.zfill fix(str): reject a negative width or length in zfill, pad_start and pad_end Jul 20, 2026
@mkzung

mkzung commented Jul 21, 2026

Copy link
Copy Markdown
Author

Moved it up to expr_str.py, so every backend now raises the same message before
anything reaches a compliant object.

I kept the guards in series_str.py as well. Series.str.zfill calls the compliant
series directly rather than building an expression, so the expression layer alone
does not cover that path. With the series-side checks removed, 8 tests in
tests/expr_and_series/str fail.

The _arrow change is still in there for the case Marco raised earlier: a negative
width returned the string unchanged on that backend instead of raising.

@camriddell
camriddell requested a review from FBruzzesi July 23, 2026 20:49
Comment thread src/narwhals/_arrow/series_str.py
Comment thread tests/expr_and_series/str/zfill_test.py Outdated
Comment on lines +69 to +75
def test_str_zfill_zero_width_pyarrow() -> None:
# A zero width is a no-op (every string is already at least 0 long). This used
# to crash on the pyarrow backend with `ArrowInvalid: Negative buffer resize`,
# because pc.case_when eagerly evaluated the utf8_lpad(width - 1) = -1 branch.
pytest.importorskip("pyarrow")
result = nw.from_dict(data, backend="pyarrow")["a"].str.zfill(0)
assert_equal_data({"a": result}, data)

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.

shall we parametrise over all constructors, to make sure none of the others crash?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The zero-width test runs over every constructor now (plus a series version) - nothing crashes, only the pandas+pyarrow xfail.

Comment thread src/narwhals/series_str.py Outdated
Comment on lines +492 to +494
if width < 0:
msg = f"`width` must be non-negative but got {width}"
raise InvalidOperationError(msg)

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 is repeated a few times, could/should it be a util function? like validate_width

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Pulled the check into a validate_width helper in _utils.py, used by zfill and both pad methods.

mkzung added 6 commits August 3, 2026 18:34
Moves the check camriddell suggested up from the Arrow backend into Expr.str.zfill
and Series.str.zfill, so every backend fails the same way and fails early. Before
this, zfill(-1) returned the string unchanged on pandas, raised an internal
'conversion from i128 to u64 failed' on Polars, and crashed inside utf8_lpad on
pyarrow. The Arrow width == 0 short-circuit stays: that one is a real backend quirk.
Same bug as zfill, same place. On pyarrow, pad_start(-1) and pad_end(-1) leak
'ArrowInvalid: Negative buffer resize' straight through narwhals; pandas returns the
string unchanged; Polars raises an internal i128-to-u64 conversion error. Validating in
the public layer gives all three the same error, as it now does for zfill.
`is_close` already raises "`abs_tol` must be non-negative but got ...", so use
the same phrasing here rather than a second format for the same kind of check.
Tests now pin the message the way the is_close ones do, and pad_start/pad_end
get the expression-path case that zfill already had.
The zfill and pad comments narrated the same three backend behaviours twice over.
Kept it once and used the space for the part that is not obvious from reading the
test: Polars already raised InvalidOperationError here, so pinning only the type
would pass against unfixed code.
One validate_width helper instead of the repeated width < 0 block across
zfill/pad_start/pad_end, and the zero-width no-op now runs over every constructor
rather than only pyarrow. Also drops a stale zfill docstring line that still said
a negative width is a no-op.
@mkzung
mkzung force-pushed the fix/zfill-nonpositive-width branch from a1282fe to c0103c1 Compare August 3, 2026 13:37
@camriddell

Copy link
Copy Markdown
Member

@mkzung looks like the above failures are from a behavior alignment correction we implement for older Polars versions.

.str.zfill(width - 1)

I think if we replace the linked line .str.zfill(width - 1) with .str.zfill(max(width - 1, 0)) we should be looking good on the tests and wrap this one up.

zfill(0) is a no-op, but on Polars <= 1.30.0 it went through the branch
that re-pads a leading "+", and that branch calls zfill(width - 1). At
width zero that is zfill(-1), which fails the cast to u64 even though the
predicate guarding the branch is false: Polars builds the whole
expression before choosing. max(width - 1, 0) keeps it valid, and the
public layer already rejects a negative width, so zero is the only value
that reaches it.

Local polars is 1.42, where the branch never runs, so the suite is green
either way. Checked against a real 1.30.0 instead: the three failing
tests are the three CI reports, expression eager and lazy plus the eager
series, and they pass with the fix. The rest of the suite is unchanged,
186 in tests/expr_and_series/str and 4701 overall.
@mkzung

mkzung commented Aug 4, 2026

Copy link
Copy Markdown
Author

Done, pushed.

The reason it fires with the predicate false is that Polars builds the then arm before choosing, so width 0 reaches zfill(-1) either way. Checked against a real 1.30.0 rather than my local 1.42, where the branch never runs: all three failures reproduce there and clear with the change.

One thing I ran into while checking, separate from this PR and already on main: that branch disagrees with newer Polars when the remainder is multibyte or holds a second plus. "+é" at width 4 comes out "++0é" against "+0é", and "++7" comes out "++07" against "+0+7". It mixes units, since zfill pads to bytes and pad_start to characters. Happy to open a follow-up if you want it.

@camriddell

Copy link
Copy Markdown
Member

One thing I ran into while checking, separate from this PR and already on main: that branch disagrees with newer Polars when the remainder is multibyte or holds a second plus. "+é" at width 4 comes out "++0é" against "+0é", and "++7" comes out "++07" against "+0+7". It mixes units, since zfill pads to bytes and pad_start to characters. Happy to open a follow-up if you want it.

I cannot replicate this. Can you re-confirm and if this issue persists then create an issue following the issue template (with an MRE).

@mkzung

mkzung commented Aug 4, 2026

Copy link
Copy Markdown
Author

You are right not to be able to replicate it, and one half of what I wrote was wrong.

It only happens on Polars <= 1.30.0, which is the only case that enters that branch at all. I should have said so. I have now checked it on a real 1.30.0 rather than by pinning the reported backend version, and the "++7" half does not hold: it comes out "+0+7" on both, matching newer Polars. Sorry for the noise on that one.

What does hold is multibyte content after a leading +. Same narwhals commit, same input, two Polars:

polars 1.42.1   "+é".zfill(4) -> "+0é"
polars 1.30.0   "+é".zfill(4) -> "++0é"

Filed as #3838 with the MRE and the version matrix. The cause is that the branch mixes units, zfill padding to bytes and pad_start to characters, so on ASCII they agree and on multibyte one + too many is prepended.

The two red checks here are Install uv failing to fetch on the lightgbm job and the downstream scikit-lego job, which has been red on main since April.

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