Repository: narwhals-dev/narwhals
Pull request #1791 (2025-01-11 15:00:46)

feat: deprecate Expr.head, Expr.tail, Expr.sort, Expr.gather_every, Expr.sample (but keep them in stable.v1)
One step closer to `narwhals.stable.v2`

These are some operations which we won't be able to support in the lazy layer at all, and I think it would be confusing to have some Expr methods which aren't available at all for lazyframes (which would be different to `Expr.unique`, which could be supported if followed by an aggregation, or `Expr.cum_sum`, which could be supported if `order_by` is passed)

Further, I just don't think any of these expressions are very useful...people call head/tail/sort/sample on dataframes, not typically on individual expressions..

For the cases where people need them, they can use DataFrame or Series operations

<!--
# 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


MarcoGorelli: tubular do:
```python
levels = X.select(nw.col(c).unique())
```
which could be replaced with `levels = X.select(nw.col(c)).unique()`

Patch:
diff --git a/narwhals/_arrow/dataframe.py b/narwhals/_arrow/dataframe.py
index ed738647..96a8ef71 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -395,10 +395,9 @@ class ArrowDataFrame(CompliantDataFrame, CompliantLazyFrame):
         )
         return self._from_native_frame(self._native_frame.drop(to_drop))
 
-    def drop_nulls(self: Self, subset: str | list[str] | None) -> Self:
+    def drop_nulls(self: Self, subset: list[str] | None) -> Self:
         if subset is None:
             return self._from_native_frame(self._native_frame.drop_null())
-        subset = [subset] if isinstance(subset, str) else subset
         plx = self.__narwhals_namespace__()
         return self.filter(~plx.any_horizontal(plx.col(*subset).is_null()))
 
diff --git a/narwhals/_arrow/namespace.py b/narwhals/_arrow/namespace.py
index b02ad32e..70d372a5 100644
--- a/narwhals/_arrow/namespace.py
+++ b/narwhals/_arrow/namespace.py
@@ -435,19 +435,18 @@ class ArrowWhen:
 
         plx = df.__narwhals_namespace__()
         condition = parse_into_expr(self._condition, namespace=plx)(df)[0]
+
         try:
             value_series = parse_into_expr(self._then_value, namespace=plx)(df)[0]
         except TypeError:
-            # `self._otherwise_value` is a scalar and can't be converted to an expression
-            value_series = condition.__class__._from_iterable(
-                pa.repeat(pa.scalar(self._then_value), len(condition)),
-                name="literal",
-                backend_version=self._backend_version,
-                version=self._version,
+            # `self._then_value` is a scalar and can't be converted to an expression
+            value_series = plx._create_series_from_scalar(
+                self._then_value, reference_series=condition
             )
 
-        value_series_native = value_series._native_series
-        condition_native = condition._native_series
+        condition_native, value_series_native = broadcast_series(
+            [condition, value_series]
+        )
 
         if self._otherwise_value is None:
             otherwise_native = pa.repeat(
@@ -472,9 +471,7 @@ class ArrowWhen:
             ]
         else:
             otherwise_series = otherwise_expr(df)[0]
-            condition_native, otherwise_native = broadcast_series(
-                [condition, otherwise_series]
-            )
+            _, otherwise_native = broadcast_series([condition, otherwise_series])
             return [
                 value_series._from_native_series(
                     pc.if_else(condition_native, value_series_native, otherwise_native)
diff --git a/narwhals/_dask/dataframe.py b/narwhals/_dask/dataframe.py
index 89e1bc48..1a8efc44 100644
--- a/narwhals/_dask/dataframe.py
+++ b/narwhals/_dask/dataframe.py
@@ -149,10 +149,9 @@ class DaskLazyFrame(CompliantLazyFrame):
         )
         return self._from_native_frame(df)
 
-    def drop_nulls(self: Self, subset: str | list[str] | None) -> Self:
+    def drop_nulls(self: Self, subset: list[str] | None) -> Self:
         if subset is None:
             return self._from_native_frame(self._native_frame.dropna())
-        subset = [subset] if isinstance(subset, str) else subset
         plx = self.__narwhals_namespace__()
         return self.filter(~plx.any_horizontal(plx.col(*subset).is_null()))
 
diff --git a/narwhals/_dask/namespace.py b/narwhals/_dask/namespace.py
index 23805afd..936b9abb 100644
--- a/narwhals/_dask/namespace.py
+++ b/narwhals/_dask/namespace.py
@@ -398,13 +398,24 @@ class DaskWhen:
         plx = df.__narwhals_namespace__()
         condition = parse_into_expr(self._condition, namespace=plx)(df)[0]
         condition = cast("dx.Series", condition)
+
         try:
-            value_series = parse_into_expr(self._then_value, namespace=plx)(df)[0]
+            then_expr = parse_into_expr(self._then_value, namespace=plx)
         except TypeError:
-            # `self._otherwise_value` is a scalar and can't be converted to an expression
+            # `self._then_value` is a scalar and can't be converted to an expression
+            value_sequence: Sequence[Any] = [self._then_value]
+            is_scalar = True
+        else:
+            is_scalar = then_expr._returns_scalar  # type: ignore[attr-defined]
+            value_sequence = then_expr(df)[0]
+
+        if is_scalar:
             _df = condition.to_frame("a")
-            _df["tmp"] = self._then_value
+            _df["tmp"] = value_sequence[0]
             value_series = _df["tmp"]
+        else:
+            value_series = value_sequence
+
         value_series = cast("dx.Series", value_series)
         validate_comparand(condition, value_series)
 
diff --git a/narwhals/_duckdb/dataframe.py b/narwhals/_duckdb/dataframe.py
index 98eca0bd..a3c2798b 100644
--- a/narwhals/_duckdb/dataframe.py
+++ b/narwhals/_duckdb/dataframe.py
@@ -321,3 +321,12 @@ class DuckDBLazyFrame:
             )
         )
         return self._from_native_frame(result)
+
+    def drop_nulls(self: Self, subset: list[str] | None) -> Self:
+        import duckdb
+
+        rel = self._native_frame
+        subset_ = subset if subset is not None else rel.columns
+        keep_condition = " and ".join(f'"{col}" is not null' for col in subset_)
+        query = f"select * from rel where {keep_condition}"  # noqa: S608
+        return self._from_native_frame(duckdb.sql(query))
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index 47c43a69..fdd53b4a 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -371,10 +371,9 @@ class PandasLikeDataFrame:
         )
         return self._from_native_frame(df)
 
-    def drop_nulls(self, subset: str | list[str] | None) -> Self:
+    def drop_nulls(self, subset: list[str] | None) -> Self:
         if subset is None:
             return self._from_native_frame(self._native_frame.dropna(axis=0))
-        subset = [subset] if isinstance(subset, str) else subset
         plx = self.__narwhals_namespace__()
         return self.filter(~plx.any_horizontal(plx.col(*subset).is_null()))
 
diff --git a/narwhals/_pandas_like/namespace.py b/narwhals/_pandas_like/namespace.py
index 52e56d34..4265a340 100644
--- a/narwhals/_pandas_like/namespace.py
+++ b/narwhals/_pandas_like/namespace.py
@@ -461,20 +461,17 @@ class PandasWhen:
 
         plx = df.__narwhals_namespace__()
         condition = parse_into_expr(self._condition, namespace=plx)(df)[0]
+
         try:
             value_series = parse_into_expr(self._then_value, namespace=plx)(df)[0]
         except TypeError:
-            # `self._otherwise_value` is a scalar and can't be converted to an expression
-            value_series = condition.__class__._from_iterable(
-                [self._then_value] * len(condition),
-                name="literal",
-                index=condition._native_series.index,
-                implementation=self._implementation,
-                backend_version=self._backend_version,
-                version=self._version,
+            # `self._then_value` is a scalar and can't be converted to an expression
+            value_series = plx._create_series_from_scalar(
+                self._then_value, reference_series=condition
             )
-        value_series_native, condition_native = broadcast_align_and_extract_native(
-            value_series, condition
+
+        condition_native, value_series_native = broadcast_align_and_extract_native(
+            condition, value_series
         )
 
         if self._otherwise_value is None:
@@ -494,7 +491,14 @@ class PandasWhen:
             ]
         else:
             otherwise_series = otherwise_expr(df)[0]
-            return [value_series.zip_with(condition, otherwise_series)]
+            _, otherwise_native = broadcast_align_and_extract_native(
+                condition, otherwise_series
+            )
+            return [
+                value_series._from_native_series(
+                    value_series_native.where(condition_native, otherwise_native)
+                )
+            ]
 
     def then(self, value: PandasLikeExpr | PandasLikeSeries | Any) -> PandasThen:
         self._then_value = value
diff --git a/narwhals/_spark_like/dataframe.py b/narwhals/_spark_like/dataframe.py
index 7e7f41dd..34da43fd 100644
--- a/narwhals/_spark_like/dataframe.py
+++ b/narwhals/_spark_like/dataframe.py
@@ -183,7 +183,7 @@ class SparkLikeLazyFrame:
         sort_cols = [sort_f(col) for col, sort_f in zip(flat_by, sort_funcs)]
         return self._from_native_frame(self._native_frame.sort(*sort_cols))
 
-    def drop_nulls(self: Self, subset: str | list[str] | None) -> Self:
+    def drop_nulls(self: Self, subset: list[str] | None) -> Self:
         return self._from_native_frame(self._native_frame.dropna(subset=subset))
 
     def rename(self: Self, mapping: dict[str, str]) -> Self:
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index 1ae43028..e4504167 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -107,7 +107,8 @@ class BaseFrame(Generic[FrameT]):
             self._compliant_frame.with_row_index(name),
         )
 
-    def drop_nulls(self: Self, subset: str | list[str] | None = None) -> Self:
+    def drop_nulls(self: Self, subset: str | list[str] | None) -> Self:
+        subset = [subset] if isinstance(subset, str) else subset
         return self._from_compliant_dataframe(
             self._compliant_frame.drop_nulls(subset=subset),
         )
diff --git a/narwhals/expr.py b/narwhals/expr.py
index 84646cf8..119189b5 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -17,6 +17,7 @@ from narwhals.expr_name import ExprNameNamespace
 from narwhals.expr_str import ExprStringNamespace
 from narwhals.utils import _validate_rolling_arguments
 from narwhals.utils import flatten
+from narwhals.utils import issue_deprecation_warning
 
 if TYPE_CHECKING:
     from typing_extensions import Self
@@ -1690,90 +1691,27 @@ class Expr:
     def sort(self, *, descending: bool = False, nulls_last: bool = False) -> Self:
         """Sort this column. Place null values first.
 
+        !!! warning
+            `Expr.sort` is deprecated and will be removed in a future version.
+            Hint: instead of `df.select(nw.col('a').sort())`, use
+            `df.select(nw.col('a')).sort()` instead.
+            Note: this will remain available in `narwhals.stable.v1`.
+            See [stable api](../backcompat.md/) for more information.
+
         Arguments:
             descending: Sort in descending order.
             nulls_last: Place null values last instead of first.
 
         Returns:
             A new expression.
-
-        Examples:
-            >>> import pandas as pd
-            >>> import polars as pl
-            >>> import pyarrow as pa
-            >>> import narwhals as nw
-            >>> from narwhals.typing import IntoFrameT
-            >>>
-            >>> data = {"a": [5, None, 1, 2]}
-            >>> df_pd = pd.DataFrame(data)
-            >>> df_pl = pl.DataFrame(data)
-            >>> df_pa = pa.table(data)
-
-            Let's define dataframe-agnostic functions:
-
-            >>> def agnostic_sort(df_native: IntoFrameT) -> IntoFrameT:
-            ...     df = nw.from_native(df_native)
-            ...     return df.select(nw.col("a").sort()).to_native()
-
-            >>> def agnostic_sort_descending(df_native: IntoFrameT) -> IntoFrameT:
-            ...     df = nw.from_native(df_native)
-            ...     return df.select(nw.col("a").sort(descending=True)).to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_sort` and `agnostic_sort_descending`:
-
-            >>> agnostic_sort(df_pd)
-                 a
-            1  NaN
-            2  1.0
-            3  2.0
-            0  5.0
-
-            >>> agnostic_sort(df_pl)
-            shape: (4, 1)
-            ┌──────┐
-            │ a    │
-            │ ---  │
-            │ i64  │
-            ╞══════╡
-            │ null │
-            │ 1    │
-            │ 2    │
-            │ 5    │
-            └──────┘
-
-            >>> agnostic_sort(df_pa)
-            pyarrow.Table
-            a: int64
-            ----
-            a: [[null,1,2,5]]
-
-            >>> agnostic_sort_descending(df_pd)
-                 a
-            1  NaN
-            0  5.0
-            3  2.0
-            2  1.0
-
-            >>> agnostic_sort_descending(df_pl)
-            shape: (4, 1)
-            ┌──────┐
-            │ a    │
-            │ ---  │
-            │ i64  │
-            ╞══════╡
-            │ null │
-            │ 5    │
-            │ 2    │
-            │ 1    │
-            └──────┘
-
-            >>> agnostic_sort_descending(df_pa)
-            pyarrow.Table
-            a: int64
-            ----
-            a: [[null,5,2,1]]
         """
+        msg = (
+            "`Expr.sort` is deprecated and will be removed in a future version.\n\n"
+            "Hint: instead of `df.select(nw.col('a').sort())`, use `df.select(nw.col('a')).sort()`.\n\n"
+            "Note: this will remain available in `narwhals.stable.v1`.\n"
+            "See https://narwhals-dev.github.io/narwhals/backcompat/ for more information.\n"
+        )
+        issue_deprecation_warning(msg, _version="1.22.0")
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).sort(
                 descending=descending, nulls_last=nulls_last
@@ -2394,6 +2332,13 @@ class Expr:
     ) -> Self:
         """Sample randomly from this expression.
 
+        !!! warning
+            `Expr.sample` is deprecated and will be removed in a future version.
+            Hint: instead of `df.select(nw.col('a').sample())`, use
+            `df.select(nw.col('a')).sample()` instead.
+            Note: this will remain available in `narwhals.stable.v1`.
+            See [stable api](../backcompat.md/) for more information.
+
         Arguments:
             n: Number of items to return. Cannot be used with fraction.
             fraction: Fraction of items to return. Cannot be used with n.
@@ -2403,54 +2348,14 @@ class Expr:
 
         Returns:
             A new expression.
-
-        Examples:
-            >>> import pandas as pd
-            >>> import polars as pl
-            >>> import pyarrow as pa
-            >>> import narwhals as nw
-            >>> from narwhals.typing import IntoFrameT
-            >>>
-            >>> data = {"a": [1, 2, 3]}
-            >>> df_pd = pd.DataFrame(data)
-            >>> df_pl = pl.DataFrame(data)
-            >>> df_pa = pa.table(data)
-
-            Let's define a dataframe-agnostic function:
-
-            >>> def agnostic_sample(df_native: IntoFrameT) -> IntoFrameT:
-            ...     df = nw.from_native(df_native)
-            ...     return df.select(
-            ...         nw.col("a").sample(fraction=1.0, with_replacement=True)
-            ...     ).to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_sample`:
-
-            >>> agnostic_sample(df_pd)  # doctest: +SKIP
-               a
-            2  3
-            0  1
-            2  3
-
-            >>> agnostic_sample(df_pl)  # doctest: +SKIP
-            shape: (3, 1)
-            ┌─────┐
-            │ a   │
-            │ --- │
-            │ f64 │
-            ╞═════╡
-            │ 2   │
-            │ 3   │
-            │ 3   │
-            └─────┘
-
-            >>> agnostic_sample(df_pa)  # doctest: +SKIP
-            pyarrow.Table
-            a: int64
-            ----
-            a: [[1,3,3]]
         """
+        msg = (
+            "`Expr.sample` is deprecated and will be removed in a future version.\n\n"
+            "Hint: instead of `df.select(nw.col('a').sample())`, use `df.select(nw.col('a')).sample()`.\n\n"
+            "Note: this will remain available in `narwhals.stable.v1`.\n"
+            "See https://narwhals-dev.github.io/narwhals/backcompat/ for more information.\n"
+        )
+        issue_deprecation_warning(msg, _version="1.22.0")
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).sample(
                 n, fraction=fraction, with_replacement=with_replacement, seed=seed
@@ -2907,113 +2812,51 @@ class Expr:
     def head(self, n: int = 10) -> Self:
         r"""Get the first `n` rows.
 
+        !!! warning
+            `Expr.head` is deprecated and will be removed in a future version.
+            Hint: instead of `df.select(nw.col('a').head())`, use
+            `df.select(nw.col('a')).head()` instead.
+            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 new expression.
-
-        Examples:
-            >>> import pandas as pd
-            >>> import polars as pl
-            >>> import pyarrow as pa
-            >>> import narwhals as nw
-            >>> from narwhals.typing import IntoFrameT
-            >>>
-            >>> data = {"a": list(range(10))}
-            >>> df_pd = pd.DataFrame(data)
-            >>> df_pl = pl.DataFrame(data)
-            >>> df_pa = pa.table(data)
-
-            Let's define a dataframe-agnostic function that returns the first 3 rows:
-
-            >>> def agnostic_head(df_native: IntoFrameT) -> IntoFrameT:
-            ...     df = nw.from_native(df_native)
-            ...     return df.select(nw.col("a").head(3)).to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_head`:
-
-            >>> agnostic_head(df_pd)
-               a
-            0  0
-            1  1
-            2  2
-
-            >>> agnostic_head(df_pl)
-            shape: (3, 1)
-            ┌─────┐
-            │ a   │
-            │ --- │
-            │ i64 │
-            ╞═════╡
-            │ 0   │
-            │ 1   │
-            │ 2   │
-            └─────┘
-
-            >>> agnostic_head(df_pa)
-            pyarrow.Table
-            a: int64
-            ----
-            a: [[0,1,2]]
         """
+        msg = (
+            "`Expr.head` is deprecated and will be removed in a future version.\n\n"
+            "Hint: instead of `df.select(nw.col('a').head())`, use `df.select(nw.col('a')).head()`.\n\n"
+            "Note: this will remain available in `narwhals.stable.v1`.\n"
+            "See https://narwhals-dev.github.io/narwhals/backcompat/ for more information.\n"
+        )
+        issue_deprecation_warning(msg, _version="1.22.0")
         return self.__class__(lambda plx: self._to_compliant_expr(plx).head(n))
 
     def tail(self, n: int = 10) -> Self:
         r"""Get the last `n` rows.
 
+        !!! warning
+            `Expr.tail` is deprecated and will be removed in a future version.
+            Hint: instead of `df.select(nw.col('a').tail())`, use
+            `df.select(nw.col('a')).tail()` instead.
+            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 new expression.
-
-        Examples:
-            >>> import pandas as pd
-            >>> import polars as pl
-            >>> import pyarrow as pa
-            >>> import narwhals as nw
-            >>> from narwhals.typing import IntoFrameT
-            >>>
-            >>> data = {"a": list(range(10))}
-            >>> df_pd = pd.DataFrame(data)
-            >>> df_pl = pl.DataFrame(data)
-            >>> df_pa = pa.table(data)
-
-            Let's define a dataframe-agnostic function that returns the last 3 rows:
-
-            >>> def agnostic_tail(df_native: IntoFrameT) -> IntoFrameT:
-            ...     df = nw.from_native(df_native)
-            ...     return df.select(nw.col("a").tail(3)).to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_tail`:
-
-            >>> agnostic_tail(df_pd)
-               a
-            7  7
-            8  8
-            9  9
-
-            >>> agnostic_tail(df_pl)
-            shape: (3, 1)
-            ┌─────┐
-            │ a   │
-            │ --- │
-            │ i64 │
-            ╞═════╡
-            │ 7   │
-            │ 8   │
-            │ 9   │
-            └─────┘
-
-            >>> agnostic_tail(df_pa)
-            pyarrow.Table
-            a: int64
-            ----
-            a: [[7,8,9]]
         """
+        msg = (
+            "`Expr.tail` is deprecated and will be removed in a future version.\n\n"
+            "Hint: instead of `df.select(nw.col('a').tail())`, use `df.select(nw.col('a')).tail()`.\n\n"
+            "Note: this will remain available in `narwhals.stable.v1`.\n"
+            "See https://narwhals-dev.github.io/narwhals/backcompat/ for more information.\n"
+        )
+        issue_deprecation_warning(msg, _version="1.22.0")
         return self.__class__(lambda plx: self._to_compliant_expr(plx).tail(n))
 
     def round(self, decimals: int = 0) -> Self:
@@ -3141,57 +2984,27 @@ class Expr:
     def gather_every(self: Self, n: int, offset: int = 0) -> Self:
         r"""Take every nth value in the Series and return as new Series.
 
+        !!! warning
+            `Expr.gather_every` is deprecated and will be removed in a future version.
+            Hint: instead of `df.select(nw.col('a').gather_every())`, use
+            `df.select(nw.col('a')).gather_every()` instead.
+            Note: this will remain available in `narwhals.stable.v1`.
+            See [stable api](../backcompat.md/) for more information.
+
         Arguments:
             n: Gather every *n*-th row.
             offset: Starting index.
 
         Returns:
             A new expression.
-
-        Examples:
-            >>> import pandas as pd
-            >>> import polars as pl
-            >>> import pyarrow as pa
-            >>> import narwhals as nw
-            >>> from narwhals.typing import IntoFrameT
-            >>>
-            >>> data = {"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}
-            >>> df_pd = pd.DataFrame(data)
-            >>> df_pl = pl.DataFrame(data)
-            >>> df_pa = pa.table(data)
-
-            Let's define a dataframe-agnostic function in which gather every 2 rows,
-            starting from a offset of 1:
-
-            >>> def agnostic_gather_every(df_native: IntoFrameT) -> IntoFrameT:
-            ...     df = nw.from_native(df_native)
-            ...     return df.select(nw.col("a").gather_every(n=2, offset=1)).to_native()
-
-            We can then pass any supported library such as pandas, Polars, or
-            PyArrow to `agnostic_gather_every`:
-
-            >>> agnostic_gather_every(df_pd)
-               a
-            1  2
-            3  4
-
-            >>> agnostic_gather_every(df_pl)
-            shape: (2, 1)
-            ┌─────┐
-            │ a   │
-            │ --- │
-            │ i64 │
-            ╞═════╡
-            │ 2   │
-            │ 4   │
-            └─────┘
-
-            >>> agnostic_gather_every(df_pa)
-            pyarrow.Table
-            a: int64
-            ----
-            a: [[2,4]]
         """
+        msg = (
+            "`Expr.gather_every` is deprecated and will be removed in a future version.\n\n"
+            "Hint: instead of `df.select(nw.col('a').gather_every())`, use `df.select(nw.col('a')).gather_every()`.\n\n"
+            "Note: this will remain available in `narwhals.stable.v1`.\n"
+            "See https://narwhals-dev.github.io/narwhals/backcompat/ for more information.\n"
+        )
+        issue_deprecation_warning(msg, _version="1.22.0")
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).gather_every(n=n, offset=offset)
         )
diff --git a/narwhals/stable/v1/__init__.py b/narwhals/stable/v1/__init__.py
index 171c5291..b4b3540a 100644
--- a/narwhals/stable/v1/__init__.py
+++ b/narwhals/stable/v1/__init__.py
@@ -836,6 +836,91 @@ class Expr(NwExpr):
             ddof=ddof,
         )
 
+    def head(self, n: int = 10) -> Self:
+        r"""Get the first `n` rows.
+
+        Arguments:
+            n: Number of rows to return.
+
+        Returns:
+            A new expression.
+        """
+        return self.__class__(lambda plx: self._to_compliant_expr(plx).head(n))
+
+    def tail(self, n: int = 10) -> Self:
+        r"""Get the last `n` rows.
+
+        Arguments:
+            n: Number of rows to return.
+
+        Returns:
+            A new expression.
+        """
+        return self.__class__(lambda plx: self._to_compliant_expr(plx).tail(n))
+
+    def gather_every(self: Self, n: int, offset: int = 0) -> Self:
+        r"""Take every nth value in the Series and return as new Series.
+
+        Arguments:
+            n: Gather every *n*-th row.
+            offset: Starting index.
+
+        Returns:
+            A new expression.
+        """
+        return self.__class__(
+            lambda plx: self._to_compliant_expr(plx).gather_every(n=n, offset=offset)
+        )
+
+    def sort(self, *, descending: bool = False, nulls_last: bool = False) -> Self:
+        """Sort this column. Place null values first.
+
+        Arguments:
+            descending: Sort in descending order.
+            nulls_last: Place null values last instead of first.
+
+        Returns:
+            A new expression.
+        """
+        return self.__class__(
+            lambda plx: self._to_compliant_expr(plx).sort(
+                descending=descending, nulls_last=nulls_last
+            )
+        )
+
+    def sample(
+        self: Self,
+        n: int | None = None,
+        *,
+        fraction: float | None = None,
+        with_replacement: bool = False,
+        seed: int | None = None,
+    ) -> Self:
+        """Sample randomly from this expression.
+
+        !!! warning
+            `Expr.sample` is deprecated and will be removed in a future version.
+            Hint: instead of `df.select(nw.col('a').sample())`, use
+            `df.select(nw.col('a')).sample()` instead.
+            Note: this will remain available in `narwhals.stable.v1`.
+            See [stable api](../backcompat.md/) for more information.
+
+        Arguments:
+            n: Number of items to return. Cannot be used with fraction.
+            fraction: Fraction of items to return. Cannot be used with n.
+            with_replacement: Allow values to be sampled more than once.
+            seed: Seed for the random number generator. If set to None (default), a random
+                seed is generated for each sample operation.
+
+        Returns:
+            A new expression.
+        """
+        return self.__class__(
+            lambda plx: self._to_compliant_expr(plx).sample(
+                n, fraction=fraction, with_replacement=with_replacement, seed=seed
+            )
+        )
+
 
 class Schema(NwSchema):
     """Ordered mapping of column names to their data type.
diff --git a/narwhals/utils.py b/narwhals/utils.py
index 73f0e20a..1a353079 100644
--- a/narwhals/utils.py
+++ b/narwhals/utils.py
@@ -981,7 +981,7 @@ def validate_strict_and_pass_though(
             msg = (
                 "`strict` in `from_native` is deprecated, please use `pass_through` instead.\n\n"
                 "Note: `strict` will remain available in `narwhals.stable.v1`.\n"
-                "See [stable api](../backcompat.md/) for more information.\n"
+                "See https://narwhals-dev.github.io/narwhals/backcompat/ for more information.\n"
             )
             issue_deprecation_warning(msg, _version="1.13.0")
         pass_through = not strict


Test patch:
diff --git a/tests/expr_and_series/gather_every_test.py b/tests/expr_and_series/gather_every_test.py
index fdaaac08..bcda13d8 100644
--- a/tests/expr_and_series/gather_every_test.py
+++ b/tests/expr_and_series/gather_every_test.py
@@ -2,6 +2,7 @@ from __future__ import annotations
 
 import pytest
 
+import narwhals as nw_main
 import narwhals.stable.v1 as nw
 from tests.utils import ConstructorEager
 from tests.utils import assert_equal_data
@@ -21,6 +22,9 @@ def test_gather_every_expr(
 
     assert_equal_data(result, expected)
 
+    with pytest.deprecated_call():
+        df.select(nw_main.col("a").gather_every(n=n, offset=offset))
+
 
 @pytest.mark.parametrize("n", [1, 2, 3])
 @pytest.mark.parametrize("offset", [1, 2, 3])
diff --git a/tests/expr_and_series/head_test.py b/tests/expr_and_series/head_test.py
index 49cc4124..952b3de1 100644
--- a/tests/expr_and_series/head_test.py
+++ b/tests/expr_and_series/head_test.py
@@ -2,7 +2,8 @@ from __future__ import annotations
 
 import pytest
 
-import narwhals as nw
+import narwhals as nw_main
+import narwhals.stable.v1 as nw
 from tests.utils import ConstructorEager
 from tests.utils import assert_equal_data
 
@@ -18,6 +19,9 @@ def test_head(
     expected = {"a": [1, 2]}
     assert_equal_data(result, expected)
 
+    with pytest.deprecated_call():
+        df.select(nw_main.col("a").head(5))
+
 
 @pytest.mark.parametrize("n", [2, -1])
 def test_head_series(constructor_eager: ConstructorEager, n: int) -> None:
diff --git a/tests/expr_and_series/sample_test.py b/tests/expr_and_series/sample_test.py
index 8e88ba7c..eaea6a52 100644
--- a/tests/expr_and_series/sample_test.py
+++ b/tests/expr_and_series/sample_test.py
@@ -2,6 +2,7 @@ from __future__ import annotations
 
 import pytest
 
+import narwhals as nw_main
 import narwhals.stable.v1 as nw
 from tests.utils import ConstructorEager
 from tests.utils import assert_equal_data
@@ -18,21 +19,20 @@ def test_expr_sample(constructor_eager: ConstructorEager) -> None:
     expected_series = (2,)
     assert result_series == expected_series
 
+    with pytest.deprecated_call():
+        df.select(nw_main.col("a").sample(n=2))
 
-def test_expr_sample_fraction(
-    constructor_eager: ConstructorEager, request: pytest.FixtureRequest
-) -> None:
-    if "dask" in str(constructor_eager):
-        request.applymarker(pytest.mark.xfail)
+
+def test_expr_sample_fraction(constructor_eager: ConstructorEager) -> None:
     df = nw.from_native(
-        constructor_eager({"a": [1, 2, 3] * 10, "b": [4, 5, 6] * 10})
-    ).lazy()
+        constructor_eager({"a": [1, 2, 3] * 10, "b": [4, 5, 6] * 10}), eager_only=True
+    )
 
-    result_expr = df.select(nw.col("a").sample(fraction=0.1)).collect().shape
+    result_expr = df.select(nw.col("a").sample(fraction=0.1)).shape
     expected_expr = (3, 1)
     assert result_expr == expected_expr
 
-    result_series = df.collect()["a"].sample(fraction=0.1).shape
+    result_series = df["a"].sample(fraction=0.1).shape
     expected_series = (3,)
     assert result_series == expected_series
 
diff --git a/tests/expr_and_series/sort_test.py b/tests/expr_and_series/sort_test.py
index 0d95722d..e35514cd 100644
--- a/tests/expr_and_series/sort_test.py
+++ b/tests/expr_and_series/sort_test.py
@@ -4,6 +4,7 @@ from typing import Any
 
 import pytest
 
+import narwhals as nw_main
 import narwhals.stable.v1 as nw
 from tests.utils import ConstructorEager
 from tests.utils import assert_equal_data
@@ -29,6 +30,11 @@ def test_sort_expr(
         nw.col("b").sort(descending=descending, nulls_last=nulls_last),
     )
     assert_equal_data(result, expected)
+    with pytest.deprecated_call():
+        df.select(
+            "a",
+            nw_main.col("b").sort(descending=descending, nulls_last=nulls_last),
+        )
 
 
 @pytest.mark.parametrize(
diff --git a/tests/expr_and_series/tail_test.py b/tests/expr_and_series/tail_test.py
index e0481381..2baabfd9 100644
--- a/tests/expr_and_series/tail_test.py
+++ b/tests/expr_and_series/tail_test.py
@@ -2,13 +2,14 @@ from __future__ import annotations
 
 import pytest
 
-import narwhals as nw
+import narwhals as nw_main
+import narwhals.stable.v1 as nw
 from tests.utils import ConstructorEager
 from tests.utils import assert_equal_data
 
 
 @pytest.mark.parametrize("n", [2, -1])
-def test_head(
+def test_tail(
     constructor_eager: ConstructorEager, n: int, request: pytest.FixtureRequest
 ) -> None:
     if "polars" in str(constructor_eager) and n < 0:
@@ -18,9 +19,12 @@ def test_head(
     expected = {"a": [2, 3]}
     assert_equal_data(result, expected)
 
+    with pytest.deprecated_call():
+        df.select(nw_main.col("a").tail(5))
+
 
 @pytest.mark.parametrize("n", [2, -1])
-def test_head_series(constructor_eager: ConstructorEager, n: int) -> None:
+def test_tail_series(constructor_eager: ConstructorEager, n: int) -> None:
     df = nw.from_native(constructor_eager({"a": [1, 2, 3]}), eager_only=True)
     result = df.select(df["a"].tail(n))
     expected = {"a": [2, 3]}
diff --git a/tests/expr_and_series/when_test.py b/tests/expr_and_series/when_test.py
index 94e37aaa..4f768db0 100644
--- a/tests/expr_and_series/when_test.py
+++ b/tests/expr_and_series/when_test.py
@@ -141,3 +141,13 @@ def test_when_then_otherwise_lit_str(
     result = df.select(nw.when(nw.col("a") > 1).then(nw.col("b")).otherwise(nw.lit("z")))
     expected = {"b": ["z", "b", "c"]}
     assert_equal_data(result, expected)
+
+
+def test_when_then_otherwise_both_lit(constructor: Constructor) -> None:
+    df = nw.from_native(constructor(data))
+    result = df.select(
+        x1=nw.when(nw.col("a") > 1).then(nw.lit(42)).otherwise(nw.lit(-1)),
+        x2=nw.when(nw.col("a") > 2).then(nw.lit(42)).otherwise(nw.lit(-1)),
+    )
+    expected = {"x1": [-1, 42, 42], "x2": [-1, -1, 42]}
+    assert_equal_data(result, expected)
diff --git a/tests/frame/drop_nulls_test.py b/tests/frame/drop_nulls_test.py
index 368ad6ba..c49b1712 100644
--- a/tests/frame/drop_nulls_test.py
+++ b/tests/frame/drop_nulls_test.py
@@ -7,18 +7,16 @@ from tests.utils import Constructor
 from tests.utils import assert_equal_data
 
 data = {
-    "a": [1.0, 2.0, None, 4.0],
-    "b": [None, 3.0, None, 5.0],
+    "alpha": [1.0, 2.0, None, 4.0],
+    "beta gamma": [None, 3.0, None, 5.0],
 }
 
 
-def test_drop_nulls(constructor: Constructor, request: pytest.FixtureRequest) -> None:
-    if "duckdb" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_drop_nulls(constructor: Constructor) -> None:
     result = nw.from_native(constructor(data)).drop_nulls()
     expected = {
-        "a": [2.0, 4.0],
-        "b": [3.0, 5.0],
+        "alpha": [2.0, 4.0],
+        "beta gamma": [3.0, 5.0],
     }
     assert_equal_data(result, expected)
 
@@ -26,18 +24,15 @@ def test_drop_nulls(constructor: Constructor, request: pytest.FixtureRequest) ->
 @pytest.mark.parametrize(
     ("subset", "expected"),
     [
-        ("a", {"a": [1, 2.0, 4.0], "b": [None, 3.0, 5.0]}),
-        (["a"], {"a": [1, 2.0, 4.0], "b": [None, 3.0, 5.0]}),
-        (["a", "b"], {"a": [2.0, 4.0], "b": [3.0, 5.0]}),
+        ("alpha", {"alpha": [1, 2.0, 4.0], "beta gamma": [None, 3.0, 5.0]}),
+        (["alpha"], {"alpha": [1, 2.0, 4.0], "beta gamma": [None, 3.0, 5.0]}),
+        (["alpha", "beta gamma"], {"alpha": [2.0, 4.0], "beta gamma": [3.0, 5.0]}),
     ],
 )
 def test_drop_nulls_subset(
     constructor: Constructor,
     subset: str | list[str],
     expected: dict[str, float],
-    request: pytest.FixtureRequest,
 ) -> None:
-    if "duckdb" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
     result = nw.from_native(constructor(data)).drop_nulls(subset=subset)
     assert_equal_data(result, expected)