Skip to content

fix: match Polars in str.slice for negative offset, raise on negative length - #3969

Merged
FBruzzesi merged 4 commits into
narwhals-dev:mainfrom
ptimizeroracle:fix/str-slice-negative-offsets
Sep 25, 2026
Merged

FBruzzesi merged 4 commits into
narwhals-dev:mainfrom
ptimizeroracle:fix/str-slice-negative-offsets

Conversation

@ptimizeroracle

@ptimizeroracle ptimizeroracle commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Aligns Expr.str.slice / Series.str.slice with Polars when offset is negative, and rejects a negative length on every backend.

When a negative offset reaches before the start of the string, Polars clamps the start to 0 and shortens length by the overhang: slice(-3, 2) on "ab" is "a", slice(-10, 3) on "fdas" is "".

  • pandas-like, PyArrow, Dask: offset + length was used as a Python-slice stop, which wraps around when offset < 0 <= offset + length (slice(-3, 3) on "fdas" gave ""). The shared str_slice_stop helper returns None in that case and keeps a single native call.
  • SQL backends (DuckDB, SQLFrame, PySpark, Ibis): a non-positive substr start is clamped to 1 and the count is shortened by the overhang. DuckDB previously dropped a character (slice(-3) on "ab" gave "a").
  • Polars < 0.20.17: native str.slice didn't shorten length on overhang and miscounted negative offsets on multi-byte strings. Patched by slicing the reversed string.
  • Negative length: Polars raised InvalidOperationError while other backends returned wrong strings. It now raises ValueError up-front on all backends (same validation as str.zfill's width), including stable.v1 / stable.v2.

Checked against native Polars on a grid of offsets and lengths, including nulls, empty strings and strings shorter than |offset|.

@ptimizeroracle

Copy link
Copy Markdown
Contributor Author

test-sdist failed with 'Unexpected packages/ (workspace) directory inside the sdist'. the diff here touches only src and test files, the sdist config is unchanged from main, and the same check passed on my other pr (#3970) built from the same base an hour earlier, so it looks like a packaging/runner flake rather than something introduced here. would a re-run be ok?

@FBruzzesi

Copy link
Copy Markdown
Member

Hey @ptimizeroracle first and foremost, thanks for splitting the PR into two. May I ask why there are changes unrelated to the str.slice functionality? The changes are touching many string namespace methods, namely:str.{starts_with, ends_with, replace, zfill}, which seem completely unrelated to the str.slice issue. That's why you are seeing some many job failures, including the sdist one (from the job logs):

FAILED tests/expr_and_series/str/starts_with_ends_with_test.py::test_empty_pattern[pyarrow-ends_with] - AssertionError: Mismatch at index 0, key match: False != True
Expected: {'match': [True, None, True]}
Got: {'match': [False, None, True]}
FAILED tests/expr_and_series/str/zfill_test.py::test_str_zfill_series[pyarrow-width_0] - pyarrow.lib.ArrowInvalid: Negative buffer resize: -18
FAILED tests/expr_and_series/str/zfill_test.py::test_str_zfill[pyarrow-width_0] - pyarrow.lib.ArrowInvalid: Negative buffer resize: -18

@ptimizeroracle
ptimizeroracle force-pushed the fix/str-slice-negative-offsets branch from af15771 to ac995aa Compare September 21, 2026 08:13
@ptimizeroracle

Copy link
Copy Markdown
Contributor Author

you were right, and thank you for the precise diagnosis. the cause: when splitting the pr i checked out whole files from the older combined branch, which silently reverted main's newer starts_with/ends_with, replace and zfill changes living in the same files. fixed in ac995aa: the backend files are main's versions again with only the slice methods changed, verified by diffing against main (only slice hunks remain). the zfill and starts_with failures disappear locally, full suite 15777 passed across nine constructors. the sdist job's packages/ leak should also follow, as it was building from the reverted tree

- pandas, pyarrow and dask computed stop = offset + length, which lands
  before the end-relative start for a negative offset: slice(-3, 3) on
  "fdas" returned an empty string instead of "das". all three now slice
  from offset and head-slice the result, matching polars
- the shared SQL backend computed a non-positive substring start for
  strings shorter than |offset|, and DuckDB silently drops a character
  there: slice(-3) on "ab" returned "a". the start is clamped to 1 via
  greatest
- a negative length was inconsistent: polars raised
  InvalidOperationError while the other backends silently returned empty
  or wrong strings. the stable Expr and Series accessors now validate it
  up-front with validate_str_non_negative, like str.zfill's width

split out of narwhals-dev#3955 per review; the float->int cast fixes follow in a
separate pr

Signed-off-by: ptimizeroracle <contact@binblok.com>
Signed-off-by: ptimizeroracle <contact@binblok.com>
@ptimizeroracle
ptimizeroracle force-pushed the fix/str-slice-negative-offsets branch from ac995aa to bfbb87b Compare September 21, 2026 20:49

@FBruzzesi FBruzzesi left a comment

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.

Hey @ptimizeroracle thanks for splitting this out and for the clean-up after the last round, the diff is now nicely scoped to str.slice.

Rejecting a negative length up-front is a good call. Polars already raises there, and it reuses the zfill validation pattern. The DuckDB non-positive substr start is a real bug too.

I checked each backend against native Polars and found one more thing to handle before merging. When a negative offset reaches before the start of the string, Polars doesn't just clamp the start to 0. It also shortens length by the overhang:

import polars as pl
pl.Series(["ab"]).str.slice(-3, 2).to_list()  # starts 1 char before "a" -> keeps 2 - 1 = 1 char
['a']
pl.Series(["fdas"]).str.slice(-10, 3).to_list()  # overhang 6 >= length 3
['']

The two-step approach (slice from offset, then take the first length characters) and the SQL greatest(1, ...) clamp keep the full length, so these cases now differ from Polars:

call input Polars this PR (pandas, pyarrow, dask) this PR (duckdb, sqlframe, ibis) main (pandas, pyarrow, dask)
slice(-3, 2) "ab" "a" "ab" "ab" "a"
slice(-3, 1) "ab" "" "a" "a" ""
slice(-5, 2) "fdas" "f" "fd" "fd" "f"
slice(-10, 3) "fdas" "" "fda" "fda" ""

The last column matters most. On main, pandas, pyarrow and dask already got these right, because a negative Python stop (offset + length < 0) clamps the same way Polars does. The original bug only shows up when offset < 0 <= offset + length, where stop wraps around past zero. So for those three backends the smallest fix is to keep the single native call and set stop = None in that one case:

def str_slice_stop(offset: int, length: int | None) -> int | None:
    """Translate Polars' `str.slice(offset, length)` into a Python-slice `stop`."""
    if length is None or offset < 0 <= offset + length:
        return None
    return offset + length

Putting it in narwhals/_utils.py lets pandas-like, pyarrow and dask share it instead of each carrying its own copy of the logic. The inline suggestions below assume that helper exists.

On a grid of 10 offsets x 6 lengths over ["abcde", "ab", "", None, "héllo"], this plus the SQL change suggested below gives 0 mismatches against native Polars. The branch as it stands has 11 mismatches per backend. The current tests all use length >= abs(offset), which is why CI didn't catch it; I've suggested extra cases on the test file.

Comment thread src/narwhals/_pandas_like/series_str.py Resolved
Comment thread src/narwhals/_arrow/series_str.py Resolved
Comment thread src/narwhals/_dask/expr_str.py Resolved
Comment thread src/narwhals/_sql/expr_str.py Resolved
Comment thread tests/expr_and_series/str/slice_test.py Resolved
Comment thread tests/expr_and_series/str/slice_test.py Resolved
replace the two-step slice with a shared str_slice_stop helper: when a
negative offset overhangs the string start, a negative python stop
clamps exactly like polars shortens the length, and the offset < 0 <=
offset+length window returns None instead of a nonsense forward index.
sql backends shorten the substr count by the same overhang. tests gain
the overhang cases from the review grid.
@ptimizeroracle

Copy link
Copy Markdown
Contributor Author

applied all suggestions, thanks for the thorough review and for catching the overhang cases — the grid made it easy to verify.

  • str_slice_stop added to narwhals/_utils.py, and pandas-like, pyarrow and dask now use a single native call with it (the nested function and the pyright ignore are gone)
  • sql backends rewritten with the early return for offset >= 0, the greatest(1, ...) start clamp and the least(0, start_index) overhang shortening
  • tests: overhang cases added, renamed to test_str_slice_negative_offset, annotations are dict[str, list[str]] and the per-case comments dropped

i also ran the parity grid locally, slightly wider than yours (offsets -10..10, lengths None,0,1,2,3,5,10, over ["abcde","ab","","héllo"]): 147 cases per backend, 0 mismatches against native polars on pandas, pandas[pyarrow], pyarrow, dask, duckdb and sqlframe

@FBruzzesi FBruzzesi changed the title fix: make str.slice with negative offsets and negative length consistent fix: match Polars in str.slice for negative offset, raise on negative length Sep 24, 2026
@FBruzzesi FBruzzesi added the fix label Sep 24, 2026
@ptimizeroracle

Copy link
Copy Markdown
Contributor Author

pulled your commit locally, all nine constructors green on the slice tests, ci fully green too. good catch on pre-0.20.17 polars having the same overhang bug — the reverse-slice workaround is a neat trick, and it means narwhals now returns consistent results even where the reference backend itself was wrong

@FBruzzesi FBruzzesi left a comment

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.

Thanks for bearing with me @ptimizeroracle

@FBruzzesi
FBruzzesi merged commit 8daca4b into narwhals-dev:main Sep 25, 2026
75 of 78 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants