Repository: narwhals-dev/narwhals
Pull request #1816 (2025-01-16 07:11:26)

feat: add `AnonymousExprError`
<!--
# 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
- [x] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues

## Checklist

- [x] Code follows style guide (ruff)
- [x] Tests added
- [x] Documented the changes

## If you have comments or can explain your changes, please do so below

I propose to add a error for the (very) common error message about anonlymous expressions.

Ideally I would like to move this a level up (since we raise it for every backend) but I get all sorts of mypy error if I do that. e.g. `mypy` thinks `root_names` could still be `None`

MarcoGorelli: awesome, thanks! i think it's fine to now move this up - in theory, I don't see why we should be raising this error at all, i think for now it's fine to just raise the error at the compliant level

Patch:
diff --git a/narwhals/_arrow/dataframe.py b/narwhals/_arrow/dataframe.py
index ed738647..e44ba29a 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -312,7 +312,11 @@ class ArrowDataFrame(CompliantDataFrame, CompliantLazyFrame):
             col_name = col_value.name
 
             column = validate_dataframe_comparand(
-                length=length, other=col_value, backend_version=self._backend_version
+                length=length,
+                other=col_value,
+                backend_version=self._backend_version,
+                allow_broadcast=True,
+                method_name="with_columns",
             )
 
             native_frame = (
@@ -395,10 +399,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()))
 
@@ -483,7 +486,7 @@ class ArrowDataFrame(CompliantDataFrame, CompliantLazyFrame):
             and all(isinstance(x, bool) for x in predicates[0])
             and not constraints
         ):
-            mask = predicates[0]
+            mask_native = predicates[0]
         else:
             plx = self.__narwhals_namespace__()
             expr = plx.all_horizontal(
@@ -492,8 +495,15 @@ class ArrowDataFrame(CompliantDataFrame, CompliantLazyFrame):
                 )
             )
             # `[0]` is safe as all_horizontal's expression only returns a single column
-            mask = expr._call(self)[0]._native_series
-        return self._from_native_frame(self._native_frame.filter(mask))
+            mask = expr._call(self)[0]
+            mask_native = validate_dataframe_comparand(
+                length=len(self),
+                other=mask,
+                backend_version=self._backend_version,
+                allow_broadcast=False,
+                method_name="filter",
+            )
+        return self._from_native_frame(self._native_frame.filter(mask_native))
 
     def null_count(self: Self) -> Self:
         import pyarrow as pa
diff --git a/narwhals/_arrow/expr.py b/narwhals/_arrow/expr.py
index d0bc49b0..8ab13a75 100644
--- a/narwhals/_arrow/expr.py
+++ b/narwhals/_arrow/expr.py
@@ -15,6 +15,7 @@ from narwhals._arrow.series import ArrowSeries
 from narwhals._expression_parsing import reuse_series_implementation
 from narwhals.dependencies import get_numpy
 from narwhals.dependencies import is_numpy_array
+from narwhals.exceptions import AnonymousExprError
 from narwhals.exceptions import ColumnNotFoundError
 from narwhals.typing import CompliantExpr
 from narwhals.utils import Implementation
@@ -390,12 +391,9 @@ class ArrowExpr(CompliantExpr[ArrowSeries]):
     def over(self: Self, keys: list[str]) -> Self:
         def func(df: ArrowDataFrame) -> list[ArrowSeries]:
             if self._output_names is None:
-                msg = (
-                    "Anonymous expressions are not supported in over.\n"
-                    "Instead of `nw.all()`, try using a named expression, such as "
-                    "`nw.col('a', 'b')`\n"
-                )
-                raise ValueError(msg)
+                msg = ".over"
+                raise AnonymousExprError.from_expr_name(msg)
+
             tmp = df.group_by(*keys, drop_null_keys=False).agg(self)
             tmp = df.select(*keys).join(
                 tmp, how="left", left_on=keys, right_on=keys, suffix="_right"
diff --git a/narwhals/_arrow/expr_name.py b/narwhals/_arrow/expr_name.py
index 7a2fbede..4c4991bf 100644
--- a/narwhals/_arrow/expr_name.py
+++ b/narwhals/_arrow/expr_name.py
@@ -3,6 +3,8 @@ from __future__ import annotations
 from typing import TYPE_CHECKING
 from typing import Callable
 
+from narwhals.exceptions import AnonymousExprError
+
 if TYPE_CHECKING:
     from typing_extensions import Self
 
@@ -17,12 +19,8 @@ class ArrowExprNameNamespace:
         root_names = self._compliant_expr._root_names
 
         if root_names is None:
-            msg = (
-                "Anonymous expressions are not supported in `.name.keep`.\n"
-                "Instead of `nw.all()`, try using a named expression, such as "
-                "`nw.col('a', 'b')`\n"
-            )
-            raise ValueError(msg)
+            msg = ".name.keep"
+            raise AnonymousExprError.from_expr_name(msg)
 
         return self._compliant_expr.__class__(
             lambda df: [
@@ -42,12 +40,8 @@ class ArrowExprNameNamespace:
         root_names = self._compliant_expr._root_names
 
         if root_names is None:
-            msg = (
-                "Anonymous expressions are not supported in `.name.map`.\n"
-                "Instead of `nw.all()`, try using a named expression, such as "
-                "`nw.col('a', 'b')`\n"
-            )
-            raise ValueError(msg)
+            msg = ".name.map"
+            raise AnonymousExprError.from_expr_name(msg)
 
         output_names = [function(str(name)) for name in root_names]
 
@@ -68,12 +62,8 @@ class ArrowExprNameNamespace:
     def prefix(self: Self, prefix: str) -> ArrowExpr:
         root_names = self._compliant_expr._root_names
         if root_names is None:
-            msg = (
-                "Anonymous expressions are not supported in `.name.prefix`.\n"
-                "Instead of `nw.all()`, try using a named expression, such as "
-                "`nw.col('a', 'b')`\n"
-            )
-            raise ValueError(msg)
+            msg = ".name.prefix"
+            raise AnonymousExprError.from_expr_name(msg)
 
         output_names = [prefix + str(name) for name in root_names]
         return self._compliant_expr.__class__(
@@ -93,12 +83,8 @@ class ArrowExprNameNamespace:
     def suffix(self: Self, suffix: str) -> ArrowExpr:
         root_names = self._compliant_expr._root_names
         if root_names is None:
-            msg = (
-                "Anonymous expressions are not supported in `.name.suffix`.\n"
-                "Instead of `nw.all()`, try using a named expression, such as "
-                "`nw.col('a', 'b')`\n"
-            )
-            raise ValueError(msg)
+            msg = ".name.suffix"
+            raise AnonymousExprError.from_expr_name(msg)
 
         output_names = [str(name) + suffix for name in root_names]
 
@@ -120,12 +106,8 @@ class ArrowExprNameNamespace:
         root_names = self._compliant_expr._root_names
 
         if root_names is None:
-            msg = (
-                "Anonymous expressions are not supported in `.name.to_lowercase`.\n"
-                "Instead of `nw.all()`, try using a named expression, such as "
-                "`nw.col('a', 'b')`\n"
-            )
-            raise ValueError(msg)
+            msg = ".name.to_lowercase"
+            raise AnonymousExprError.from_expr_name(msg)
         output_names = [str(name).lower() for name in root_names]
 
         return self._compliant_expr.__class__(
@@ -146,12 +128,8 @@ class ArrowExprNameNamespace:
         root_names = self._compliant_expr._root_names
 
         if root_names is None:
-            msg = (
-                "Anonymous expressions are not supported in `.name.to_uppercase`.\n"
-                "Instead of `nw.all()`, try using a named expression, such as "
-                "`nw.col('a', 'b')`\n"
-            )
-            raise ValueError(msg)
+            msg = ".name.to_uppercase"
+            raise AnonymousExprError.from_expr_name(msg)
         output_names = [str(name).upper() for name in root_names]
 
         return self._compliant_expr.__class__(
diff --git a/narwhals/_arrow/group_by.py b/narwhals/_arrow/group_by.py
index 11ed914f..aac1decb 100644
--- a/narwhals/_arrow/group_by.py
+++ b/narwhals/_arrow/group_by.py
@@ -9,6 +9,7 @@ from typing import Sequence
 
 from narwhals._expression_parsing import is_simple_aggregation
 from narwhals._expression_parsing import parse_into_exprs
+from narwhals.exceptions import AnonymousExprError
 from narwhals.utils import generate_temporary_column_name
 from narwhals.utils import remove_prefix
 
@@ -61,12 +62,8 @@ class ArrowGroupBy:
         )
         for expr in exprs:
             if expr._output_names is None:
-                msg = (
-                    "Anonymous expressions are not supported in group_by.agg.\n"
-                    "Instead of `nw.all()`, try using a named expression, such as "
-                    "`nw.col('a', 'b')`\n"
-                )
-                raise ValueError(msg)
+                msg = "group_by.agg"
+                raise AnonymousExprError.from_expr_name(msg)
 
         return agg_arrow(
             self._grouped,
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/_arrow/utils.py b/narwhals/_arrow/utils.py
index ca485265..7f642b94 100644
--- a/narwhals/_arrow/utils.py
+++ b/narwhals/_arrow/utils.py
@@ -6,6 +6,7 @@ from typing import Any
 from typing import Sequence
 from typing import overload
 
+from narwhals.exceptions import ShapeError
 from narwhals.utils import import_dtypes_module
 from narwhals.utils import isinstance_or_issubclass
 
@@ -212,7 +213,12 @@ def broadcast_and_extract_native(
 
 
 def validate_dataframe_comparand(
-    length: int, other: Any, backend_version: tuple[int, ...]
+    length: int,
+    other: Any,
+    backend_version: tuple[int, ...],
+    *,
+    allow_broadcast: bool,
+    method_name: str,
 ) -> Any:
     """Validate RHS of binary operation.
 
@@ -222,7 +228,14 @@ def validate_dataframe_comparand(
     from narwhals._arrow.series import ArrowSeries
 
     if isinstance(other, ArrowSeries):
-        if len(other) == 1:
+        len_other = len(other)
+        if len_other == 1:
+            if length > 1 and not allow_broadcast:
+                msg = (
+                    f"{method_name}'s length: 1 differs from that of the series: {length}"
+                )
+                raise ShapeError(msg)
+
             import numpy as np  # ignore-banned-import
             import pyarrow as pa
 
@@ -230,6 +243,11 @@ def validate_dataframe_comparand(
             if backend_version < (13,) and hasattr(value, "as_py"):
                 value = value.as_py()
             return pa.array(np.full(shape=length, fill_value=value))
+
+        if length != len_other:
+            msg = f"{method_name}'s length: {len_other} differs from that of the series: {length}"
+            raise ShapeError(msg)
+
         return other._native_series
 
     from narwhals._arrow.dataframe import ArrowDataFrame  # pragma: no cover
diff --git a/narwhals/_dask/dataframe.py b/narwhals/_dask/dataframe.py
index 89e1bc48..92c1f198 100644
--- a/narwhals/_dask/dataframe.py
+++ b/narwhals/_dask/dataframe.py
@@ -96,13 +96,14 @@ class DaskLazyFrame(CompliantLazyFrame):
     def columns(self) -> list[str]:
         return self._native_frame.columns.tolist()  # type: ignore[no-any-return]
 
-    def filter(self, *predicates: DaskExpr, **constraints: Any) -> Self:
+    def filter(self: Self, *predicates: DaskExpr, **constraints: Any) -> Self:
         plx = self.__narwhals_namespace__()
         expr = plx.all_horizontal(
             *chain(predicates, (plx.col(name) == v for name, v in constraints.items()))
         )
         # `[0]` is safe as all_horizontal's expression only returns a single column
         mask = expr._call(self)[0]
+
         return self._from_native_frame(self._native_frame.loc[mask])
 
     def select(
@@ -149,10 +150,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/expr.py b/narwhals/_dask/expr.py
index 951222c3..04e49cab 100644
--- a/narwhals/_dask/expr.py
+++ b/narwhals/_dask/expr.py
@@ -16,6 +16,7 @@ from narwhals._dask.utils import maybe_evaluate
 from narwhals._dask.utils import narwhals_to_native_dtype
 from narwhals._expression_parsing import infer_new_root_output_names
 from narwhals._pandas_like.utils import native_to_narwhals_dtype
+from narwhals.exceptions import AnonymousExprError
 from narwhals.exceptions import ColumnNotFoundError
 from narwhals.exceptions import InvalidOperationError
 from narwhals.typing import CompliantExpr
@@ -690,12 +691,8 @@ class DaskExpr(CompliantExpr["dx.Series"]):
     def over(self: Self, keys: list[str]) -> Self:
         def func(df: DaskLazyFrame) -> list[Any]:
             if self._output_names is None:
-                msg = (
-                    "Anonymous expressions are not supported in over.\n"
-                    "Instead of `nw.all()`, try using a named expression, such as "
-                    "`nw.col('a', 'b')`\n"
-                )
-                raise ValueError(msg)
+                msg = "over"
+                raise AnonymousExprError.from_expr_name(msg)
 
             if df._native_frame.npartitions == 1:  # pragma: no cover
                 tmp = df.group_by(*keys, drop_null_keys=False).agg(self)
diff --git a/narwhals/_dask/expr_name.py b/narwhals/_dask/expr_name.py
index 3f5a0e4a..bbed6add 100644
--- a/narwhals/_dask/expr_name.py
+++ b/narwhals/_dask/expr_name.py
@@ -3,6 +3,8 @@ from __future__ import annotations
 from typing import TYPE_CHECKING
 from typing import Callable
 
+from narwhals.exceptions import AnonymousExprError
+
 if TYPE_CHECKING:
     from typing_extensions import Self
 
@@ -17,12 +19,8 @@ class DaskExprNameNamespace:
         root_names = self._compliant_expr._root_names
 
         if root_names is None:
-            msg = (
-                "Anonymous expressions are not supported in `.name.keep`.\n"
-                "Instead of `nw.all()`, try using a named expression, such as "
-                "`nw.col('a', 'b')`\n"
-            )
-            raise ValueError(msg)
+            msg = ".name.keep"
+            raise AnonymousExprError.from_expr_name(msg)
 
         return self._compliant_expr.__class__(
             lambda df: [
@@ -43,12 +41,8 @@ class DaskExprNameNamespace:
         root_names = self._compliant_expr._root_names
 
         if root_names is None:
-            msg = (
-                "Anonymous expressions are not supported in `.name.map`.\n"
-                "Instead of `nw.all()`, try using a named expression, such as "
-                "`nw.col('a', 'b')`\n"
-            )
-            raise ValueError(msg)
+            msg = ".name.map"
+            raise AnonymousExprError.from_expr_name(msg)
 
         output_names = [function(str(name)) for name in root_names]
 
@@ -70,12 +64,8 @@ class DaskExprNameNamespace:
     def prefix(self: Self, prefix: str) -> DaskExpr:
         root_names = self._compliant_expr._root_names
         if root_names is None:
-            msg = (
-                "Anonymous expressions are not supported in `.name.prefix`.\n"
-                "Instead of `nw.all()`, try using a named expression, such as "
-                "`nw.col('a', 'b')`\n"
-            )
-            raise ValueError(msg)
+            msg = ".name.prefix"
+            raise AnonymousExprError.from_expr_name(msg)
 
         output_names = [prefix + str(name) for name in root_names]
         return self._compliant_expr.__class__(
@@ -96,12 +86,8 @@ class DaskExprNameNamespace:
     def suffix(self: Self, suffix: str) -> DaskExpr:
         root_names = self._compliant_expr._root_names
         if root_names is None:
-            msg = (
-                "Anonymous expressions are not supported in `.name.suffix`.\n"
-                "Instead of `nw.all()`, try using a named expression, such as "
-                "`nw.col('a', 'b')`\n"
-            )
-            raise ValueError(msg)
+            msg = ".name.suffix"
+            raise AnonymousExprError.from_expr_name(msg)
 
         output_names = [str(name) + suffix for name in root_names]
 
@@ -124,12 +110,8 @@ class DaskExprNameNamespace:
         root_names = self._compliant_expr._root_names
 
         if root_names is None:
-            msg = (
-                "Anonymous expressions are not supported in `.name.to_lowercase`.\n"
-                "Instead of `nw.all()`, try using a named expression, such as "
-                "`nw.col('a', 'b')`\n"
-            )
-            raise ValueError(msg)
+            msg = ".name.to_lowercase"
+            raise AnonymousExprError.from_expr_name(msg)
         output_names = [str(name).lower() for name in root_names]
 
         return self._compliant_expr.__class__(
@@ -151,12 +133,8 @@ class DaskExprNameNamespace:
         root_names = self._compliant_expr._root_names
 
         if root_names is None:
-            msg = (
-                "Anonymous expressions are not supported in `.name.to_uppercase`.\n"
-                "Instead of `nw.all()`, try using a named expression, such as "
-                "`nw.col('a', 'b')`\n"
-            )
-            raise ValueError(msg)
+            msg = ".name.to_uppercase"
+            raise AnonymousExprError.from_expr_name(msg)
         output_names = [str(name).upper() for name in root_names]
 
         return self._compliant_expr.__class__(
diff --git a/narwhals/_dask/group_by.py b/narwhals/_dask/group_by.py
index 60086efa..2fbb4edb 100644
--- a/narwhals/_dask/group_by.py
+++ b/narwhals/_dask/group_by.py
@@ -8,6 +8,7 @@ from typing import Sequence
 
 from narwhals._expression_parsing import is_simple_aggregation
 from narwhals._expression_parsing import parse_into_exprs
+from narwhals.exceptions import AnonymousExprError
 from narwhals.utils import remove_prefix
 
 if TYPE_CHECKING:
@@ -110,12 +111,8 @@ class DaskLazyGroupBy:
         output_names: list[str] = copy(self._keys)
         for expr in exprs:
             if expr._output_names is None:
-                msg = (
-                    "Anonymous expressions are not supported in group_by.agg.\n"
-                    "Instead of `nw.all()`, try using a named expression, such as "
-                    "`nw.col('a', 'b')`\n"
-                )
-                raise ValueError(msg)
+                msg = "group_by.agg"
+                raise AnonymousExprError.from_expr_name(msg)
 
             output_names.extend(expr._output_names)
 
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..0bd2c9f0 100644
--- a/narwhals/_duckdb/dataframe.py
+++ b/narwhals/_duckdb/dataframe.py
@@ -147,7 +147,7 @@ class DuckDBLazyFrame:
             result.append(value.alias(col))
         return self._from_native_frame(self._native_frame.select(*result))
 
-    def filter(self, *predicates: DuckDBExpr, **constraints: Any) -> Self:
+    def filter(self: Self, *predicates: DuckDBExpr, **constraints: Any) -> Self:
         plx = self.__narwhals_namespace__()
         expr = plx.all_horizontal(
             *chain(predicates, (plx.col(name) == v for name, v in constraints.items()))
@@ -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/_duckdb/group_by.py b/narwhals/_duckdb/group_by.py
index 0b312ff0..ce81a157 100644
--- a/narwhals/_duckdb/group_by.py
+++ b/narwhals/_duckdb/group_by.py
@@ -4,6 +4,7 @@ from copy import copy
 from typing import TYPE_CHECKING
 
 from narwhals._expression_parsing import parse_into_exprs
+from narwhals.exceptions import AnonymousExprError
 
 if TYPE_CHECKING:
     from narwhals._duckdb.dataframe import DuckDBLazyFrame
@@ -33,12 +34,8 @@ class DuckDBGroupBy:
         output_names: list[str] = copy(self._keys)
         for expr in exprs:
             if expr._output_names is None:  # pragma: no cover
-                msg = (
-                    "Anonymous expressions are not supported in group_by.agg.\n"
-                    "Instead of `nw.all()`, try using a named expression, such as "
-                    "`nw.col('a', 'b')`\n"
-                )
-                raise ValueError(msg)
+                msg = "group_by.agg"
+                raise AnonymousExprError.from_expr_name(msg)
 
             output_names.extend(expr._output_names)
 
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index 47c43a69..05802b6a 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()))
 
@@ -401,7 +400,7 @@ class PandasLikeDataFrame:
     def row(self, row: int) -> tuple[Any, ...]:
         return tuple(x for x in self._native_frame.iloc[row])
 
-    def filter(self, *predicates: IntoPandasLikeExpr, **constraints: Any) -> Self:
+    def filter(self: Self, *predicates: IntoPandasLikeExpr, **constraints: Any) -> Self:
         plx = self.__narwhals_namespace__()
         if (
             len(predicates) == 1
@@ -409,7 +408,7 @@ class PandasLikeDataFrame:
             and all(isinstance(x, bool) for x in predicates[0])
             and not constraints
         ):
-            _mask = predicates[0]
+            mask_native = predicates[0]
         else:
             expr = plx.all_horizontal(
                 *chain(
@@ -418,8 +417,14 @@ class PandasLikeDataFrame:
             )
             # `[0]` is safe as all_horizontal's expression only returns a single column
             mask = expr._call(self)[0]
-            _mask = validate_dataframe_comparand(self._native_frame.index, mask)
-        return self._from_native_frame(self._native_frame.loc[_mask])
+            mask_native = validate_dataframe_comparand(
+                self._native_frame.index,
+                mask,
+                allow_broadcast=False,
+                method_name="filter",
+            )
+
+        return self._from_native_frame(self._native_frame.loc[mask_native])
 
     def with_columns(
         self,
@@ -438,13 +443,21 @@ class PandasLikeDataFrame:
             if name in new_column_name_to_new_column_map:
                 to_concat.append(
                     validate_dataframe_comparand(
-                        index, new_column_name_to_new_column_map.pop(name)
+                        index,
+                        new_column_name_to_new_column_map.pop(name),
+                        allow_broadcast=True,
+                        method_name="with_columns",
                     )
                 )
             else:
                 to_concat.append(self._native_frame[name])
         to_concat.extend(
-            validate_dataframe_comparand(index, new_column_name_to_new_column_map[s])
+            validate_dataframe_comparand(
+                index,
+                new_column_name_to_new_column_map[s],
+                allow_broadcast=True,
+                method_name="with_columns",
+            )
             for s in new_column_name_to_new_column_map
         )
 
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index 3519674d..6eceaaac 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -16,6 +16,7 @@ from narwhals._pandas_like.series import PandasLikeSeries
 from narwhals._pandas_like.utils import rename
 from narwhals.dependencies import get_numpy
 from narwhals.dependencies import is_numpy_array
+from narwhals.exceptions import AnonymousExprError
 from narwhals.exceptions import ColumnNotFoundError
 from narwhals.typing import CompliantExpr
 
@@ -399,12 +400,8 @@ class PandasLikeExpr(CompliantExpr[PandasLikeSeries]):
                     self._output_names is None or self._root_names is None
                 ):  # pragma: no cover
                     # Technically unreachable, but keep this for safety
-                    msg = (
-                        "Anonymous expressions are not supported in over.\n"
-                        "Instead of `nw.all()`, try using a named expression, such as "
-                        "`nw.col('a', 'b')`\n"
-                    )
-                    raise ValueError(msg)
+                    msg = "over"
+                    raise AnonymousExprError.from_expr_name(msg)
 
                 reverse = self._kwargs.get("reverse", False)
                 if reverse:
@@ -452,12 +449,8 @@ class PandasLikeExpr(CompliantExpr[PandasLikeSeries]):
 
             def func(df: PandasLikeDataFrame) -> list[PandasLikeSeries]:
                 if self._output_names is None:
-                    msg = (
-                        "Anonymous expressions are not supported in over.\n"
-                        "Instead of `nw.all()`, try using a named expression, such as "
-                        "`nw.col('a', 'b')`\n"
-                    )
-                    raise ValueError(msg)
+                    msg = "over"
+                    raise AnonymousExprError.from_expr_name(msg)
                 tmp = df.group_by(*keys, drop_null_keys=False).agg(self)
                 tmp = df.select(*keys).join(
                     tmp, how="left", left_on=keys, right_on=keys, suffix="_right"
diff --git a/narwhals/_pandas_like/expr_name.py b/narwhals/_pandas_like/expr_name.py
index 0050e698..fb56c944 100644
--- a/narwhals/_pandas_like/expr_name.py
+++ b/narwhals/_pandas_like/expr_name.py
@@ -3,6 +3,8 @@ from __future__ import annotations
 from typing import TYPE_CHECKING
 from typing import Callable
 
+from narwhals.exceptions import AnonymousExprError
+
 if TYPE_CHECKING:
     from typing_extensions import Self
 
@@ -17,12 +19,8 @@ class PandasLikeExprNameNamespace:
         root_names = self._compliant_expr._root_names
 
         if root_names is None:
-            msg = (
-                "Anonymous expressions are not supported in `.name.keep`.\n"
-                "Instead of `nw.all()`, try using a named expression, such as "
-                "`nw.col('a', 'b')`\n"
-            )
-            raise ValueError(msg)
+            msg = ".name.keep"
+            raise AnonymousExprError.from_expr_name(msg)
 
         return self._compliant_expr.__class__(
             lambda df: [
@@ -43,12 +41,8 @@ class PandasLikeExprNameNamespace:
         root_names = self._compliant_expr._root_names
 
         if root_names is None:
-            msg = (
-                "Anonymous expressions are not supported in `.name.map`.\n"
-                "Instead of `nw.all()`, try using a named expression, such as "
-                "`nw.col('a', 'b')`\n"
-            )
-            raise ValueError(msg)
+            msg = ".name.map"
+            raise AnonymousExprError.from_expr_name(msg)
 
         output_names = [function(str(name)) for name in root_names]
 
@@ -70,12 +64,8 @@ class PandasLikeExprNameNamespace:
     def prefix(self: Self, prefix: str) -> PandasLikeExpr:
         root_names = self._compliant_expr._root_names
         if root_names is None:
-            msg = (
-                "Anonymous expressions are not supported in `.name.prefix`.\n"
-                "Instead of `nw.all()`, try using a named expression, such as "
-                "`nw.col('a', 'b')`\n"
-            )
-            raise ValueError(msg)
+            msg = ".name.prefix"
+            raise AnonymousExprError.from_expr_name(msg)
 
         output_names = [prefix + str(name) for name in root_names]
         return self._compliant_expr.__class__(
@@ -96,12 +86,8 @@ class PandasLikeExprNameNamespace:
     def suffix(self: Self, suffix: str) -> PandasLikeExpr:
         root_names = self._compliant_expr._root_names
         if root_names is None:
-            msg = (
-                "Anonymous expressions are not supported in `.name.suffix`.\n"
-                "Instead of `nw.all()`, try using a named expression, such as "
-                "`nw.col('a', 'b')`\n"
-            )
-            raise ValueError(msg)
+            msg = ".name.suffix"
+            raise AnonymousExprError.from_expr_name(msg)
 
         output_names = [str(name) + suffix for name in root_names]
 
@@ -124,12 +110,9 @@ class PandasLikeExprNameNamespace:
         root_names = self._compliant_expr._root_names
 
         if root_names is None:
-            msg = (
-                "Anonymous expressions are not supported in `.name.to_lowercase`.\n"
-                "Instead of `nw.all()`, try using a named expression, such as "
-                "`nw.col('a', 'b')`\n"
-            )
-            raise ValueError(msg)
+            msg = ".name.to_lowercase"
+            raise AnonymousExprError.from_expr_name(msg)
+
         output_names = [str(name).lower() for name in root_names]
 
         return self._compliant_expr.__class__(
@@ -151,12 +134,9 @@ class PandasLikeExprNameNamespace:
         root_names = self._compliant_expr._root_names
 
         if root_names is None:
-            msg = (
-                "Anonymous expressions are not supported in `.name.to_uppercase`.\n"
-                "Instead of `nw.all()`, try using a named expression, such as "
-                "`nw.col('a', 'b')`\n"
-            )
-            raise ValueError(msg)
+            msg = ".name.to_uppercase"
+            raise AnonymousExprError.from_expr_name(msg)
+
         output_names = [str(name).upper() for name in root_names]
 
         return self._compliant_expr.__class__(
diff --git a/narwhals/_pandas_like/group_by.py b/narwhals/_pandas_like/group_by.py
index a1eca5b5..51efb1d8 100644
--- a/narwhals/_pandas_like/group_by.py
+++ b/narwhals/_pandas_like/group_by.py
@@ -15,6 +15,7 @@ from narwhals._pandas_like.utils import horizontal_concat
 from narwhals._pandas_like.utils import native_series_from_iterable
 from narwhals._pandas_like.utils import select_columns_by_name
 from narwhals._pandas_like.utils import set_columns
+from narwhals.exceptions import AnonymousExprError
 from narwhals.utils import Implementation
 from narwhals.utils import find_stacklevel
 from narwhals.utils import remove_prefix
@@ -92,12 +93,9 @@ class PandasLikeGroupBy:
         output_names: list[str] = copy(self._keys)
         for expr in exprs:
             if expr._output_names is None:
-                msg = (
-                    "Anonymous expressions are not supported in group_by.agg.\n"
-                    "Instead of `nw.all()`, try using a named expression, such as "
-                    "`nw.col('a', 'b')`\n"
-                )
-                raise ValueError(msg)
+                msg = "group_by.agg"
+                raise AnonymousExprError.from_expr_name(msg)
+
             output_names.extend(expr._output_names)
 
         return agg_pandas(
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/_pandas_like/utils.py b/narwhals/_pandas_like/utils.py
index 08d49058..4eec710a 100644
--- a/narwhals/_pandas_like/utils.py
+++ b/narwhals/_pandas_like/utils.py
@@ -17,6 +17,7 @@ from narwhals._arrow.utils import (
     native_to_narwhals_dtype as arrow_native_to_narwhals_dtype,
 )
 from narwhals.exceptions import ColumnNotFoundError
+from narwhals.exceptions import ShapeError
 from narwhals.utils import Implementation
 from narwhals.utils import import_dtypes_module
 from narwhals.utils import isinstance_or_issubclass
@@ -152,7 +153,9 @@ def broadcast_align_and_extract_native(
     return lhs._native_series, rhs
 
 
-def validate_dataframe_comparand(index: Any, other: Any) -> Any:
+def validate_dataframe_comparand(
+    index: Any, other: Any, *, allow_broadcast: bool, method_name: str
+) -> Any:
     """Validate RHS of binary operation.
 
     If the comparison isn't supported, return `NotImplemented` so that the
@@ -164,10 +167,27 @@ def validate_dataframe_comparand(index: Any, other: Any) -> Any:
     if isinstance(other, PandasLikeDataFrame):
         return NotImplemented
     if isinstance(other, PandasLikeSeries):
-        if other.len() == 1:
+        len_index = len(index)
+        len_other = other.len()
+
+        if len_other == 1:
+            if len_index > 1 and not allow_broadcast:
+                msg = (
+                    f"{method_name}'s length: 1 differs from that of the series: "
+                    f"{len_index}"
+                )
+                raise ShapeError(msg)
             # broadcast
             s = other._native_series
             return s.__class__(s.iloc[0], index=index, dtype=s.dtype, name=s.name)
+
+        if len_index != len_other:
+            msg = (
+                f"{method_name}'s length: {len_other} differs from that of the series: "
+                f"{len_index}"
+            )
+            raise ShapeError(msg)
+
         if other._native_series.index is not index:
             return set_index(
                 other._native_series,
diff --git a/narwhals/_spark_like/dataframe.py b/narwhals/_spark_like/dataframe.py
index 7e7f41dd..9d302014 100644
--- a/narwhals/_spark_like/dataframe.py
+++ b/narwhals/_spark_like/dataframe.py
@@ -108,7 +108,7 @@ class SparkLikeLazyFrame:
         new_columns_list = [col.alias(col_name) for col_name, col in new_columns.items()]
         return self._from_native_frame(self._native_frame.select(*new_columns_list))
 
-    def filter(self, *predicates: SparkLikeExpr, **constraints: Any) -> Self:
+    def filter(self: Self, *predicates: SparkLikeExpr, **constraints: Any) -> Self:
         plx = self.__narwhals_namespace__()
         expr = plx.all_horizontal(
             *chain(predicates, (plx.col(name) == v for name, v in constraints.items()))
@@ -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/_spark_like/group_by.py b/narwhals/_spark_like/group_by.py
index 66f4bf2b..575163fe 100644
--- a/narwhals/_spark_like/group_by.py
+++ b/narwhals/_spark_like/group_by.py
@@ -11,6 +11,7 @@ from narwhals._expression_parsing import is_simple_aggregation
 from narwhals._expression_parsing import parse_into_exprs
 from narwhals._spark_like.utils import _std
 from narwhals._spark_like.utils import _var
+from narwhals.exceptions import AnonymousExprError
 from narwhals.utils import parse_version
 from narwhals.utils import remove_prefix
 
@@ -55,12 +56,8 @@ class SparkLikeLazyGroupBy:
         output_names: list[str] = copy(self._keys)
         for expr in exprs:
             if expr._output_names is None:  # pragma: no cover
-                msg = (
-                    "Anonymous expressions are not supported in group_by.agg.\n"
-                    "Instead of `nw.all()`, try using a named expression, such as "
-                    "`nw.col('a', 'b')`\n"
-                )
-                raise ValueError(msg)
+                msg = "group_by.agg"
+                raise AnonymousExprError.from_expr_name(msg)
 
             output_names.extend(expr._output_names)
 
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/exceptions.py b/narwhals/exceptions.py
index ee4b79b6..18a225c8 100644
--- a/narwhals/exceptions.py
+++ b/narwhals/exceptions.py
@@ -66,5 +66,22 @@ class InvalidIntoExprError(TypeError):
         return InvalidIntoExprError(message)
 
 
+class AnonymousExprError(ValueError):
+    """Exception raised when trying to perform operations on anonymous expressions."""
+
+    def __init__(self, message: str) -> None:
+        self.message = message
+        super().__init__(self.message)
+
+    @classmethod
+    def from_expr_name(cls, expr_name: str) -> AnonymousExprError:
+        message = (
+            f"Anonymous expressions are not supported in `{expr_name}`.\n"
+            "Instead of `nw.all()`, try using a named expression, such as "
+            "`nw.col('a', 'b')`"
+        )
+        return AnonymousExprError(message)
+
+
 class NarwhalsUnstableWarning(UserWarning):
     """Warning issued when a method or function is considered unstable in the stable api."""
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/.github/workflows/downstream_tests.yml b/.github/workflows/downstream_tests.yml
index 5ad95b6d..647640dd 100644
--- a/.github/workflows/downstream_tests.yml
+++ b/.github/workflows/downstream_tests.yml
@@ -1,6 +1,6 @@
 name: Test Downstream Libraries - Fast
 
-on: 
+on:
   pull_request:
   push:
     branches: [main]
@@ -26,31 +26,31 @@ jobs:
           cache-dependency-glob: "pyproject.toml"
       - name: clone-altair
         run: |
-            git clone https://github.com/vega/altair.git --depth=1
-            cd altair
-            git log
+          git clone https://github.com/vega/altair.git --depth=1
+          cd altair
+          git log
       - name: install-basics
         run: uv pip install --upgrade tox virtualenv setuptools --system
       - name: install-altair-dev
         run: |
-            cd altair
-            uv pip install -e ".[dev, all]" --system
-            # temporary pin to get ci green
-            uv pip install "numpy<2.2" --system
+          cd altair
+          uv pip install -e ".[dev, all]" --system
+          # temporary pin to get ci green
+          uv pip install "numpy<2.2" --system
       - name: install-narwhals-dev
         run: |
-            uv pip uninstall narwhals --system
-            uv pip install -e . --system
+          uv pip uninstall narwhals --system
+          uv pip install -e . --system
       - name: show-deps
         run: uv pip freeze
       - name: Run pytest
         run: |
-            cd altair
-            pytest tests
+          cd altair
+          pytest tests
       - name: Run mypy
         run: |
-            cd altair
-            mypy altair tests
+          cd altair
+          mypy altair tests
 
   marimo:
     strategy:
@@ -73,45 +73,45 @@ jobs:
           cache-dependency-glob: "pyproject.toml"
       - name: clone-marimo
         run: |
-            git clone https://github.com/marimo-team/marimo.git --depth=1
-            cd marimo
-            uv venv -p 3.12
-            git log
+          git clone https://github.com/marimo-team/marimo.git --depth=1
+          cd marimo
+          uv venv -p 3.12
+          git log
       - name: install-basics
         run: uv pip install --upgrade tox virtualenv setuptools hatch --system
       - name: install-marimo-dev
         run: |
-            cd marimo
-            . .venv/bin/activate
-            uv pip install -e ".[dev]"
-            which python
+          cd marimo
+          . .venv/bin/activate
+          uv pip install -e ".[dev]"
+          which python
       - name: install-narwhals-dev
         run: |
-            cd marimo
-            . .venv/bin/activate
-            uv pip uninstall narwhals
-            uv pip install -e ./..
+          cd marimo
+          . .venv/bin/activate
+          uv pip uninstall narwhals
+          uv pip install -e ./..
       - name: show-deps
         run: |
-            cd marimo
-            . .venv/bin/activate
-            uv pip freeze
+          cd marimo
+          . .venv/bin/activate
+          uv pip freeze
       - name: Create assets directory, copy over index.html
         continue-on-error: true
         run: |
-            mkdir -p marimo/marimo/_static/assets
-            cp marimo/frontend/index.html marimo/marimo/_static/index.html
-            cp marimo/frontend/public/favicon.ico marimo/marimo/_static/favicon.ico
+          mkdir -p marimo/marimo/_static/assets
+          cp marimo/frontend/index.html marimo/marimo/_static/index.html
+          cp marimo/frontend/public/favicon.ico marimo/marimo/_static/favicon.ico
       - name: Run tests with full dependencies
         if: ${{ matrix.dependencies == 'core,optional' }}
         run: |
-            cd marimo
-            . .venv/bin/activate
-            # make sure that we use the .venv when running tests, so that
-            # the local narwhals install is picked up
-            sed -i '/^\[tool.hatch.envs.default\]/a path = ".venv"' pyproject.toml
-            hatch run python -c "import narwhals; print(narwhals.__file__)"
-            hatch run test-optional:test-narwhals
+          cd marimo
+          . .venv/bin/activate
+          # make sure that we use the .venv when running tests, so that
+          # the local narwhals install is picked up
+          sed -i '/^\[tool.hatch.envs.default\]/a path = ".venv"' pyproject.toml
+          hatch run python -c "import narwhals; print(narwhals.__file__)"
+          hatch run test-optional:test-narwhals
         timeout-minutes: 15
 
   scikit-lego:
@@ -138,18 +138,18 @@ jobs:
         run: uv pip install --upgrade tox virtualenv setuptools --system
       - name: install-scikit-lego-dev
         run: |
-            cd scikit-lego
-            uv pip install -e ".[test]" --system
+          cd scikit-lego
+          uv pip install -e ".[test]" --system
       - name: install-narwhals-dev
         run: |
-            uv pip uninstall narwhals --system
-            uv pip install -e . --system
+          uv pip uninstall narwhals --system
+          uv pip install -e . --system
       - name: show-deps
         run: uv pip freeze
       - name: Run pytest
         run: |
-            cd scikit-lego
-            pytest -n auto --disable-warnings --cov=sklego -m "not cvxpy and not formulaic and not umap"
+          cd scikit-lego
+          pytest -n auto --disable-warnings --cov=sklego -m "not cvxpy and not formulaic and not umap"
 
   shiny:
     strategy:
@@ -171,28 +171,28 @@ jobs:
           cache-dependency-glob: "pyproject.toml"
       - name: clone-shiny
         run: |
-            git clone https://github.com/posit-dev/py-shiny.git
-            cd py-shiny
-            git log
+          git clone https://github.com/posit-dev/py-shiny.git
+          cd py-shiny
+          git log
       - name: install-basics
         run: uv pip install --upgrade tox virtualenv setuptools --system
       - name: install-shiny-dev
         env:
           UV_SYSTEM_PYTHON: 1
         run: |
-            cd py-shiny
-            make narwhals-install-shiny
+          cd py-shiny
+          make narwhals-install-shiny
       - name: install-narwhals-dev
         run: |
-            uv pip uninstall narwhals --system
-            uv pip install -e . --system
+          uv pip uninstall narwhals --system
+          uv pip install -e . --system
       - name: show-deps
         run: uv pip freeze
       - name: Run `make narwhals-test-integration`
         run: |
-            cd py-shiny
-            make narwhals-test-integration
-  
+          cd py-shiny
+          make narwhals-test-integration
+
   tea-tasting:
     strategy:
       matrix:
@@ -203,9 +203,9 @@ jobs:
       - uses: actions/checkout@v4
       - name: clone-tea-tasting
         run: |
-            git clone https://github.com/e10v/tea-tasting.git --depth=1
-            cd tea-tasting
-            git log
+          git clone https://github.com/e10v/tea-tasting.git --depth=1
+          cd tea-tasting
+          git log
       - name: install-pdm
         uses: pdm-project/setup-pdm@v4
         with:
@@ -214,22 +214,22 @@ jobs:
           cache-dependency-path: tea-tasting/pdm.lock
       - name: install-tea-tasting-test
         run: |
-            cd tea-tasting
-            pdm sync -G test
+          cd tea-tasting
+          pdm sync -G test
       - name: install-narwhals-dev
         run: |
-            cd tea-tasting
-            pdm remove narwhals
-            pdm add ./..[dev]
+          cd tea-tasting
+          pdm remove narwhals
+          pdm add ./..[dev]
       - name: show-deps
         run: |
-            cd tea-tasting
-            pdm list
+          cd tea-tasting
+          pdm list
       - name: run-pytest
         run: |
-            cd tea-tasting
-            pdm run test
-  
+          cd tea-tasting
+          pdm run test
+
   tubular:
     strategy:
       matrix:
@@ -250,25 +250,25 @@ jobs:
           cache-dependency-glob: "pyproject.toml"
       - name: clone-tubular
         run: |
-            git clone https://github.com/lvgig/tubular --depth=1
-            cd tubular
-            git log
+          git clone https://github.com/lvgig/tubular --depth=1
+          cd tubular
+          git log
       - name: install-basics
         run: uv pip install --upgrade tox virtualenv setuptools pytest-env --system
       - name: install-tubular-dev
         run: |
-            cd tubular
-            uv pip install -e ".[dev]" --system
+          cd tubular
+          uv pip install -e ".[dev]" --system
       - name: install-narwhals-dev
         run: |
-            uv pip uninstall narwhals --system
-            uv pip install -e . --system
+          uv pip uninstall narwhals --system
+          uv pip install -e . --system
       - name: show-deps
         run: uv pip freeze
       - name: Run pytest
         run: |
-            cd tubular
-            pytest tests --config-file=pyproject.toml
+          cd tubular
+          pytest tests --config-file=pyproject.toml
 
   plotly:
     strategy:
@@ -290,25 +290,25 @@ jobs:
           cache-dependency-glob: "pyproject.toml"
       - name: clone-plotly
         run: |
-            git clone https://github.com/plotly/plotly.py --depth=1
-            cd plotly.py
-            git log
+          git clone https://github.com/plotly/plotly.py --depth=1
+          cd plotly.py
+          git log
       - name: install-basics
         run: uv pip install --upgrade tox virtualenv setuptools --system
       - name: install-deps
         run: |
-            cd plotly.py
-            uv pip install -r packages/python/plotly/test_requirements/requirements_312_optional.txt --system
+          cd plotly.py
+          uv pip install -r packages/python/plotly/test_requirements/requirements_312_optional.txt --system
       - name: install-narwhals-dev
         run: |
-            uv pip uninstall narwhals --system
-            uv pip install -e . --system
+          uv pip uninstall narwhals --system
+          uv pip install -e . --system
       - name: show-deps
         run: uv pip freeze
       - name: Run pytest on plotly express
         run: |
-            cd plotly.py
-            pytest packages/python/plotly/plotly/tests/test_optional/test_px
+          cd plotly.py
+          pytest packages/python/plotly/plotly/tests/test_optional/test_px
 
   hierarchicalforecast:
     strategy:
@@ -330,22 +330,65 @@ jobs:
           cache-dependency-glob: "pyproject.toml"
       - name: clone-hierarchicalforecast
         run: |
-            git clone https://github.com/Nixtla/hierarchicalforecast --depth=1
-            cd hierarchicalforecast
-            git log
+          git clone https://github.com/Nixtla/hierarchicalforecast --depth=1
+          cd hierarchicalforecast
+          git log
       - name: install-basics
         run: uv pip install --upgrade tox virtualenv setuptools --system
       - name: install-deps
         run: |
-            cd hierarchicalforecast
-            uv pip install --system ".[dev]"
+          cd hierarchicalforecast
+          uv pip install --system ".[dev]"
       - name: install-narwhals-dev
         run: |
-            uv pip uninstall narwhals --system
-            uv pip install -e . --system
+          uv pip uninstall narwhals --system
+          uv pip install -e . --system
       - name: show-deps
         run: uv pip freeze
       - name: Run tests
         run: |
-            cd hierarchicalforecast
-            nbdev_test --do_print --timing --n_workers 0
+          cd hierarchicalforecast
+          nbdev_test --do_print --timing --n_workers 0
+
+  formulaic:
+    strategy:
+      matrix:
+        python-version: ["3.12"]
+        os: [ubuntu-latest]
+
+    runs-on: ${{ matrix.os }}
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install uv
+        uses: astral-sh/setup-uv@v5
+        with:
+          enable-cache: "true"
+          cache-suffix: ${{ matrix.python-version }}
+          cache-dependency-glob: "pyproject.toml"
+      - name: clone-formulaic
+        run: |
+          git clone https://github.com/matthewwardrop/formulaic.git --depth=1
+          cd formulaic
+          git log
+      - name: create-hatch-env
+        run: |
+          cd formulaic
+          uv pip install hatch hatch-vcs --system
+      - name: install-formulaic-dev
+        run: |
+          cd formulaic
+          hatch run uv pip install -e ".[arrow,calculus]"
+      - name: install-narwhals-dev
+        run: |
+          cd formulaic
+          hatch run uv pip uninstall narwhals
+          hatch run uv pip install -e ./..
+      - name: show-deps
+        run: hatch run uv pip freeze
+      - name: run test
+        run: |
+          cd formulaic
+          hatch run tests --config-file=pyproject.toml
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/name/keep_test.py b/tests/expr_and_series/name/keep_test.py
index e382db73..54219dae 100644
--- a/tests/expr_and_series/name/keep_test.py
+++ b/tests/expr_and_series/name/keep_test.py
@@ -6,6 +6,7 @@ import polars as pl
 import pytest
 
 import narwhals.stable.v1 as nw
+from narwhals.exceptions import AnonymousExprError
 from tests.utils import Constructor
 from tests.utils import assert_equal_data
 
@@ -47,7 +48,7 @@ def test_keep_raise_anonymous(
         does_not_raise()
         if isinstance(df_raw, (pl.LazyFrame, pl.DataFrame))
         else pytest.raises(
-            ValueError,
+            AnonymousExprError,
             match="Anonymous expressions are not supported in `.name.keep`.",
         )
     )
diff --git a/tests/expr_and_series/name/map_test.py b/tests/expr_and_series/name/map_test.py
index 276138ef..d86a12fb 100644
--- a/tests/expr_and_series/name/map_test.py
+++ b/tests/expr_and_series/name/map_test.py
@@ -6,6 +6,7 @@ import polars as pl
 import pytest
 
 import narwhals.stable.v1 as nw
+from narwhals.exceptions import AnonymousExprError
 from tests.utils import Constructor
 from tests.utils import assert_equal_data
 
@@ -51,7 +52,7 @@ def test_map_raise_anonymous(
         does_not_raise()
         if isinstance(df_raw, (pl.LazyFrame, pl.DataFrame))
         else pytest.raises(
-            ValueError,
+            AnonymousExprError,
             match="Anonymous expressions are not supported in `.name.map`.",
         )
     )
diff --git a/tests/expr_and_series/name/prefix_test.py b/tests/expr_and_series/name/prefix_test.py
index 934d1d66..0f6ff2f6 100644
--- a/tests/expr_and_series/name/prefix_test.py
+++ b/tests/expr_and_series/name/prefix_test.py
@@ -6,6 +6,7 @@ import polars as pl
 import pytest
 
 import narwhals.stable.v1 as nw
+from narwhals.exceptions import AnonymousExprError
 from tests.utils import Constructor
 from tests.utils import assert_equal_data
 
@@ -48,7 +49,7 @@ def test_prefix_raise_anonymous(
         does_not_raise()
         if isinstance(df_raw, (pl.LazyFrame, pl.DataFrame))
         else pytest.raises(
-            ValueError,
+            AnonymousExprError,
             match="Anonymous expressions are not supported in `.name.prefix`.",
         )
     )
diff --git a/tests/expr_and_series/name/to_lowercase_test.py b/tests/expr_and_series/name/to_lowercase_test.py
index 1b39fc72..191e7227 100644
--- a/tests/expr_and_series/name/to_lowercase_test.py
+++ b/tests/expr_and_series/name/to_lowercase_test.py
@@ -6,6 +6,7 @@ import polars as pl
 import pytest
 
 import narwhals.stable.v1 as nw
+from narwhals.exceptions import AnonymousExprError
 from tests.utils import Constructor
 from tests.utils import assert_equal_data
 
@@ -47,7 +48,7 @@ def test_to_lowercase_raise_anonymous(
         does_not_raise()
         if isinstance(df_raw, (pl.LazyFrame, pl.DataFrame))
         else pytest.raises(
-            ValueError,
+            AnonymousExprError,
             match="Anonymous expressions are not supported in `.name.to_lowercase`.",
         )
     )
diff --git a/tests/expr_and_series/name/to_uppercase_test.py b/tests/expr_and_series/name/to_uppercase_test.py
index e6703212..b0e49c7d 100644
--- a/tests/expr_and_series/name/to_uppercase_test.py
+++ b/tests/expr_and_series/name/to_uppercase_test.py
@@ -6,6 +6,7 @@ import polars as pl
 import pytest
 
 import narwhals.stable.v1 as nw
+from narwhals.exceptions import AnonymousExprError
 from tests.utils import Constructor
 from tests.utils import assert_equal_data
 
@@ -44,7 +45,7 @@ def test_to_uppercase_raise_anonymous(
         does_not_raise()
         if isinstance(df_raw, (pl.LazyFrame, pl.DataFrame))
         else pytest.raises(
-            ValueError,
+            AnonymousExprError,
             match="Anonymous expressions are not supported in `.name.to_uppercase`.",
         )
     )
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)
diff --git a/tests/frame/filter_test.py b/tests/frame/filter_test.py
index 759d175c..922af0e6 100644
--- a/tests/frame/filter_test.py
+++ b/tests/frame/filter_test.py
@@ -3,8 +3,10 @@ from __future__ import annotations
 from contextlib import nullcontext as does_not_raise
 
 import pytest
+from polars.exceptions import ShapeError as PlShapeError
 
 import narwhals.stable.v1 as nw
+from narwhals.exceptions import ShapeError
 from tests.utils import Constructor
 from tests.utils import assert_equal_data
 
@@ -33,3 +35,40 @@ def test_filter_with_boolean_list(
         result = df.filter([False, True, True])
         expected = {"a": [3, 2], "b": [4, 6], "z": [8.0, 9.0]}
         assert_equal_data(result, expected)
+
+
+def test_filter_raise_on_agg_predicate(constructor: Constructor) -> None:
+    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
+    df = nw.from_native(constructor(data))
+
+    context = (
+        pytest.raises(
+            ShapeError,
+            match="filter's length: 1 differs from that of the series: 3",
+        )
+        if any(x in str(constructor) for x in ("pandas", "pyarrow", "modin"))
+        else does_not_raise()
+        if "polars" in str(constructor)
+        else pytest.raises(Exception)  # type: ignore[arg-type] # noqa: PT011
+    )
+    with context:
+        df.filter(nw.col("a").max() > 2).lazy().collect()
+
+
+def test_filter_raise_on_shape_mismatch(constructor: Constructor) -> None:
+    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
+    df = nw.from_native(constructor(data))
+
+    context = (
+        pytest.raises(
+            ShapeError, match="filter's length: 2 differs from that of the series: 3"
+        )
+        if any(x in str(constructor) for x in ("pandas", "pyarrow", "modin"))
+        else pytest.raises(
+            PlShapeError, match="filter's length: 2 differs from that of the series: 3"
+        )
+        if "polars" in str(constructor)
+        else pytest.raises(Exception)  # type: ignore[arg-type] # noqa: PT011
+    )
+    with context:
+        df.filter(nw.col("b").unique() > 2).lazy().collect()
diff --git a/tests/group_by_test.py b/tests/group_by_test.py
index 911cc473..40acd142 100644
--- a/tests/group_by_test.py
+++ b/tests/group_by_test.py
@@ -8,6 +8,7 @@ import pyarrow as pa
 import pytest
 
 import narwhals.stable.v1 as nw
+from narwhals.exceptions import AnonymousExprError
 from tests.utils import PANDAS_VERSION
 from tests.utils import PYARROW_VERSION
 from tests.utils import Constructor
@@ -49,7 +50,8 @@ def test_invalid_group_by_dask() -> None:
         nw.from_native(df_dask).group_by("a").agg(nw.col("b"))
 
     with pytest.raises(
-        ValueError, match=r"Anonymous expressions are not supported in group_by\.agg"
+        AnonymousExprError,
+        match=r"Anonymous expressions are not supported in `group_by\.agg`",
     ):
         nw.from_native(df_dask).group_by("a").agg(nw.all().mean())
 
@@ -60,11 +62,13 @@ def test_invalid_group_by() -> None:
     with pytest.raises(ValueError, match="does your"):
         df.group_by("a").agg(nw.col("b"))
     with pytest.raises(
-        ValueError, match=r"Anonymous expressions are not supported in group_by\.agg"
+        AnonymousExprError,
+        match=r"Anonymous expressions are not supported in `group_by\.agg`",
     ):
         df.group_by("a").agg(nw.all().mean())
     with pytest.raises(
-        ValueError, match=r"Anonymous expressions are not supported in group_by\.agg"
+        AnonymousExprError,
+        match=r"Anonymous expressions are not supported in `group_by\.agg`",
     ):
         nw.from_native(pa.table({"a": [1, 2, 3]})).group_by("a").agg(nw.all().mean())
     with pytest.raises(ValueError, match=r"Non-trivial complex aggregation found"):