Repository: narwhals-dev/narwhals
Pull request #1839 (2025-01-20 10:32:58)

feat: deprecate `maintain_order` in `Expr.unique` and `LazyFrame.tail` (but keep around in `stable.v1`)
<!--
# 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/_arrow/expr.py b/narwhals/_arrow/expr.py
index 8ab13a75..ccc0e806 100644
--- a/narwhals/_arrow/expr.py
+++ b/narwhals/_arrow/expr.py
@@ -352,8 +352,8 @@ class ArrowExpr(CompliantExpr[ArrowSeries]):
     def is_last_distinct(self: Self) -> Self:
         return reuse_series_implementation(self, "is_last_distinct")
 
-    def unique(self: Self, *, maintain_order: bool) -> Self:
-        return reuse_series_implementation(self, "unique", maintain_order=maintain_order)
+    def unique(self: Self) -> Self:
+        return reuse_series_implementation(self, "unique", maintain_order=False)
 
     def replace_strict(
         self: Self, old: Sequence[Any], new: Sequence[Any], *, return_dtype: DType | None
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index 00822a8c..617e88e7 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -683,9 +683,7 @@ class ArrowSeries(CompliantSeries):
         return maybe_extract_py_scalar(result, return_py_scalar=True)  # type: ignore[no-any-return]
 
     def unique(self: Self, *, maintain_order: bool) -> ArrowSeries:
-        # The param `maintain_order` is only here for compatibility with the Polars API
-        # and has no effect on the output.
-
+        # TODO(marco): `pc.unique` seems to always maintain order, is that guaranteed?
         return self._from_native_series(pc.unique(self._native_series))
 
     def replace_strict(
diff --git a/narwhals/_dask/dataframe.py b/narwhals/_dask/dataframe.py
index d493a963..f1ec3b6e 100644
--- a/narwhals/_dask/dataframe.py
+++ b/narwhals/_dask/dataframe.py
@@ -372,11 +372,11 @@ class DaskLazyFrame(CompliantLazyFrame):
 
         return DaskLazyGroupBy(self, list(by), drop_null_keys=drop_null_keys)
 
-    def tail(self: Self, n: int) -> Self:
+    def tail(self: Self, n: int) -> Self:  # pragma: no cover
         native_frame = self._native_frame
         n_partitions = native_frame.npartitions
 
-        if n_partitions == 1:  # pragma: no cover
+        if n_partitions == 1:
             return self._from_native_frame(self._native_frame.tail(n=n, compute=False))
         else:
             msg = "`LazyFrame.tail` is not supported for Dask backend with multiple partitions."
diff --git a/narwhals/_dask/expr.py b/narwhals/_dask/expr.py
index 1319f100..7f2fdb49 100644
--- a/narwhals/_dask/expr.py
+++ b/narwhals/_dask/expr.py
@@ -447,8 +447,7 @@ class DaskExpr(CompliantExpr["dx.Series"]):
             returns_scalar=self._returns_scalar,
         )
 
-    def unique(self, *, maintain_order: bool) -> Self:
-        # TODO(marco): maintain_order has no effect and will be deprecated
+    def unique(self) -> Self:
         return self._from_call(
             lambda _input: _input.unique(),
             "unique",
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index 6eceaaac..cee3ff32 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -351,8 +351,8 @@ class PandasLikeExpr(CompliantExpr[PandasLikeSeries]):
     def cum_sum(self: Self, *, reverse: bool) -> Self:
         return reuse_series_implementation(self, "cum_sum", reverse=reverse)
 
-    def unique(self, *, maintain_order: bool = False) -> Self:
-        return reuse_series_implementation(self, "unique", maintain_order=maintain_order)
+    def unique(self) -> Self:
+        return reuse_series_implementation(self, "unique", maintain_order=False)
 
     def diff(self) -> Self:
         return reuse_series_implementation(self, "diff")
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index 95a4eae8..71969444 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -735,9 +735,9 @@ class PandasLikeSeries(CompliantSeries):
         )
         return self._from_native_series(result)
 
-    def unique(self, *, maintain_order: bool = False) -> PandasLikeSeries:
-        # The param `maintain_order` is only here for compatibility with the Polars API
-        # and has no effect on the output.
+    def unique(self, *, maintain_order: bool) -> PandasLikeSeries:
+        # pandas always maintains order, as per its docstring:
+        # "Uniques are returned in order of appearance"  # noqa: ERA001
         return self._from_native_series(
             self._native_series.__class__(
                 self._native_series.unique(), name=self._native_series.name
@@ -779,7 +779,7 @@ class PandasLikeSeries(CompliantSeries):
         if result.is_null().sum() != self.is_null().sum():
             msg = (
                 "replace_strict did not replace all non-null values.\n\n"
-                f"The following did not get replaced: {self.filter(~self.is_null() & result.is_null()).unique().to_list()}"
+                f"The following did not get replaced: {self.filter(~self.is_null() & result.is_null()).unique(maintain_order=False).to_list()}"
             )
             raise ValueError(msg)
         return result
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index 1633a3c2..385a5de0 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -4312,7 +4312,7 @@ class LazyFrame(BaseFrame[FrameT]):
         return super().rename(mapping)
 
     def head(self, n: int = 5) -> Self:
-        r"""Get the first `n` rows.
+        r"""Get `n` rows.
 
         Arguments:
             n: Number of rows to return.
@@ -4360,56 +4360,19 @@ class LazyFrame(BaseFrame[FrameT]):
         """
         return super().head(n)
 
-    def tail(self, n: int = 5) -> Self:
+    def tail(self, n: int = 5) -> Self:  # pragma: no cover
         r"""Get the last `n` rows.
 
+        !!! warning
+            `LazyFrame.tail` is deprecated and will be removed in a future version.
+            Note: this will remain available in `narwhals.stable.v1`.
+            See [stable api](../backcompat.md/) for more information.
+
         Arguments:
             n: Number of rows to return.
 
         Returns:
             A subset of the LazyFrame of shape (n, n_columns).
-
-        Notes:
-            `LazyFrame.tail` is not supported for the Dask backend with multiple
-            partitions.
-
-        Examples:
-            >>> import narwhals as nw
-            >>> import polars as pl
-            >>> import dask.dataframe as dd
-            >>> from narwhals.typing import IntoFrameT
-            >>>
-            >>> data = {
-            ...     "a": [1, 2, 3, 4, 5, 6],
-            ...     "b": [7, 8, 9, 10, 11, 12],
-            ... }
-            >>> lf_pl = pl.LazyFrame(data)
-            >>> lf_dask = dd.from_dict(data, npartitions=1)
-
-            Let's define a dataframe-agnostic function that gets the last 3 rows.
-
-            >>> def agnostic_tail(df_native: IntoFrameT) -> IntoFrameT:
-            ...     df = nw.from_native(df_native)
-            ...     return df.tail(3).collect().to_native()
-
-            We can then pass any supported library such as Polars or Dask to `agnostic_tail`:
-
-            >>> agnostic_tail(lf_pl)
-            shape: (3, 2)
-            ┌─────┬─────┐
-            │ a   ┆ b   │
-            │ --- ┆ --- │
-            │ i64 ┆ i64 │
-            ╞═════╪═════╡
-            │ 4   ┆ 10  │
-            │ 5   ┆ 11  │
-            │ 6   ┆ 12  │
-            └─────┴─────┘
-            >>> agnostic_tail(lf_dask)
-               a   b
-            3  4  10
-            4  5  11
-            5  6  12
         """
         return super().tail(n)
 
diff --git a/narwhals/expr.py b/narwhals/expr.py
index ccb33ead..ea64a769 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -1503,14 +1503,9 @@ class Expr:
             aggregates=True,
         )
 
-    def unique(self, *, maintain_order: bool = False) -> Self:
+    def unique(self) -> Self:
         """Return unique values of this expression.
 
-        Arguments:
-            maintain_order: Keep the same order as the original expression. This may be more
-                expensive to compute. Settings this to `True` blocks the possibility
-                to run on the streaming engine for Polars.
-
         Returns:
             A new expression.
 
@@ -1530,27 +1525,23 @@ class Expr:
 
             >>> def agnostic_unique(df_native: IntoFrameT) -> IntoFrameT:
             ...     df = nw.from_native(df_native)
-            ...     return df.select(nw.col("a", "b").unique(maintain_order=True)).to_native()
+            ...     return df.select(nw.col("a", "b").unique().sum()).to_native()
 
             We can then pass any supported library such as pandas, Polars, or
             PyArrow to `agnostic_unique`:
 
             >>> agnostic_unique(df_pd)
-               a  b
-            0  1  2
-            1  3  4
-            2  5  6
+               a   b
+            0  9  12
 
             >>> agnostic_unique(df_pl)
-            shape: (3, 2)
+            shape: (1, 2)
             ┌─────┬─────┐
             │ a   ┆ b   │
             │ --- ┆ --- │
             │ i64 ┆ i64 │
             ╞═════╪═════╡
-            │ 1   ┆ 2   │
-            │ 3   ┆ 4   │
-            │ 5   ┆ 6   │
+            │ 9   ┆ 12  │
             └─────┴─────┘
 
             >>> agnostic_unique(df_pa)
@@ -1558,13 +1549,11 @@ class Expr:
             a: int64
             b: int64
             ----
-            a: [[1,3,5]]
-            b: [[2,4,6]]
+            a: [[9]]
+            b: [[12]]
         """
         return self.__class__(
-            lambda plx: self._to_compliant_expr(plx).unique(
-                maintain_order=maintain_order
-            ),
+            lambda plx: self._to_compliant_expr(plx).unique(),
             self._is_order_dependent,
             changes_length=True,
             aggregates=self._aggregates,
diff --git a/narwhals/stable/v1/__init__.py b/narwhals/stable/v1/__init__.py
index 6e02b08f..c2f2e7b6 100644
--- a/narwhals/stable/v1/__init__.py
+++ b/narwhals/stable/v1/__init__.py
@@ -69,6 +69,7 @@ from narwhals.typing import IntoFrameT
 from narwhals.typing import IntoSeriesT
 from narwhals.utils import Implementation
 from narwhals.utils import Version
+from narwhals.utils import find_stacklevel
 from narwhals.utils import generate_temporary_column_name
 from narwhals.utils import is_ordered_categorical
 from narwhals.utils import maybe_align_index
@@ -280,6 +281,17 @@ class LazyFrame(NwLazyFrame[IntoFrameT]):
         """
         return self.select(all()._l1_norm())
 
+    def tail(self, n: int = 5) -> Self:  # pragma: no cover
+        r"""Get the last `n` rows.
+
+        Arguments:
+            n: Number of rows to return.
+
+        Returns:
+            A subset of the LazyFrame of shape (n, n_columns).
+        """
+        return super().tail(n)
+
 
 class Series(NwSeries[Any]):
     """Narwhals Series, backed by a native series.
@@ -911,6 +923,30 @@ class Expr(NwExpr):
             aggregates=self._aggregates,
         )
 
+    def unique(self, *, maintain_order: bool | None = None) -> Self:
+        """Return unique values of this expression.
+
+        Arguments:
+            maintain_order: Keep the same order as the original expression.
+                This is deprecated and will be removed in a future version,
+                but will still be kept around in `narwhals.stable.v1`.
+
+        Returns:
+            A new expression.
+        """
+        if maintain_order is not None:
+            msg = (
+                "`maintain_order` has no effect and is only kept around for backwards-compatibility. "
+                "You can safely remove this argument."
+            )
+            warn(message=msg, category=UserWarning, stacklevel=find_stacklevel())
+        return self.__class__(
+            lambda plx: self._to_compliant_expr(plx).unique(),
+            self._is_order_dependent,
+            changes_length=True,
+            aggregates=self._aggregates,
+        )
+
     def sort(self, *, descending: bool = False, nulls_last: bool = False) -> Self:
         """Sort this column. Place null values first.
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index eeb1f1a8..857af06f 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -205,7 +205,9 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
             *iter(LAZY_CONSTRUCTORS.keys()),
         ]
         selected_constructors = [
-            x for x in selected_constructors if x not in GPU_CONSTRUCTORS
+            x
+            for x in selected_constructors
+            if x not in GPU_CONSTRUCTORS and x not in "modin"  # too slow
         ]
     else:  # pragma: no cover
         selected_constructors = metafunc.config.getoption("constructors").split(",")
@@ -242,7 +244,7 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
         if (
             any(
                 x in str(metafunc.module)
-                for x in ("list", "unpivot", "from_dict", "from_numpy", "tail")
+                for x in ("list", "unpivot", "from_dict", "from_numpy")
             )
             and LAZY_CONSTRUCTORS["duckdb"] in constructors
         ):
diff --git a/tests/expr_and_series/unique_test.py b/tests/expr_and_series/unique_test.py
index b2877c64..3c893d99 100644
--- a/tests/expr_and_series/unique_test.py
+++ b/tests/expr_and_series/unique_test.py
@@ -5,6 +5,7 @@ from contextlib import nullcontext as does_not_raise
 import pytest
 
 import narwhals as nw
+import narwhals.stable.v1 as nw_v1
 from narwhals.exceptions import LengthChangingExprError
 from tests.utils import Constructor
 from tests.utils import ConstructorEager
@@ -49,3 +50,10 @@ def test_unique_series(constructor_eager: ConstructorEager) -> None:
     result = series.unique(maintain_order=True)
     expected = {"a": ["x", "y"]}
     assert_equal_data({"a": result}, expected)
+
+    series = nw.from_native(constructor_eager(data), eager_only=True)["a"]
+    # this shouldn't warn
+    series.to_frame().select(nw_v1.col("a").unique().sum())
+    with pytest.warns(UserWarning):
+        # this warns that maintain_order has no effect
+        series.to_frame().select(nw_v1.col("a").unique(maintain_order=False).sum())
diff --git a/tests/frame/tail_test.py b/tests/frame/tail_test.py
index c261477f..21eebf15 100644
--- a/tests/frame/tail_test.py
+++ b/tests/frame/tail_test.py
@@ -1,42 +1,16 @@
 from __future__ import annotations
 
-from contextlib import nullcontext as does_not_raise
-
-import pytest
-
 import narwhals.stable.v1 as nw
-from tests.utils import Constructor
+from tests.utils import ConstructorEager
 from tests.utils import assert_equal_data
 
 
-def test_tail(request: pytest.FixtureRequest, constructor: Constructor) -> None:
-    if "pyspark" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
-
+def test_tail(constructor_eager: ConstructorEager) -> None:
     data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8.0, 9.0]}
     expected = {"a": [3, 2], "b": [4, 6], "z": [8.0, 9]}
-
-    df_raw = constructor(data)
-    df = nw.from_native(df_raw).lazy()
-
-    context = (
-        pytest.raises(
-            NotImplementedError,
-            match="`LazyFrame.tail` is not supported for Dask backend with multiple partitions.",
-        )
-        if "dask_lazy_p2" in str(constructor)
-        else does_not_raise()
-    )
-
-    with context:
-        result = df.tail(2)
-        assert_equal_data(result, expected)
-
-        result = df.collect().tail(2)  # type: ignore[assignment]
-        assert_equal_data(result, expected)
-
-        result = df.collect().tail(-1)  # type: ignore[assignment]
-        assert_equal_data(result, expected)
-
-        result = df.collect().select(nw.col("a").tail(2))  # type: ignore[assignment]
-        assert_equal_data(result, {"a": expected["a"]})
+    df_raw = constructor_eager(data)
+    df = nw.from_native(df_raw)
+    result = df.tail(2)
+    assert_equal_data(result, expected)
+    result = df.tail(-1)
+    assert_equal_data(result, expected)
diff --git a/tests/stable_api_test.py b/tests/stable_api_test.py
index cac0b098..c1c3f210 100644
--- a/tests/stable_api_test.py
+++ b/tests/stable_api_test.py
@@ -133,6 +133,9 @@ def test_lazyframe_docstrings() -> None:
         if item in ("schema", "columns"):
             # to avoid performance warning
             continue
+        if item in ("tail",):
+            # deprecated
+            continue
         assert remove_docstring_examples(
             getattr(stable_df, item).__doc__.replace(
                 "import narwhals.stable.v1 as nw", "import narwhals as nw"