Repository: narwhals-dev/narwhals
Pull request #2846 (2025-07-18 10:40:42)

perf, fix: avoid using `assign` for `Series.rank` in pandas, fix `Series.rank` for nameless pandas Series


<!--
# Thanks for contributing a pull request! 
## Please make sure you see our contribution guidelines: https://github.com/narwhals-dev/narwhals/blob/main/CONTRIBUTING.md
-->

## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [ ] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues

- Related issue #\<issue number\>
- Closes #\<issue number\>

## Checklist

- [ ] Code follows style guide (ruff)
- [ ] Tests added
- [ ] Documented the changes

## If you have comments or can explain your changes, please do so below



Patch:
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index cba03ed9..1862d956 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -914,28 +914,36 @@ class PandasLikeSeries(EagerSeries[Any]):
         if (
             self._implementation is Implementation.PANDAS
             and self._backend_version < (3,)
+            and get_dtype_backend(self.native.dtype, self._implementation)
+            == "numpy_nullable"
             and self.dtype.is_integer()
-            and (null_mask := self.native.isna()).any()
+            and (null_mask := self.is_null()).any()
         ):
             # crazy workaround for the case of `na_option="keep"` and nullable
             # integer dtypes. This should be supported in pandas > 3.0
             # https://github.com/pandas-dev/pandas/issues/56976
-            ranked_series = (
-                self.native.to_frame()
-                .assign(**{f"{name}_is_null": null_mask})
-                .groupby(f"{name}_is_null")
+            mask_name = f"{name}_is_null"
+            plx = self.__narwhals_namespace__()
+            df = (
+                self.to_frame()
+                .with_columns(plx._expr._from_series(null_mask).alias(mask_name))
+                .native
+            )
+            return self._with_native(
+                df.groupby(mask_name)
                 .rank(
                     method=pd_method,
                     na_option="keep",
                     ascending=not descending,
                     pct=False,
-                )[name]
-            )
-        else:
-            ranked_series = self.native.rank(
+                )
+                .iloc[:, 0]
+            ).alias(self.name)
+        return self._with_native(
+            self.native.rank(
                 method=pd_method, na_option="keep", ascending=not descending, pct=False
             )
-        return self._with_native(ranked_series)
+        )
 
     def hist(  # noqa: C901, PLR0912
         self,


Test patch:
diff --git a/tests/expr_and_series/rank_test.py b/tests/expr_and_series/rank_test.py
index 3e259e4f..1d4e9f1c 100644
--- a/tests/expr_and_series/rank_test.py
+++ b/tests/expr_and_series/rank_test.py
@@ -121,6 +121,17 @@ def test_rank_series(
         assert_equal_data(result, expected_data)
 
 
+@pytest.mark.skipif(PANDAS_VERSION < (2, 1), reason="too old for nullable")
+def test_rank_series_pandas_namesless() -> None:
+    pytest.importorskip("pandas")
+    import pandas as pd
+
+    s = nw.from_native(pd.Series([1, 2, 3, None], dtype="Int64"), series_only=True)
+    result = s.rank(method="min")
+    assert result.name is None
+    assert_equal_data({"a": result}, {"a": [1, 2, 3, None]})
+
+
 @pytest.mark.parametrize("method", rank_methods)
 def test_rank_expr_in_over_context(
     request: pytest.FixtureRequest,
@@ -177,10 +188,10 @@ def test_lazy_rank_expr(
         and PANDAS_VERSION < (2, 1)
         and isinstance(data["a"][0], int)
     ):
-        request.applymarker(pytest.mark.xfail)
+        pytest.skip()
 
     if "duckdb" in str(constructor) and DUCKDB_VERSION < (1, 3):
-        request.applymarker(pytest.mark.xfail)
+        pytest.skip()
 
     if "dask" in str(constructor):
         # `rank` is not implemented in Dask
@@ -216,10 +227,10 @@ def test_lazy_rank_expr_desc(
         and PANDAS_VERSION < (2, 1)
         and isinstance(data["a"][0], int)
     ):
-        request.applymarker(pytest.mark.xfail)
+        pytest.skip()
 
     if "duckdb" in str(constructor) and DUCKDB_VERSION < (1, 3):
-        request.applymarker(pytest.mark.xfail)
+        pytest.skip()
 
     if "dask" in str(constructor):
         # `rank` is not implemented in Dask