Repository: narwhals-dev/narwhals
Pull request #1986 (2025-02-10 13:53:12)

chore: Expr metadata refactor
First step towards #1848 

No noticeable difference in perf when running this locally

Theoretically, I'd expect it to make things not-worse, as we're not only looping through `args` once to construct `ExprMetadata`, as opposed to 4 times

But the main reason to do this is to aim towards #1848 , after which, implementations at the compliant level should become much simpler and easier to reason about

<!--
# Thanks for contributing a pull request! 
## Please make sure you see our contribution guidelines: https://github.com/narwhals-dev/narwhals/blob/main/CONTRIBUTING.md
-->

## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [ ] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues

- Related issue #\<issue number\>
- Closes #\<issue number\>

## Checklist

- [ ] Code follows style guide (ruff)
- [ ] Tests added
- [ ] Documented the changes

## If you have comments or can explain your changes, please do so below



Patch:
diff --git a/narwhals/_expression_parsing.py b/narwhals/_expression_parsing.py
index 2c5b8258..443c0ebd 100644
--- a/narwhals/_expression_parsing.py
+++ b/narwhals/_expression_parsing.py
@@ -3,10 +3,13 @@
 # and pandas or PyArrow.
 from __future__ import annotations
 
+from enum import Enum
+from enum import auto
 from typing import TYPE_CHECKING
 from typing import Any
 from typing import Callable
 from typing import Sequence
+from typing import TypedDict
 from typing import TypeVar
 from typing import Union
 from typing import overload
@@ -14,6 +17,7 @@ from typing import overload
 from narwhals.dependencies import is_numpy_array
 from narwhals.exceptions import InvalidIntoExprError
 from narwhals.exceptions import LengthChangingExprError
+from narwhals.exceptions import ShapeError
 from narwhals.utils import Implementation
 from narwhals.utils import is_compliant_expr
 from narwhals.utils import is_compliant_series
@@ -21,6 +25,7 @@ from narwhals.utils import is_compliant_series
 if TYPE_CHECKING:
     from narwhals._arrow.expr import ArrowExpr
     from narwhals._pandas_like.expr import PandasLikeExpr
+    from narwhals.expr import Expr
     from narwhals.typing import CompliantDataFrame
     from narwhals.typing import CompliantExpr
     from narwhals.typing import CompliantLazyFrame
@@ -318,67 +323,20 @@ def extract_compliant(
     plx: CompliantNamespace[CompliantSeriesT_co],
     other: Any,
     *,
-    parse_column_name_as_expr: bool,
+    strings_are_column_names: bool,
 ) -> CompliantExpr[CompliantSeriesT_co] | CompliantSeriesT_co | Any:
     from narwhals.expr import Expr
     from narwhals.series import Series
 
     if isinstance(other, Expr):
         return other._to_compliant_expr(plx)
-    if parse_column_name_as_expr and isinstance(other, str):
+    if strings_are_column_names and isinstance(other, str):
         return plx.col(other)
     if isinstance(other, Series):
         return other._compliant_series
     return other
 
 
-def operation_is_order_dependent(*args: IntoExpr | Any) -> bool:
-    # If an arg is an Expr, we look at `_is_order_dependent`. If it isn't,
-    # it means that it was a scalar (e.g. nw.col('a') + 1) or a column name,
-    # neither of which is order-dependent, so we default to `False`.
-    return any(getattr(x, "_is_order_dependent", False) for x in args)
-
-
-def operation_changes_length(*args: IntoExpr | Any) -> bool:
-    """Track whether operation changes length.
-
-    n-ary operations between expressions which change length are not
-    allowed. This is because the output might be non-relational. For
-    example:
-        df = pl.LazyFrame({'a': [1,2,None], 'b': [4,None,6]})
-        df.select(pl.col('a', 'b').drop_nulls())
-    Polars does allow this, but in the result we end up with the
-    tuple (2, 6) which wasn't part of the original data.
-
-    Rules are:
-        - in an n-ary operation, if any one of them changes length, then
-          it must be the only expression present
-        - in a comparison between a changes-length expression and a
-          scalar, the output changes length
-    """
-    from narwhals.expr import Expr
-
-    n_exprs = len([x for x in args if isinstance(x, Expr)])
-    changes_length = any(isinstance(x, Expr) and x._changes_length for x in args)
-    if n_exprs > 1 and changes_length:
-        msg = (
-            "Found multiple expressions at least one of which changes length.\n"
-            "Any length-changing expression can only be used in isolation, unless\n"
-            "it is followed by an aggregation."
-        )
-        raise LengthChangingExprError(msg)
-    return changes_length
-
-
-def operation_aggregates(*args: IntoExpr | Any) -> bool:
-    # If an arg is an Expr, we look at `_aggregates`. If it isn't,
-    # it means that it was a scalar (e.g. nw.col('a').sum() + 1),
-    # which is already length-1, so we default to `True`. If any
-    # expression does not aggregate, then broadcasting will take
-    # place and the result will not be an aggregate.
-    return all(getattr(x, "_aggregates", True) for x in args)
-
-
 def evaluate_output_names_and_aliases(
     expr: CompliantExpr[Any],
     df: CompliantDataFrame | CompliantLazyFrame,
@@ -399,3 +357,110 @@ def evaluate_output_names_and_aliases(
             *[(x, alias) for x, alias in zip(output_names, aliases) if x not in exclude]
         )
     return output_names, aliases
+
+
+def operation_is_order_dependent(*args: IntoExpr | Any) -> bool:
+    from narwhals.expr import Expr
+
+    return any(isinstance(x, Expr) and x._metadata["is_order_dependent"] for x in args)
+
+
+class ExprKind(Enum):
+    """Describe which kind of expression we are dealing with.
+
+    Commutative composition rules are:
+    - LITERAL vs LITERAL -> LITERAL
+    - CHANGES_LENGTH vs (LITERAL | AGGREGATION) -> CHANGES_LENGTH
+    - CHANGES_LENGTH vs (CHANGES_LENGTH | TRANSFORM) -> raise
+    - TRANSFORM vs (LITERAL | AGGREGATION) -> TRANSFORM
+    - AGGREGATION vs (LITERAL | AGGREGATION) -> AGGREGATION
+    """
+
+    LITERAL = auto()  # e.g. nw.lit(1)
+    AGGREGATION = auto()  # e.g. nw.col('a').mean()
+    TRANSFORM = auto()  # length-preserving, e.g. nw.col('a').round()
+    CHANGES_LENGTH = auto()  # e.g. nw.col('a').drop_nulls()
+
+
+class ExprMetadata(TypedDict):
+    kind: ExprKind
+    is_order_dependent: bool
+
+
+def combine_metadata(*args: IntoExpr, strings_are_column_names: bool) -> ExprMetadata:
+    # Combine metadata from `args`.
+    from narwhals.expr import Expr
+
+    n_changes_length = 0
+    has_transforms = False
+    has_aggregations = False
+    has_literals = False
+    result_is_order_dependent = False
+
+    for arg in args:
+        if isinstance(arg, str) and strings_are_column_names:
+            has_transforms = True
+        elif isinstance(arg, Expr):
+            if arg._metadata["is_order_dependent"]:
+                result_is_order_dependent = True
+            kind = arg._metadata["kind"]
+            if kind is ExprKind.AGGREGATION:
+                has_aggregations = True
+            elif kind is ExprKind.LITERAL:
+                has_literals = True
+            elif kind is ExprKind.CHANGES_LENGTH:
+                n_changes_length += 1
+            elif kind is ExprKind.TRANSFORM:
+                has_transforms = True
+            else:  # pragma: no cover
+                msg = "unreachable code"
+                raise AssertionError(msg)
+    if (
+        has_literals
+        and not has_aggregations
+        and not has_transforms
+        and not n_changes_length
+    ):
+        result_kind = ExprKind.LITERAL
+    elif n_changes_length > 1:
+        msg = "Length-changing expressions can only be used in isolation, or followed by an aggregation"
+        raise LengthChangingExprError(msg)
+    elif n_changes_length and has_transforms:
+        msg = "Cannot combine length-changing expressions with length-preserving ones"
+        raise ShapeError(msg)
+    elif n_changes_length:
+        result_kind = ExprKind.CHANGES_LENGTH
+    elif has_transforms:
+        result_kind = ExprKind.TRANSFORM
+    else:
+        result_kind = ExprKind.AGGREGATION
+
+    return ExprMetadata(kind=result_kind, is_order_dependent=result_is_order_dependent)
+
+
+def check_expressions_transform(*args: IntoExpr, function_name: str) -> None:
+    # Raise if any argument in `args` isn't length-preserving.
+    # For Series input, we don't raise (yet), we let such checks happen later,
+    # as this function works lazily and so can't evaluate lengths.
+    from narwhals.expr import Expr
+    from narwhals.series import Series
+
+    if not all(
+        (isinstance(x, Expr) and x._metadata["kind"] is ExprKind.TRANSFORM)
+        or isinstance(x, (str, Series))
+        for x in args
+    ):
+        msg = f"Expressions which aggregate or change length cannot be passed to '{function_name}'."
+        raise ShapeError(msg)
+
+
+def all_expressions_aggregate(*args: Expr, **kwargs: Expr) -> bool:
+    # Raise if any argument in `args` isn't an aggregation or literal.
+    # For Series input, we don't raise (yet), we let such checks happen later,
+    # as this function works lazily and so can't evaluate lengths.
+    return all(
+        x._metadata["kind"] in (ExprKind.AGGREGATION, ExprKind.LITERAL) for x in args
+    ) and all(
+        x._metadata["kind"] in (ExprKind.AGGREGATION, ExprKind.LITERAL)
+        for x in kwargs.values()
+    )
diff --git a/narwhals/_spark_like/dataframe.py b/narwhals/_spark_like/dataframe.py
index 7275687f..ec8045ea 100644
--- a/narwhals/_spark_like/dataframe.py
+++ b/narwhals/_spark_like/dataframe.py
@@ -22,6 +22,7 @@ from narwhals.utils import validate_backend_version
 if TYPE_CHECKING:
     from types import ModuleType
 
+    import pyarrow as pa
     from pyspark.sql import DataFrame
     from typing_extensions import Self
 
@@ -108,6 +109,38 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
             implementation=self._implementation,
         )
 
+    def _collect_to_arrow(self) -> pa.Table:
+        if self._implementation is Implementation.PYSPARK and self._backend_version < (
+            4,
+        ):
+            import pyarrow as pa  # ignore-banned-import
+
+            try:
+                native_pyarrow_frame = pa.Table.from_batches(
+                    self._native_frame._collect_as_arrow()
+                )
+            except ValueError as exc:
+                if "at least one RecordBatch" in str(exc):
+                    # Empty dataframe
+                    from narwhals._arrow.utils import narwhals_to_native_dtype
+
+                    data: dict[str, list[Any]] = {}
+                    schema = []
+                    current_schema = self.collect_schema()
+                    for key, value in current_schema.items():
+                        data[key] = []
+                        schema.append(
+                            (key, narwhals_to_native_dtype(value, self._version))
+                        )
+                    native_pyarrow_frame = pa.Table.from_pydict(
+                        data, schema=pa.schema(schema)
+                    )
+                else:  # pragma: no cover
+                    raise
+        else:
+            native_pyarrow_frame = self._native_frame.toArrow()
+        return native_pyarrow_frame
+
     @property
     def columns(self: Self) -> list[str]:
         return self._native_frame.columns  # type: ignore[no-any-return]
@@ -135,30 +168,8 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
 
             from narwhals._arrow.dataframe import ArrowDataFrame
 
-            try:
-                native_pyarrow_frame = pa.Table.from_batches(
-                    self._native_frame._collect_as_arrow()
-                )
-            except ValueError as exc:
-                if "at least one RecordBatch" in str(exc):
-                    # Empty dataframe
-                    from narwhals._arrow.utils import narwhals_to_native_dtype
-
-                    data: dict[str, list[Any]] = {}
-                    schema = []
-                    current_schema = self.collect_schema()
-                    for key, value in current_schema.items():
-                        data[key] = []
-                        schema.append(
-                            (key, narwhals_to_native_dtype(value, self._version))
-                        )
-                    native_pyarrow_frame = pa.Table.from_pydict(
-                        data, schema=pa.schema(schema)
-                    )
-                else:  # pragma: no cover
-                    raise
             return ArrowDataFrame(
-                native_pyarrow_frame,
+                self._collect_to_arrow(),
                 backend_version=parse_version(pa),
                 version=self._version,
                 validate_column_names=False,
@@ -171,9 +182,7 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
             from narwhals._polars.dataframe import PolarsDataFrame
 
             return PolarsDataFrame(
-                df=pl.from_arrow(  # type: ignore[arg-type]
-                    pa.Table.from_batches(self._native_frame._collect_as_arrow())
-                ),
+                df=pl.from_arrow(self._collect_to_arrow()),  # type: ignore[arg-type]
                 backend_version=parse_version(pl),
                 version=self._version,
             )
@@ -367,7 +376,6 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
                     if colname not in (right_on or [])
                 ]
             )
-
         return self._from_native_frame(
             self_native.join(other, on=left_on, how=how).select(col_order)
         )
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index 465da71e..f00a6dc6 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -14,6 +14,8 @@ from typing import TypeVar
 from typing import overload
 from warnings import warn
 
+from narwhals._expression_parsing import ExprKind
+from narwhals._expression_parsing import check_expressions_transform
 from narwhals.dependencies import get_polars
 from narwhals.dependencies import is_numpy_array
 from narwhals.dependencies import is_numpy_array_1d
@@ -21,7 +23,6 @@ from narwhals.exceptions import ColumnNotFoundError
 from narwhals.exceptions import InvalidIntoExprError
 from narwhals.exceptions import LengthChangingExprError
 from narwhals.exceptions import OrderDependentExprError
-from narwhals.exceptions import ShapeError
 from narwhals.schema import Schema
 from narwhals.translate import to_native
 from narwhals.utils import Implementation
@@ -186,17 +187,12 @@ class BaseFrame(Generic[_FrameT]):
         **constraints: Any,
     ) -> Self:
         flat_predicates = flatten(predicates)
-        if any(
-            getattr(x, "_aggregates", False) or getattr(x, "_changes_length", False)
-            for x in flat_predicates
-        ):
-            msg = "Expressions which aggregate or change length cannot be passed to `filter`."
-            raise ShapeError(msg)
         if not (
             len(predicates) == 1
             and isinstance(predicates[0], list)
             and all(isinstance(x, bool) for x in predicates[0])
         ):
+            check_expressions_transform(*flat_predicates, function_name="filter")
             predicates = [self._extract_compliant(v) for v in flat_predicates]  # type: ignore[assignment]
         return self._from_compliant_dataframe(
             self._compliant_frame.filter(*predicates, **constraints),
@@ -2181,7 +2177,7 @@ class LazyFrame(BaseFrame[FrameT]):
             msg = "Binary operations between Series and LazyFrame are not supported."
             raise TypeError(msg)
         if isinstance(arg, Expr):
-            if arg._is_order_dependent:
+            if arg._metadata["is_order_dependent"]:
                 msg = (
                     "Order-dependent expressions are not supported for use in LazyFrame.\n\n"
                     "Hints:\n"
@@ -2192,7 +2188,7 @@ class LazyFrame(BaseFrame[FrameT]):
                     "  they will be supported."
                 )
                 raise OrderDependentExprError(msg)
-            if arg._changes_length:
+            if arg._metadata["kind"] is ExprKind.CHANGES_LENGTH:
                 msg = (
                     "Length-changing expressions are not supported for use in LazyFrame, unless\n"
                     "followed by an aggregation.\n\n"
diff --git a/narwhals/expr.py b/narwhals/expr.py
index c13e7823..196ac78a 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -8,9 +8,10 @@ from typing import Literal
 from typing import Mapping
 from typing import Sequence
 
+from narwhals._expression_parsing import ExprKind
+from narwhals._expression_parsing import ExprMetadata
+from narwhals._expression_parsing import combine_metadata
 from narwhals._expression_parsing import extract_compliant
-from narwhals._expression_parsing import operation_aggregates
-from narwhals._expression_parsing import operation_changes_length
 from narwhals._expression_parsing import operation_is_order_dependent
 from narwhals.dtypes import _validate_dtype
 from narwhals.exceptions import LengthChangingExprError
@@ -43,32 +44,20 @@ class Expr:
     def __init__(
         self: Self,
         to_compliant_expr: Callable[[Any], Any],
-        is_order_dependent: bool,  # noqa: FBT001
-        changes_length: bool,  # noqa: FBT001
-        aggregates: bool,  # noqa: FBT001
+        metadata: ExprMetadata,
     ) -> None:
         # callable from CompliantNamespace to CompliantExpr
         self._to_compliant_expr = to_compliant_expr
-        self._is_order_dependent = is_order_dependent
-        self._changes_length = changes_length
-        self._aggregates = aggregates
+        self._metadata = metadata
 
     def __repr__(self: Self) -> str:
-        return (
-            "Narwhals Expr\n"
-            f"is_order_dependent: {self._is_order_dependent}\n"
-            f"changes_length: {self._changes_length}\n"
-            f"aggregates: {self._aggregates}"
-        )
+        return f"Narwhals Expr\nmetadata: {self._metadata}\n"
 
     def _taxicab_norm(self: Self) -> Self:
         # This is just used to test out the stable api feature in a realistic-ish way.
         # It's not intended to be used.
         return self.__class__(
-            lambda plx: self._to_compliant_expr(plx).abs().sum(),
-            self._is_order_dependent,
-            self._changes_length,
-            self._aggregates,
+            lambda plx: self._to_compliant_expr(plx).abs().sum(), self._metadata
         )
 
     # --- convert ---
@@ -96,10 +85,7 @@ class Expr:
             └──────────────────┘
         """
         return self.__class__(
-            lambda plx: self._to_compliant_expr(plx).alias(name),
-            is_order_dependent=self._is_order_dependent,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            lambda plx: self._to_compliant_expr(plx).alias(name), self._metadata
         )
 
     def pipe(
@@ -162,287 +148,224 @@ class Expr:
         """
         _validate_dtype(dtype)
         return self.__class__(
-            lambda plx: self._to_compliant_expr(plx).cast(dtype),
-            is_order_dependent=self._is_order_dependent,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            lambda plx: self._to_compliant_expr(plx).cast(dtype), self._metadata
         )
 
     # --- binary ---
-    def __eq__(self: Self, other: object) -> Self:  # type: ignore[override]
+    def __eq__(self: Self, other: Self | Any) -> Self:  # type: ignore[override]
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).__eq__(
-                extract_compliant(plx, other, parse_column_name_as_expr=False)
+                extract_compliant(plx, other, strings_are_column_names=False)
             ),
-            is_order_dependent=operation_is_order_dependent(self, other),
-            changes_length=operation_changes_length(self, other),
-            aggregates=operation_aggregates(self, other),
+            combine_metadata(self, other, strings_are_column_names=False),
         )
 
-    def __ne__(self: Self, other: object) -> Self:  # type: ignore[override]
+    def __ne__(self: Self, other: Self | Any) -> Self:  # type: ignore[override]
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).__ne__(
-                extract_compliant(plx, other, parse_column_name_as_expr=False)
+                extract_compliant(plx, other, strings_are_column_names=False)
             ),
-            is_order_dependent=operation_is_order_dependent(self, other),
-            changes_length=operation_changes_length(self, other),
-            aggregates=operation_aggregates(self, other),
+            combine_metadata(self, other, strings_are_column_names=False),
         )
 
     def __and__(self: Self, other: Any) -> Self:
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).__and__(
-                extract_compliant(plx, other, parse_column_name_as_expr=False)
+                extract_compliant(plx, other, strings_are_column_names=False)
             ),
-            is_order_dependent=operation_is_order_dependent(self, other),
-            changes_length=operation_changes_length(self, other),
-            aggregates=operation_aggregates(self, other),
+            combine_metadata(self, other, strings_are_column_names=False),
         )
 
     def __rand__(self: Self, other: Any) -> Self:
         def func(plx: CompliantNamespace[Any]) -> CompliantExpr[Any]:
             return plx.lit(
-                extract_compliant(plx, other, parse_column_name_as_expr=False), dtype=None
-            ).__and__(extract_compliant(plx, self, parse_column_name_as_expr=False))
+                extract_compliant(plx, other, strings_are_column_names=False), dtype=None
+            ).__and__(extract_compliant(plx, self, strings_are_column_names=False))
 
         return self.__class__(
-            func,
-            is_order_dependent=operation_is_order_dependent(self, other),
-            changes_length=operation_changes_length(self, other),
-            aggregates=operation_aggregates(self, other),
+            func, combine_metadata(self, other, strings_are_column_names=False)
         )
 
     def __or__(self: Self, other: Any) -> Self:
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).__or__(
-                extract_compliant(plx, other, parse_column_name_as_expr=False)
+                extract_compliant(plx, other, strings_are_column_names=False)
             ),
-            is_order_dependent=operation_is_order_dependent(self, other),
-            changes_length=operation_changes_length(self, other),
-            aggregates=operation_aggregates(self, other),
+            combine_metadata(self, other, strings_are_column_names=False),
         )
 
     def __ror__(self: Self, other: Any) -> Self:
         def func(plx: CompliantNamespace[Any]) -> CompliantExpr[Any]:
             return plx.lit(
-                extract_compliant(plx, other, parse_column_name_as_expr=False), dtype=None
-            ).__or__(extract_compliant(plx, self, parse_column_name_as_expr=False))
+                extract_compliant(plx, other, strings_are_column_names=False), dtype=None
+            ).__or__(extract_compliant(plx, self, strings_are_column_names=False))
 
         return self.__class__(
-            func,
-            is_order_dependent=operation_is_order_dependent(self, other),
-            changes_length=operation_changes_length(self, other),
-            aggregates=operation_aggregates(self, other),
+            func, combine_metadata(self, other, strings_are_column_names=False)
         )
 
     def __add__(self: Self, other: Any) -> Self:
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).__add__(
-                extract_compliant(plx, other, parse_column_name_as_expr=False)
+                extract_compliant(plx, other, strings_are_column_names=False)
             ),
-            is_order_dependent=operation_is_order_dependent(self, other),
-            changes_length=operation_changes_length(self, other),
-            aggregates=operation_aggregates(self, other),
+            combine_metadata(self, other, strings_are_column_names=False),
         )
 
     def __radd__(self: Self, other: Any) -> Self:
         def func(plx: CompliantNamespace[Any]) -> CompliantExpr[Any]:
             return plx.lit(
-                extract_compliant(plx, other, parse_column_name_as_expr=False), dtype=None
-            ).__add__(extract_compliant(plx, self, parse_column_name_as_expr=False))
+                extract_compliant(plx, other, strings_are_column_names=False), dtype=None
+            ).__add__(extract_compliant(plx, self, strings_are_column_names=False))
 
         return self.__class__(
-            func,
-            is_order_dependent=operation_is_order_dependent(self, other),
-            changes_length=operation_changes_length(self, other),
-            aggregates=operation_aggregates(self, other),
+            func, combine_metadata(self, other, strings_are_column_names=False)
         )
 
     def __sub__(self: Self, other: Any) -> Self:
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).__sub__(
-                extract_compliant(plx, other, parse_column_name_as_expr=False)
+                extract_compliant(plx, other, strings_are_column_names=False)
             ),
-            is_order_dependent=operation_is_order_dependent(self, other),
-            changes_length=operation_changes_length(self, other),
-            aggregates=operation_aggregates(self, other),
+            combine_metadata(self, other, strings_are_column_names=False),
         )
 
     def __rsub__(self: Self, other: Any) -> Self:
         def func(plx: CompliantNamespace[Any]) -> CompliantExpr[Any]:
             return plx.lit(
-                extract_compliant(plx, other, parse_column_name_as_expr=False), dtype=None
-            ).__sub__(extract_compliant(plx, self, parse_column_name_as_expr=False))
+                extract_compliant(plx, other, strings_are_column_names=False), dtype=None
+            ).__sub__(extract_compliant(plx, self, strings_are_column_names=False))
 
         return self.__class__(
-            func,
-            is_order_dependent=operation_is_order_dependent(self, other),
-            changes_length=operation_changes_length(self, other),
-            aggregates=operation_aggregates(self, other),
+            func, combine_metadata(self, other, strings_are_column_names=False)
         )
 
     def __truediv__(self: Self, other: Any) -> Self:
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).__truediv__(
-                extract_compliant(plx, other, parse_column_name_as_expr=False)
+                extract_compliant(plx, other, strings_are_column_names=False)
             ),
-            is_order_dependent=operation_is_order_dependent(self, other),
-            changes_length=operation_changes_length(self, other),
-            aggregates=operation_aggregates(self, other),
+            combine_metadata(self, other, strings_are_column_names=False),
         )
 
     def __rtruediv__(self: Self, other: Any) -> Self:
         def func(plx: CompliantNamespace[Any]) -> CompliantExpr[Any]:
             return plx.lit(
-                extract_compliant(plx, other, parse_column_name_as_expr=False), dtype=None
-            ).__truediv__(extract_compliant(plx, self, parse_column_name_as_expr=False))
+                extract_compliant(plx, other, strings_are_column_names=False), dtype=None
+            ).__truediv__(extract_compliant(plx, self, strings_are_column_names=False))
 
         return self.__class__(
-            func,
-            is_order_dependent=operation_is_order_dependent(self, other),
-            changes_length=operation_changes_length(self, other),
-            aggregates=operation_aggregates(self, other),
+            func, combine_metadata(self, other, strings_are_column_names=False)
         )
 
     def __mul__(self: Self, other: Any) -> Self:
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).__mul__(
-                extract_compliant(plx, other, parse_column_name_as_expr=False)
+                extract_compliant(plx, other, strings_are_column_names=False)
             ),
-            is_order_dependent=operation_is_order_dependent(self, other),
-            changes_length=operation_changes_length(self, other),
-            aggregates=operation_aggregates(self, other),
+            combine_metadata(self, other, strings_are_column_names=False),
         )
 
     def __rmul__(self: Self, other: Any) -> Self:
         def func(plx: CompliantNamespace[Any]) -> CompliantExpr[Any]:
             return plx.lit(
-                extract_compliant(plx, other, parse_column_name_as_expr=False), dtype=None
-            ).__mul__(extract_compliant(plx, self, parse_column_name_as_expr=False))
+                extract_compliant(plx, other, strings_are_column_names=False), dtype=None
+            ).__mul__(extract_compliant(plx, self, strings_are_column_names=False))
 
         return self.__class__(
-            func,
-            is_order_dependent=operation_is_order_dependent(self, other),
-            changes_length=operation_changes_length(self, other),
-            aggregates=operation_aggregates(self, other),
+            func, combine_metadata(self, other, strings_are_column_names=False)
         )
 
     def __le__(self: Self, other: Any) -> Self:
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).__le__(
-                extract_compliant(plx, other, parse_column_name_as_expr=False)
+                extract_compliant(plx, other, strings_are_column_names=False)
             ),
-            is_order_dependent=operation_is_order_dependent(self, other),
-            changes_length=operation_changes_length(self, other),
-            aggregates=operation_aggregates(self, other),
+            combine_metadata(self, other, strings_are_column_names=False),
         )
 
     def __lt__(self: Self, other: Any) -> Self:
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).__lt__(
-                extract_compliant(plx, other, parse_column_name_as_expr=False)
+                extract_compliant(plx, other, strings_are_column_names=False)
             ),
-            is_order_dependent=operation_is_order_dependent(self, other),
-            changes_length=operation_changes_length(self, other),
-            aggregates=operation_aggregates(self, other),
+            combine_metadata(self, other, strings_are_column_names=False),
         )
 
     def __gt__(self: Self, other: Any) -> Self:
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).__gt__(
-                extract_compliant(plx, other, parse_column_name_as_expr=False)
+                extract_compliant(plx, other, strings_are_column_names=False)
             ),
-            is_order_dependent=operation_is_order_dependent(self, other),
-            changes_length=operation_changes_length(self, other),
-            aggregates=operation_aggregates(self, other),
+            combine_metadata(self, other, strings_are_column_names=False),
         )
 
     def __ge__(self: Self, other: Any) -> Self:
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).__ge__(
-                extract_compliant(plx, other, parse_column_name_as_expr=False)
+                extract_compliant(plx, other, strings_are_column_names=False)
             ),
-            is_order_dependent=operation_is_order_dependent(self, other),
-            changes_length=operation_changes_length(self, other),
-            aggregates=operation_aggregates(self, other),
+            combine_metadata(self, other, strings_are_column_names=False),
         )
 
     def __pow__(self: Self, other: Any) -> Self:
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).__pow__(
-                extract_compliant(plx, other, parse_column_name_as_expr=False)
+                extract_compliant(plx, other, strings_are_column_names=False)
             ),
-            is_order_dependent=operation_is_order_dependent(self, other),
-            changes_length=operation_changes_length(self, other),
-            aggregates=operation_aggregates(self, other),
+            combine_metadata(self, other, strings_are_column_names=False),
         )
 
     def __rpow__(self: Self, other: Any) -> Self:
         def func(plx: CompliantNamespace[Any]) -> CompliantExpr[Any]:
             return plx.lit(
-                extract_compliant(plx, other, parse_column_name_as_expr=False), dtype=None
-            ).__pow__(extract_compliant(plx, self, parse_column_name_as_expr=False))
+                extract_compliant(plx, other, strings_are_column_names=False), dtype=None
+            ).__pow__(extract_compliant(plx, self, strings_are_column_names=False))
 
         return self.__class__(
-            func,
-            is_order_dependent=operation_is_order_dependent(self, other),
-            changes_length=operation_changes_length(self, other),
-            aggregates=operation_aggregates(self, other),
+            func, combine_metadata(self, other, strings_are_column_names=False)
         )
 
     def __floordiv__(self: Self, other: Any) -> Self:
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).__floordiv__(
-                extract_compliant(plx, other, parse_column_name_as_expr=False)
+                extract_compliant(plx, other, strings_are_column_names=False)
             ),
-            is_order_dependent=operation_is_order_dependent(self, other),
-            changes_length=operation_changes_length(self, other),
-            aggregates=operation_aggregates(self, other),
+            combine_metadata(self, other, strings_are_column_names=False),
         )
 
     def __rfloordiv__(self: Self, other: Any) -> Self:
         def func(plx: CompliantNamespace[Any]) -> CompliantExpr[Any]:
             return plx.lit(
-                extract_compliant(plx, other, parse_column_name_as_expr=False), dtype=None
-            ).__floordiv__(extract_compliant(plx, self, parse_column_name_as_expr=False))
+                extract_compliant(plx, other, strings_are_column_names=False), dtype=None
+            ).__floordiv__(extract_compliant(plx, self, strings_are_column_names=False))
 
         return self.__class__(
-            func,
-            is_order_dependent=operation_is_order_dependent(self, other),
-            changes_length=operation_changes_length(self, other),
-            aggregates=operation_aggregates(self, other),
+            func, combine_metadata(self, other, strings_are_column_names=False)
         )
 
     def __mod__(self: Self, other: Any) -> Self:
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).__mod__(
-                extract_compliant(plx, other, parse_column_name_as_expr=False)
+                extract_compliant(plx, other, strings_are_column_names=False)
             ),
-            is_order_dependent=operation_is_order_dependent(self, other),
-            changes_length=operation_changes_length(self, other),
-            aggregates=operation_aggregates(self, other),
+            combine_metadata(self, other, strings_are_column_names=False),
         )
 
     def __rmod__(self: Self, other: Any) -> Self:
         def func(plx: CompliantNamespace[Any]) -> CompliantExpr[Any]:
             return plx.lit(
-                extract_compliant(plx, other, parse_column_name_as_expr=False), dtype=None
-            ).__mod__(extract_compliant(plx, self, parse_column_name_as_expr=False))
+                extract_compliant(plx, other, strings_are_column_names=False), dtype=None
+            ).__mod__(extract_compliant(plx, self, strings_are_column_names=False))
 
         return self.__class__(
-            func,
-            is_order_dependent=operation_is_order_dependent(self, other),
-            changes_length=operation_changes_length(self, other),
-            aggregates=operation_aggregates(self, other),
+            func, combine_metadata(self, other, strings_are_column_names=False)
         )
 
     # --- unary ---
     def __invert__(self: Self) -> Self:
         return self.__class__(
-            lambda plx: self._to_compliant_expr(plx).__invert__(),
-            is_order_dependent=self._is_order_dependent,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            lambda plx: self._to_compliant_expr(plx).__invert__(), self._metadata
         )
 
     def any(self: Self) -> Self:
@@ -466,9 +389,10 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).any(),
-            is_order_dependent=self._is_order_dependent,
-            changes_length=False,
-            aggregates=True,
+            ExprMetadata(
+                kind=ExprKind.AGGREGATION,
+                is_order_dependent=self._metadata["is_order_dependent"],
+            ),
         )
 
     def all(self: Self) -> Self:
@@ -492,9 +416,10 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).all(),
-            is_order_dependent=self._is_order_dependent,
-            changes_length=False,
-            aggregates=True,
+            ExprMetadata(
+                kind=ExprKind.AGGREGATION,
+                is_order_dependent=self._metadata["is_order_dependent"],
+            ),
         )
 
     def ewm_mean(
@@ -597,9 +522,7 @@ class Expr:
                 min_samples=min_samples,
                 ignore_nulls=ignore_nulls,
             ),
-            is_order_dependent=self._is_order_dependent,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            self._metadata,
         )
 
     def mean(self: Self) -> Self:
@@ -623,9 +546,10 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).mean(),
-            is_order_dependent=self._is_order_dependent,
-            changes_length=False,
-            aggregates=True,
+            ExprMetadata(
+                kind=ExprKind.AGGREGATION,
+                is_order_dependent=self._metadata["is_order_dependent"],
+            ),
         )
 
     def median(self: Self) -> Self:
@@ -652,9 +576,10 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).median(),
-            is_order_dependent=self._is_order_dependent,
-            changes_length=False,
-            aggregates=True,
+            ExprMetadata(
+                kind=ExprKind.AGGREGATION,
+                is_order_dependent=self._metadata["is_order_dependent"],
+            ),
         )
 
     def std(self: Self, *, ddof: int = 1) -> Self:
@@ -682,9 +607,10 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).std(ddof=ddof),
-            is_order_dependent=self._is_order_dependent,
-            changes_length=False,
-            aggregates=True,
+            ExprMetadata(
+                kind=ExprKind.AGGREGATION,
+                is_order_dependent=self._metadata["is_order_dependent"],
+            ),
         )
 
     def var(self: Self, *, ddof: int = 1) -> Self:
@@ -712,9 +638,10 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).var(ddof=ddof),
-            is_order_dependent=self._is_order_dependent,
-            changes_length=False,
-            aggregates=True,
+            ExprMetadata(
+                kind=ExprKind.AGGREGATION,
+                is_order_dependent=self._metadata["is_order_dependent"],
+            ),
         )
 
     def map_batches(
@@ -761,9 +688,7 @@ class Expr:
                 function=function, return_dtype=return_dtype
             ),
             # safest assumptions
-            is_order_dependent=True,
-            changes_length=True,
-            aggregates=False,
+            ExprMetadata(kind=ExprKind.CHANGES_LENGTH, is_order_dependent=True),
         )
 
     def skew(self: Self) -> Self:
@@ -787,9 +712,10 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).skew(),
-            is_order_dependent=self._is_order_dependent,
-            changes_length=False,
-            aggregates=True,
+            ExprMetadata(
+                kind=ExprKind.AGGREGATION,
+                is_order_dependent=self._metadata["is_order_dependent"],
+            ),
         )
 
     def sum(self: Self) -> Expr:
@@ -817,9 +743,10 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).sum(),
-            is_order_dependent=self._is_order_dependent,
-            changes_length=False,
-            aggregates=True,
+            ExprMetadata(
+                kind=ExprKind.AGGREGATION,
+                is_order_dependent=self._metadata["is_order_dependent"],
+            ),
         )
 
     def min(self: Self) -> Self:
@@ -843,9 +770,10 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).min(),
-            is_order_dependent=self._is_order_dependent,
-            changes_length=False,
-            aggregates=True,
+            ExprMetadata(
+                kind=ExprKind.AGGREGATION,
+                is_order_dependent=self._metadata["is_order_dependent"],
+            ),
         )
 
     def max(self: Self) -> Self:
@@ -869,9 +797,10 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).max(),
-            is_order_dependent=self._is_order_dependent,
-            changes_length=False,
-            aggregates=True,
+            ExprMetadata(
+                kind=ExprKind.AGGREGATION,
+                is_order_dependent=self._metadata["is_order_dependent"],
+            ),
         )
 
     def arg_min(self: Self) -> Self:
@@ -895,9 +824,7 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).arg_min(),
-            is_order_dependent=True,
-            changes_length=False,
-            aggregates=True,
+            ExprMetadata(kind=ExprKind.AGGREGATION, is_order_dependent=True),
         )
 
     def arg_max(self: Self) -> Self:
@@ -921,9 +848,7 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).arg_max(),
-            is_order_dependent=True,
-            changes_length=False,
-            aggregates=True,
+            ExprMetadata(kind=ExprKind.AGGREGATION, is_order_dependent=True),
         )
 
     def count(self: Self) -> Self:
@@ -947,9 +872,10 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).count(),
-            self._is_order_dependent,
-            changes_length=False,
-            aggregates=True,
+            ExprMetadata(
+                kind=ExprKind.AGGREGATION,
+                is_order_dependent=self._metadata["is_order_dependent"],
+            ),
         )
 
     def n_unique(self: Self) -> Self:
@@ -973,9 +899,10 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).n_unique(),
-            self._is_order_dependent,
-            changes_length=False,
-            aggregates=True,
+            ExprMetadata(
+                kind=ExprKind.AGGREGATION,
+                is_order_dependent=self._metadata["is_order_dependent"],
+            ),
         )
 
     def unique(self: Self) -> Self:
@@ -999,9 +926,10 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).unique(),
-            self._is_order_dependent,
-            changes_length=True,
-            aggregates=self._aggregates,
+            ExprMetadata(
+                kind=ExprKind.CHANGES_LENGTH,
+                is_order_dependent=self._metadata["is_order_dependent"],
+            ),
         )
 
     def abs(self: Self) -> Self:
@@ -1025,10 +953,7 @@ class Expr:
             └─────────────────────┘
         """
         return self.__class__(
-            lambda plx: self._to_compliant_expr(plx).abs(),
-            self._is_order_dependent,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            lambda plx: self._to_compliant_expr(plx).abs(), self._metadata
         )
 
     def cum_sum(self: Self, *, reverse: bool = False) -> Self:
@@ -1059,9 +984,7 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).cum_sum(reverse=reverse),
-            is_order_dependent=True,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            ExprMetadata(kind=self._metadata["kind"], is_order_dependent=True),
         )
 
     def diff(self: Self) -> Self:
@@ -1104,9 +1027,7 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).diff(),
-            is_order_dependent=True,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            ExprMetadata(kind=self._metadata["kind"], is_order_dependent=True),
         )
 
     def shift(self: Self, n: int) -> Self:
@@ -1152,9 +1073,7 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).shift(n),
-            is_order_dependent=True,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            ExprMetadata(kind=self._metadata["kind"], is_order_dependent=True),
         )
 
     def replace_strict(
@@ -1214,9 +1133,7 @@ class Expr:
             lambda plx: self._to_compliant_expr(plx).replace_strict(
                 old, new, return_dtype=return_dtype
             ),
-            self._is_order_dependent,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            self._metadata,
         )
 
     def sort(self: Self, *, descending: bool = False, nulls_last: bool = False) -> Self:
@@ -1247,9 +1164,7 @@ class Expr:
             lambda plx: self._to_compliant_expr(plx).sort(
                 descending=descending, nulls_last=nulls_last
             ),
-            is_order_dependent=True,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            ExprMetadata(kind=self._metadata["kind"], is_order_dependent=True),
         )
 
     # --- transform ---
@@ -1288,8 +1203,8 @@ class Expr:
         """
 
         def func(plx: CompliantNamespace[Any]) -> CompliantExpr[Any]:
-            lb = extract_compliant(plx, lower_bound, parse_column_name_as_expr=True)
-            ub = extract_compliant(plx, upper_bound, parse_column_name_as_expr=True)
+            lb = extract_compliant(plx, lower_bound, strings_are_column_names=True)
+            ub = extract_compliant(plx, upper_bound, strings_are_column_names=True)
             expr = self._to_compliant_expr(plx)
             if closed == "left":
                 return (expr >= lb) & (expr < ub)  # type: ignore[no-any-return]
@@ -1299,13 +1214,12 @@ class Expr:
                 return (expr > lb) & (expr < ub)  # type: ignore[no-any-return]
             return (expr >= lb) & (expr <= ub)  # type: ignore[no-any-return]
 
+        is_order_dependent = operation_is_order_dependent(self, lower_bound, upper_bound)
         return self.__class__(
             func,
-            is_order_dependent=operation_is_order_dependent(
-                self, lower_bound, upper_bound
+            ExprMetadata(
+                kind=self._metadata["kind"], is_order_dependent=is_order_dependent
             ),
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
         )
 
     def is_in(self: Self, other: Any) -> Self:
@@ -1336,11 +1250,9 @@ class Expr:
         if isinstance(other, Iterable) and not isinstance(other, (str, bytes)):
             return self.__class__(
                 lambda plx: self._to_compliant_expr(plx).is_in(
-                    extract_compliant(plx, other, parse_column_name_as_expr=False)
+                    extract_compliant(plx, other, strings_are_column_names=False)
                 ),
-                self._is_order_dependent,
-                changes_length=self._changes_length,
-                aggregates=self._aggregates,
+                self._metadata,
             )
         else:
             msg = "Narwhals `is_in` doesn't accept expressions as an argument, as opposed to Polars. You should provide an iterable instead."
@@ -1376,16 +1288,17 @@ class Expr:
             └──────────────────┘
         """
         flat_predicates = flatten(predicates)
+        is_order_dependent = operation_is_order_dependent(*flat_predicates)
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).filter(
                 *[
-                    extract_compliant(plx, pred, parse_column_name_as_expr=True)
+                    extract_compliant(plx, pred, strings_are_column_names=True)
                     for pred in flat_predicates
                 ],
             ),
-            is_order_dependent=operation_is_order_dependent(*flat_predicates),
-            changes_length=True,
-            aggregates=self._aggregates,
+            ExprMetadata(
+                kind=ExprKind.CHANGES_LENGTH, is_order_dependent=is_order_dependent
+            ),
         )
 
     def is_null(self: Self) -> Self:
@@ -1422,10 +1335,7 @@ class Expr:
             └──────────────────────────────────────────┘
         """
         return self.__class__(
-            lambda plx: self._to_compliant_expr(plx).is_null(),
-            self._is_order_dependent,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            lambda plx: self._to_compliant_expr(plx).is_null(), self._metadata
         )
 
     def is_nan(self: Self) -> Self:
@@ -1462,10 +1372,7 @@ class Expr:
             └────────────────────────────────────────┘
         """
         return self.__class__(
-            lambda plx: self._to_compliant_expr(plx).is_nan(),
-            self._is_order_dependent,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            lambda plx: self._to_compliant_expr(plx).is_nan(), self._metadata
         )
 
     def arg_true(self: Self) -> Self:
@@ -1482,9 +1389,7 @@ class Expr:
         issue_deprecation_warning(msg, _version="1.23.0")
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).arg_true(),
-            is_order_dependent=True,
-            changes_length=True,
-            aggregates=self._aggregates,
+            ExprMetadata(kind=ExprKind.CHANGES_LENGTH, is_order_dependent=True),
         )
 
     def fill_null(
@@ -1573,9 +1478,7 @@ class Expr:
             lambda plx: self._to_compliant_expr(plx).fill_null(
                 value=value, strategy=strategy, limit=limit
             ),
-            self._is_order_dependent,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            self._metadata,
         )
 
     # --- partial reduction ---
@@ -1615,9 +1518,10 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).drop_nulls(),
-            self._is_order_dependent,
-            changes_length=True,
-            aggregates=self._aggregates,
+            ExprMetadata(
+                kind=ExprKind.CHANGES_LENGTH,
+                is_order_dependent=self._metadata["is_order_dependent"],
+            ),
         )
 
     def sample(
@@ -1658,9 +1562,10 @@ class Expr:
             lambda plx: self._to_compliant_expr(plx).sample(
                 n, fraction=fraction, with_replacement=with_replacement, seed=seed
             ),
-            self._is_order_dependent,
-            changes_length=True,
-            aggregates=self._aggregates,
+            ExprMetadata(
+                kind=ExprKind.CHANGES_LENGTH,
+                is_order_dependent=self._metadata["is_order_dependent"],
+            ),
         )
 
     def over(self: Self, *keys: str | Iterable[str]) -> Self:
@@ -1702,14 +1607,11 @@ class Expr:
             |2  4  y                    4|
             └────────────────────────────┘
         """
-        if self._changes_length:
+        if self._metadata["kind"] is ExprKind.CHANGES_LENGTH:
             msg = "`.over()` can not be used for expressions which change length."
             raise LengthChangingExprError(msg)
         return self.__class__(
-            lambda plx: self._to_compliant_expr(plx).over(flatten(keys)),
-            self._is_order_dependent,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            lambda plx: self._to_compliant_expr(plx).over(flatten(keys)), self._metadata
         )
 
     def is_duplicated(self: Self) -> Self:
@@ -1759,10 +1661,7 @@ class Expr:
             └─────────────────────────────────┘
         """
         return self.__class__(
-            lambda plx: self._to_compliant_expr(plx).is_unique(),
-            self._is_order_dependent,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            lambda plx: self._to_compliant_expr(plx).is_unique(), self._metadata
         )
 
     def null_count(self: Self) -> Self:
@@ -1793,9 +1692,10 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).null_count(),
-            self._is_order_dependent,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            ExprMetadata(
+                kind=ExprKind.AGGREGATION,
+                is_order_dependent=self._metadata["is_order_dependent"],
+            ),
         )
 
     def is_first_distinct(self: Self) -> Self:
@@ -1824,9 +1724,7 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).is_first_distinct(),
-            is_order_dependent=True,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            ExprMetadata(kind=self._metadata["kind"], is_order_dependent=True),
         )
 
     def is_last_distinct(self: Self) -> Self:
@@ -1855,9 +1753,7 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).is_last_distinct(),
-            is_order_dependent=True,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            ExprMetadata(kind=self._metadata["kind"], is_order_dependent=True),
         )
 
     def quantile(
@@ -1898,9 +1794,10 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).quantile(quantile, interpolation),
-            self._is_order_dependent,
-            changes_length=False,
-            aggregates=True,
+            ExprMetadata(
+                kind=ExprKind.AGGREGATION,
+                is_order_dependent=self._metadata["is_order_dependent"],
+            ),
         )
 
     def head(self: Self, n: int = 10) -> Self:
@@ -1928,9 +1825,7 @@ class Expr:
         issue_deprecation_warning(msg, _version="1.22.0")
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).head(n),
-            is_order_dependent=True,
-            changes_length=True,
-            aggregates=self._aggregates,
+            ExprMetadata(kind=ExprKind.CHANGES_LENGTH, is_order_dependent=True),
         )
 
     def tail(self: Self, n: int = 10) -> Self:
@@ -1958,9 +1853,7 @@ class Expr:
         issue_deprecation_warning(msg, _version="1.22.0")
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).tail(n),
-            is_order_dependent=True,
-            changes_length=True,
-            aggregates=self._aggregates,
+            ExprMetadata(kind=ExprKind.CHANGES_LENGTH, is_order_dependent=True),
         )
 
     def round(self: Self, decimals: int = 0) -> Self:
@@ -1997,10 +1890,7 @@ class Expr:
             └──────────────────────┘
         """
         return self.__class__(
-            lambda plx: self._to_compliant_expr(plx).round(decimals),
-            self._is_order_dependent,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            lambda plx: self._to_compliant_expr(plx).round(decimals), self._metadata
         )
 
     def len(self: Self) -> Self:
@@ -2029,9 +1919,10 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).len(),
-            self._is_order_dependent,
-            changes_length=False,
-            aggregates=True,
+            ExprMetadata(
+                kind=ExprKind.AGGREGATION,
+                is_order_dependent=self._metadata["is_order_dependent"],
+            ),
         )
 
     def gather_every(self: Self, n: int, offset: int = 0) -> Self:
@@ -2060,9 +1951,7 @@ class Expr:
         issue_deprecation_warning(msg, _version="1.22.0")
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).gather_every(n=n, offset=offset),
-            is_order_dependent=True,
-            changes_length=True,
-            aggregates=self._aggregates,
+            ExprMetadata(kind=ExprKind.CHANGES_LENGTH, is_order_dependent=True),
         )
 
     # need to allow numeric typing
@@ -2096,16 +1985,15 @@ class Expr:
             | 2  3          3  |
             └──────────────────┘
         """
+        is_order_dependent = operation_is_order_dependent(self, lower_bound, upper_bound)
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).clip(
-                extract_compliant(plx, lower_bound, parse_column_name_as_expr=True),
-                extract_compliant(plx, upper_bound, parse_column_name_as_expr=True),
+                extract_compliant(plx, lower_bound, strings_are_column_names=True),
+                extract_compliant(plx, upper_bound, strings_are_column_names=True),
             ),
-            is_order_dependent=operation_is_order_dependent(
-                self, lower_bound, upper_bound
+            ExprMetadata(
+                kind=self._metadata["kind"], is_order_dependent=is_order_dependent
             ),
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
         )
 
     def mode(self: Self) -> Self:
@@ -2131,9 +2019,10 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).mode(),
-            self._is_order_dependent,
-            changes_length=True,
-            aggregates=self._aggregates,
+            ExprMetadata(
+                kind=ExprKind.CHANGES_LENGTH,
+                is_order_dependent=self._metadata["is_order_dependent"],
+            ),
         )
 
     def is_finite(self: Self) -> Self:
@@ -2173,10 +2062,7 @@ class Expr:
             └──────────────────────┘
         """
         return self.__class__(
-            lambda plx: self._to_compliant_expr(plx).is_finite(),
-            self._is_order_dependent,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            lambda plx: self._to_compliant_expr(plx).is_finite(), self._metadata
         )
 
     def cum_count(self: Self, *, reverse: bool = False) -> Self:
@@ -2209,9 +2095,7 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).cum_count(reverse=reverse),
-            is_order_dependent=True,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            ExprMetadata(kind=self._metadata["kind"], is_order_dependent=True),
         )
 
     def cum_min(self: Self, *, reverse: bool = False) -> Self:
@@ -2244,9 +2128,7 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).cum_min(reverse=reverse),
-            is_order_dependent=True,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            ExprMetadata(kind=self._metadata["kind"], is_order_dependent=True),
         )
 
     def cum_max(self: Self, *, reverse: bool = False) -> Self:
@@ -2279,9 +2161,7 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).cum_max(reverse=reverse),
-            is_order_dependent=True,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            ExprMetadata(kind=self._metadata["kind"], is_order_dependent=True),
         )
 
     def cum_prod(self: Self, *, reverse: bool = False) -> Self:
@@ -2314,9 +2194,7 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).cum_prod(reverse=reverse),
-            is_order_dependent=True,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            ExprMetadata(kind=self._metadata["kind"], is_order_dependent=True),
         )
 
     def rolling_sum(
@@ -2378,9 +2256,7 @@ class Expr:
                 min_samples=min_samples,
                 center=center,
             ),
-            is_order_dependent=True,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            ExprMetadata(kind=self._metadata["kind"], is_order_dependent=True),
         )
 
     def rolling_mean(
@@ -2442,9 +2318,7 @@ class Expr:
                 min_samples=min_samples,
                 center=center,
             ),
-            is_order_dependent=True,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            ExprMetadata(kind=self._metadata["kind"], is_order_dependent=True),
         )
 
     def rolling_var(
@@ -2506,9 +2380,7 @@ class Expr:
             lambda plx: self._to_compliant_expr(plx).rolling_var(
                 window_size=window_size, min_samples=min_samples, center=center, ddof=ddof
             ),
-            is_order_dependent=True,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            ExprMetadata(kind=self._metadata["kind"], is_order_dependent=True),
         )
 
     def rolling_std(
@@ -2573,9 +2445,7 @@ class Expr:
                 center=center,
                 ddof=ddof,
             ),
-            is_order_dependent=True,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            ExprMetadata(kind=self._metadata["kind"], is_order_dependent=True),
         )
 
     def rank(
@@ -2641,9 +2511,7 @@ class Expr:
             lambda plx: self._to_compliant_expr(plx).rank(
                 method=method, descending=descending
             ),
-            is_order_dependent=True,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            ExprMetadata(kind=self._metadata["kind"], is_order_dependent=True),
         )
 
     @property
diff --git a/narwhals/expr_cat.py b/narwhals/expr_cat.py
index 16dbb392..0a6e10d8 100644
--- a/narwhals/expr_cat.py
+++ b/narwhals/expr_cat.py
@@ -4,6 +4,9 @@ from typing import TYPE_CHECKING
 from typing import Generic
 from typing import TypeVar
 
+from narwhals._expression_parsing import ExprKind
+from narwhals._expression_parsing import ExprMetadata
+
 if TYPE_CHECKING:
     from typing_extensions import Self
 
@@ -63,7 +66,8 @@ class ExprCatNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).cat.get_categories(),
-            self._expr._is_order_dependent,
-            changes_length=True,
-            aggregates=self._expr._aggregates,
+            ExprMetadata(
+                kind=ExprKind.CHANGES_LENGTH,
+                is_order_dependent=self._expr._metadata["is_order_dependent"],
+            ),
         )
diff --git a/narwhals/expr_dt.py b/narwhals/expr_dt.py
index 91272365..178009f6 100644
--- a/narwhals/expr_dt.py
+++ b/narwhals/expr_dt.py
@@ -73,10 +73,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             a: [[2012-01-07,2023-03-10]]
         """
         return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).dt.date(),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            lambda plx: self._expr._to_compliant_expr(plx).dt.date(), self._expr._metadata
         )
 
     def year(self: Self) -> ExprT:
@@ -144,10 +141,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             year: [[1978,2024,2065]]
         """
         return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).dt.year(),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            lambda plx: self._expr._to_compliant_expr(plx).dt.year(), self._expr._metadata
         )
 
     def month(self: Self) -> ExprT:
@@ -216,9 +210,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).dt.month(),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def day(self: Self) -> ExprT:
@@ -286,10 +278,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             day: [[1,13,1]]
         """
         return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).dt.day(),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            lambda plx: self._expr._to_compliant_expr(plx).dt.day(), self._expr._metadata
         )
 
     def hour(self: Self) -> ExprT:
@@ -357,10 +346,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             hour: [[1,5,10]]
         """
         return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).dt.hour(),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            lambda plx: self._expr._to_compliant_expr(plx).dt.hour(), self._expr._metadata
         )
 
     def minute(self: Self) -> ExprT:
@@ -429,9 +415,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).dt.minute(),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def second(self: Self) -> ExprT:
@@ -498,9 +482,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).dt.second(),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def millisecond(self: Self) -> ExprT:
@@ -567,9 +549,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).dt.millisecond(),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def microsecond(self: Self) -> ExprT:
@@ -636,9 +616,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).dt.microsecond(),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def nanosecond(self: Self) -> ExprT:
@@ -705,9 +683,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).dt.nanosecond(),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def ordinal_day(self: Self) -> ExprT:
@@ -766,9 +742,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).dt.ordinal_day(),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def weekday(self: Self) -> ExprT:
@@ -825,9 +799,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).dt.weekday(),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def total_minutes(self: Self) -> ExprT:
@@ -891,9 +863,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).dt.total_minutes(),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def total_seconds(self: Self) -> ExprT:
@@ -959,9 +929,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).dt.total_seconds(),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def total_milliseconds(self: Self) -> ExprT:
@@ -1030,9 +998,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).dt.total_milliseconds(),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def total_microseconds(self: Self) -> ExprT:
@@ -1101,9 +1067,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).dt.total_microseconds(),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def total_nanoseconds(self: Self) -> ExprT:
@@ -1159,9 +1123,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).dt.total_nanoseconds(),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def to_string(self: Self, format: str) -> ExprT:  # noqa: A002
@@ -1260,9 +1222,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).dt.to_string(format),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def replace_time_zone(self: Self, time_zone: str | None) -> ExprT:
@@ -1329,9 +1289,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             lambda plx: self._expr._to_compliant_expr(plx).dt.replace_time_zone(
                 time_zone
             ),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def convert_time_zone(self: Self, time_zone: str) -> ExprT:
@@ -1404,9 +1362,7 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             lambda plx: self._expr._to_compliant_expr(plx).dt.convert_time_zone(
                 time_zone
             ),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def timestamp(self: Self, time_unit: TimeUnit = "us") -> ExprT:
@@ -1480,7 +1436,5 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             raise ValueError(msg)
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).dt.timestamp(time_unit),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
diff --git a/narwhals/expr_list.py b/narwhals/expr_list.py
index 0532db5f..fc6a1227 100644
--- a/narwhals/expr_list.py
+++ b/narwhals/expr_list.py
@@ -74,7 +74,5 @@ class ExprListNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).list.len(),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
diff --git a/narwhals/expr_name.py b/narwhals/expr_name.py
index 60a1dd4b..fbf8c86f 100644
--- a/narwhals/expr_name.py
+++ b/narwhals/expr_name.py
@@ -62,9 +62,7 @@ class ExprNameNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).name.keep(),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def map(self: Self, function: Callable[[str], str]) -> ExprT:
@@ -114,9 +112,7 @@ class ExprNameNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).name.map(function),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def prefix(self: Self, prefix: str) -> ExprT:
@@ -165,9 +161,7 @@ class ExprNameNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).name.prefix(prefix),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def suffix(self: Self, suffix: str) -> ExprT:
@@ -216,9 +210,7 @@ class ExprNameNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).name.suffix(suffix),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def to_lowercase(self: Self) -> ExprT:
@@ -264,9 +256,7 @@ class ExprNameNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).name.to_lowercase(),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def to_uppercase(self: Self) -> ExprT:
@@ -312,7 +302,5 @@ class ExprNameNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).name.to_uppercase(),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
diff --git a/narwhals/expr_str.py b/narwhals/expr_str.py
index d82b2ec7..dbef8343 100644
--- a/narwhals/expr_str.py
+++ b/narwhals/expr_str.py
@@ -77,9 +77,7 @@ class ExprStringNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).str.len_chars(),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def replace(
@@ -146,9 +144,7 @@ class ExprStringNamespace(Generic[ExprT]):
             lambda plx: self._expr._to_compliant_expr(plx).str.replace(
                 pattern, value, literal=literal, n=n
             ),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def replace_all(
@@ -216,9 +212,7 @@ class ExprStringNamespace(Generic[ExprT]):
             lambda plx: self._expr._to_compliant_expr(plx).str.replace_all(
                 pattern, value, literal=literal
             ),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def strip_chars(self: Self, characters: str | None = None) -> ExprT:
@@ -267,9 +261,7 @@ class ExprStringNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).str.strip_chars(characters),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def starts_with(self: Self, prefix: str) -> ExprT:
@@ -332,9 +324,7 @@ class ExprStringNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).str.starts_with(prefix),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def ends_with(self: Self, suffix: str) -> ExprT:
@@ -397,9 +387,7 @@ class ExprStringNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).str.ends_with(suffix),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def contains(self: Self, pattern: str, *, literal: bool = False) -> ExprT:
@@ -480,9 +468,7 @@ class ExprStringNamespace(Generic[ExprT]):
             lambda plx: self._expr._to_compliant_expr(plx).str.contains(
                 pattern, literal=literal
             ),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def slice(self: Self, offset: int, length: int | None = None) -> ExprT:
@@ -585,9 +571,7 @@ class ExprStringNamespace(Generic[ExprT]):
             lambda plx: self._expr._to_compliant_expr(plx).str.slice(
                 offset=offset, length=length
             ),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def head(self: Self, n: int = 5) -> ExprT:
@@ -655,9 +639,7 @@ class ExprStringNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).str.slice(0, n),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def tail(self: Self, n: int = 5) -> ExprT:
@@ -727,9 +709,7 @@ class ExprStringNamespace(Generic[ExprT]):
             lambda plx: self._expr._to_compliant_expr(plx).str.slice(
                 offset=-n, length=None
             ),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def to_datetime(self: Self, format: str | None = None) -> ExprT:  # noqa: A002
@@ -799,9 +779,7 @@ class ExprStringNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).str.to_datetime(format=format),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def to_uppercase(self: Self) -> ExprT:
@@ -866,9 +844,7 @@ class ExprStringNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).str.to_uppercase(),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
 
     def to_lowercase(self: Self) -> ExprT:
@@ -928,7 +904,5 @@ class ExprStringNamespace(Generic[ExprT]):
         """
         return self._expr.__class__(
             lambda plx: self._expr._to_compliant_expr(plx).str.to_lowercase(),
-            self._expr._is_order_dependent,
-            changes_length=self._expr._changes_length,
-            aggregates=self._expr._aggregates,
+            self._expr._metadata,
         )
diff --git a/narwhals/functions.py b/narwhals/functions.py
index 51b80d11..dc08097a 100644
--- a/narwhals/functions.py
+++ b/narwhals/functions.py
@@ -12,17 +12,18 @@ from typing import TypeVar
 from typing import Union
 from typing import overload
 
+from narwhals._expression_parsing import ExprKind
+from narwhals._expression_parsing import ExprMetadata
+from narwhals._expression_parsing import check_expressions_transform
+from narwhals._expression_parsing import combine_metadata
 from narwhals._expression_parsing import extract_compliant
-from narwhals._expression_parsing import operation_aggregates
-from narwhals._expression_parsing import operation_changes_length
-from narwhals._expression_parsing import operation_is_order_dependent
 from narwhals.dataframe import DataFrame
 from narwhals.dataframe import LazyFrame
 from narwhals.dependencies import is_numpy_array
 from narwhals.dependencies import is_numpy_array_2d
-from narwhals.exceptions import ShapeError
 from narwhals.expr import Expr
 from narwhals.schema import Schema
+from narwhals.series import Series
 from narwhals.translate import from_native
 from narwhals.translate import to_native
 from narwhals.utils import Implementation
@@ -1052,7 +1053,7 @@ def col(*names: str | Iterable[str]) -> Expr:
     def func(plx: Any) -> Any:
         return plx.col(*flatten(names))
 
-    return Expr(func, is_order_dependent=False, changes_length=False, aggregates=False)
+    return Expr(func, ExprMetadata(kind=ExprKind.TRANSFORM, is_order_dependent=False))
 
 
 def nth(*indices: int | Sequence[int]) -> Expr:
@@ -1089,7 +1090,7 @@ def nth(*indices: int | Sequence[int]) -> Expr:
     def func(plx: Any) -> Any:
         return plx.nth(*flatten(indices))
 
-    return Expr(func, is_order_dependent=False, changes_length=False, aggregates=False)
+    return Expr(func, ExprMetadata(kind=ExprKind.TRANSFORM, is_order_dependent=False))
 
 
 # Add underscore so it doesn't conflict with builtin `all`
@@ -1115,9 +1116,7 @@ def all_() -> Expr:
     """
     return Expr(
         lambda plx: plx.all(),
-        is_order_dependent=False,
-        changes_length=False,
-        aggregates=False,
+        ExprMetadata(kind=ExprKind.TRANSFORM, is_order_dependent=False),
     )
 
 
@@ -1151,7 +1150,7 @@ def len_() -> Expr:
     def func(plx: Any) -> Any:
         return plx.len()
 
-    return Expr(func, is_order_dependent=False, changes_length=False, aggregates=True)
+    return Expr(func, ExprMetadata(kind=ExprKind.AGGREGATION, is_order_dependent=False))
 
 
 def sum(*columns: str) -> Expr:
@@ -1179,12 +1178,7 @@ def sum(*columns: str) -> Expr:
         |    0  3  4.8     |
         └──────────────────┘
     """
-    return Expr(
-        lambda plx: plx.col(*columns).sum(),
-        is_order_dependent=False,
-        changes_length=False,
-        aggregates=True,
-    )
+    return col(*columns).sum()
 
 
 def mean(*columns: str) -> Expr:
@@ -1216,12 +1210,7 @@ def mean(*columns: str) -> Expr:
         |b: [[17.173333333333336]]|
         └─────────────────────────┘
     """
-    return Expr(
-        lambda plx: plx.col(*columns).mean(),
-        is_order_dependent=False,
-        changes_length=False,
-        aggregates=True,
-    )
+    return col(*columns).mean()
 
 
 def median(*columns: str) -> Expr:
@@ -1257,12 +1246,7 @@ def median(*columns: str) -> Expr:
         |  └─────┘         |
         └──────────────────┘
     """
-    return Expr(
-        lambda plx: plx.col(*columns).median(),
-        is_order_dependent=False,
-        changes_length=False,
-        aggregates=True,
-    )
+    return col(*columns).median()
 
 
 def min(*columns: str) -> Expr:
@@ -1294,12 +1278,7 @@ def min(*columns: str) -> Expr:
         |  b: [[5]]        |
         └──────────────────┘
     """
-    return Expr(
-        lambda plx: plx.col(*columns).min(),
-        is_order_dependent=False,
-        changes_length=False,
-        aggregates=True,
-    )
+    return col(*columns).min()
 
 
 def max(*columns: str) -> Expr:
@@ -1327,12 +1306,7 @@ def max(*columns: str) -> Expr:
         |     0  2  10     |
         └──────────────────┘
     """
-    return Expr(
-        lambda plx: plx.col(*columns).max(),
-        is_order_dependent=False,
-        changes_length=False,
-        aggregates=True,
-    )
+    return col(*columns).max()
 
 
 def sum_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
@@ -1376,13 +1350,11 @@ def sum_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
     return Expr(
         lambda plx: plx.sum_horizontal(
             *(
-                extract_compliant(plx, v, parse_column_name_as_expr=True)
+                extract_compliant(plx, v, strings_are_column_names=True)
                 for v in flat_exprs
             )
         ),
-        is_order_dependent=operation_is_order_dependent(*flat_exprs),
-        changes_length=operation_changes_length(*flat_exprs),
-        aggregates=operation_aggregates(*flat_exprs),
+        combine_metadata(*flat_exprs, strings_are_column_names=True),
     )
 
 
@@ -1425,13 +1397,11 @@ def min_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
     return Expr(
         lambda plx: plx.min_horizontal(
             *(
-                extract_compliant(plx, v, parse_column_name_as_expr=True)
+                extract_compliant(plx, v, strings_are_column_names=True)
                 for v in flat_exprs
             )
         ),
-        is_order_dependent=operation_is_order_dependent(*flat_exprs),
-        changes_length=operation_changes_length(*flat_exprs),
-        aggregates=operation_aggregates(*flat_exprs),
+        combine_metadata(*flat_exprs, strings_are_column_names=True),
     )
 
 
@@ -1476,13 +1446,11 @@ def max_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
     return Expr(
         lambda plx: plx.max_horizontal(
             *(
-                extract_compliant(plx, v, parse_column_name_as_expr=True)
+                extract_compliant(plx, v, strings_are_column_names=True)
                 for v in flat_exprs
             )
         ),
-        is_order_dependent=operation_is_order_dependent(*flat_exprs),
-        changes_length=operation_changes_length(*flat_exprs),
-        aggregates=operation_aggregates(*flat_exprs),
+        combine_metadata(*flat_exprs, strings_are_column_names=True),
     )
 
 
@@ -1492,27 +1460,20 @@ class When:
         if not self._predicates:
             msg = "At least one predicate needs to be provided to `narwhals.when`."
             raise TypeError(msg)
-        if any(
-            getattr(x, "_aggregates", False) or getattr(x, "_changes_length", False)
-            for x in self._predicates
-        ):
-            msg = "Expressions which aggregate or change length cannot be passed to `filter`."
-            raise ShapeError(msg)
+        check_expressions_transform(*self._predicates, function_name="when")
 
     def _extract_predicates(self: Self, plx: Any) -> Any:
         return [
-            extract_compliant(plx, v, parse_column_name_as_expr=True)
+            extract_compliant(plx, v, strings_are_column_names=True)
             for v in self._predicates
         ]
 
     def then(self: Self, value: IntoExpr | Any) -> Then:
         return Then(
             lambda plx: plx.when(*self._extract_predicates(plx)).then(
-                extract_compliant(plx, value, parse_column_name_as_expr=True)
+                extract_compliant(plx, value, strings_are_column_names=True)
             ),
-            is_order_dependent=operation_is_order_dependent(*self._predicates, value),
-            changes_length=operation_changes_length(*self._predicates, value),
-            aggregates=operation_aggregates(*self._predicates, value),
+            combine_metadata(*self._predicates, value, strings_are_column_names=True),
         )
 
 
@@ -1520,11 +1481,9 @@ class Then(Expr):
     def otherwise(self: Self, value: IntoExpr | Any) -> Expr:
         return Expr(
             lambda plx: self._to_compliant_expr(plx).otherwise(
-                extract_compliant(plx, value, parse_column_name_as_expr=True)
+                extract_compliant(plx, value, strings_are_column_names=True)
             ),
-            is_order_dependent=operation_is_order_dependent(self, value),
-            changes_length=operation_changes_length(self, value),
-            aggregates=operation_aggregates(self, value),
+            combine_metadata(self, value, strings_are_column_names=True),
         )
 
 
@@ -1611,13 +1570,11 @@ def all_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
     return Expr(
         lambda plx: plx.all_horizontal(
             *(
-                extract_compliant(plx, v, parse_column_name_as_expr=True)
+                extract_compliant(plx, v, strings_are_column_names=True)
                 for v in flat_exprs
             )
         ),
-        is_order_dependent=operation_is_order_dependent(*flat_exprs),
-        changes_length=operation_changes_length(*flat_exprs),
-        aggregates=operation_aggregates(*flat_exprs),
+        combine_metadata(*flat_exprs, strings_are_column_names=True),
     )
 
 
@@ -1659,9 +1616,7 @@ def lit(value: Any, dtype: DType | type[DType] | None = None) -> Expr:
 
     return Expr(
         lambda plx: plx.lit(value, dtype),
-        is_order_dependent=False,
-        changes_length=False,
-        aggregates=True,
+        ExprMetadata(kind=ExprKind.LITERAL, is_order_dependent=False),
     )
 
 
@@ -1710,13 +1665,11 @@ def any_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
     return Expr(
         lambda plx: plx.any_horizontal(
             *(
-                extract_compliant(plx, v, parse_column_name_as_expr=True)
+                extract_compliant(plx, v, strings_are_column_names=True)
                 for v in flat_exprs
             )
         ),
-        is_order_dependent=operation_is_order_dependent(*flat_exprs),
-        changes_length=operation_changes_length(*flat_exprs),
-        aggregates=operation_aggregates(*flat_exprs),
+        combine_metadata(*flat_exprs, strings_are_column_names=True),
     )
 
 
@@ -1761,13 +1714,11 @@ def mean_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
     return Expr(
         lambda plx: plx.mean_horizontal(
             *(
-                extract_compliant(plx, v, parse_column_name_as_expr=True)
+                extract_compliant(plx, v, strings_are_column_names=True)
                 for v in flat_exprs
             )
         ),
-        is_order_dependent=operation_is_order_dependent(*flat_exprs),
-        changes_length=operation_changes_length(*flat_exprs),
-        aggregates=operation_aggregates(*flat_exprs),
+        combine_metadata(*flat_exprs, strings_are_column_names=True),
     )
 
 
@@ -1827,11 +1778,9 @@ def concat_str(
     exprs = flatten([*flatten([exprs]), *more_exprs])
     return Expr(
         lambda plx: plx.concat_str(
-            *(extract_compliant(plx, v, parse_column_name_as_expr=True) for v in exprs),
+            *(extract_compliant(plx, v, strings_are_column_names=True) for v in exprs),
             separator=separator,
             ignore_nulls=ignore_nulls,
         ),
-        is_order_dependent=operation_is_order_dependent(*exprs),
-        changes_length=operation_changes_length(*exprs),
-        aggregates=operation_aggregates(*exprs),
+        combine_metadata(*exprs, strings_are_column_names=True),
     )
diff --git a/narwhals/group_by.py b/narwhals/group_by.py
index 359eeb4c..8631a028 100644
--- a/narwhals/group_by.py
+++ b/narwhals/group_by.py
@@ -8,6 +8,7 @@ from typing import Iterator
 from typing import TypeVar
 from typing import cast
 
+from narwhals._expression_parsing import all_expressions_aggregate
 from narwhals.dataframe import DataFrame
 from narwhals.dataframe import LazyFrame
 from narwhals.exceptions import InvalidOperationError
@@ -112,9 +113,7 @@ class GroupBy(Generic[DataFrameT]):
             └─────┴─────┴─────┘
         """
         flat_aggs = tuple(flatten(aggs))
-        if not all(getattr(x, "_aggregates", True) for x in flat_aggs) and all(
-            getattr(x, "_aggregates", True) for x in named_aggs.values()
-        ):
+        if not all_expressions_aggregate(*flat_aggs, **named_aggs):
             msg = (
                 "Found expression which does not aggregate.\n\n"
                 "All expressions passed to GroupBy.agg must aggregate.\n"
@@ -216,9 +215,7 @@ class LazyGroupBy(Generic[LazyFrameT]):
             └─────┴─────┴─────┘
         """
         flat_aggs = tuple(flatten(aggs))
-        if not all(getattr(x, "_aggregates", True) for x in flat_aggs) and all(
-            getattr(x, "_aggregates", True) for x in named_aggs.values()
-        ):
+        if not all_expressions_aggregate(*flat_aggs, **named_aggs):
             msg = (
                 "Found expression which does not aggregate.\n\n"
                 "All expressions passed to GroupBy.agg must aggregate.\n"
diff --git a/narwhals/selectors.py b/narwhals/selectors.py
index 3b74b5c4..695d130a 100644
--- a/narwhals/selectors.py
+++ b/narwhals/selectors.py
@@ -5,6 +5,8 @@ from typing import Any
 from typing import Iterable
 from typing import NoReturn
 
+from narwhals._expression_parsing import ExprKind
+from narwhals._expression_parsing import ExprMetadata
 from narwhals.expr import Expr
 from narwhals.utils import flatten
 
@@ -19,12 +21,7 @@ if TYPE_CHECKING:
 
 class Selector(Expr):
     def _to_expr(self: Self) -> Expr:
-        return Expr(
-            to_compliant_expr=self._to_compliant_expr,
-            is_order_dependent=self._is_order_dependent,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
-        )
+        return Expr(self._to_compliant_expr, self._metadata)
 
     def __add__(self: Self, other: Any) -> Expr:  # type: ignore[override]
         if isinstance(other, Selector):
@@ -100,9 +97,7 @@ def by_dtype(*dtypes: DType | type[DType] | Iterable[DType | type[DType]]) -> Se
     """
     return Selector(
         lambda plx: plx.selectors.by_dtype(flatten(dtypes)),
-        is_order_dependent=False,
-        changes_length=False,
-        aggregates=False,
+        ExprMetadata(kind=ExprKind.TRANSFORM, is_order_dependent=False),
     )
 
 
@@ -169,9 +164,7 @@ def matches(pattern: str) -> Selector:
     """
     return Selector(
         lambda plx: plx.selectors.matches(pattern),
-        is_order_dependent=False,
-        changes_length=False,
-        aggregates=False,
+        ExprMetadata(kind=ExprKind.TRANSFORM, is_order_dependent=False),
     )
 
 
@@ -230,9 +223,7 @@ def numeric() -> Selector:
     """
     return Selector(
         lambda plx: plx.selectors.numeric(),
-        is_order_dependent=False,
-        changes_length=False,
-        aggregates=False,
+        ExprMetadata(kind=ExprKind.TRANSFORM, is_order_dependent=False),
     )
 
 
@@ -288,9 +279,7 @@ def boolean() -> Selector:
     """
     return Selector(
         lambda plx: plx.selectors.boolean(),
-        is_order_dependent=False,
-        changes_length=False,
-        aggregates=False,
+        ExprMetadata(kind=ExprKind.TRANSFORM, is_order_dependent=False),
     )
 
 
@@ -346,9 +335,7 @@ def string() -> Selector:
     """
     return Selector(
         lambda plx: plx.selectors.string(),
-        is_order_dependent=False,
-        changes_length=False,
-        aggregates=False,
+        ExprMetadata(kind=ExprKind.TRANSFORM, is_order_dependent=False),
     )
 
 
@@ -409,9 +396,7 @@ def categorical() -> Selector:
     """
     return Selector(
         lambda plx: plx.selectors.categorical(),
-        is_order_dependent=False,
-        changes_length=False,
-        aggregates=False,
+        ExprMetadata(kind=ExprKind.TRANSFORM, is_order_dependent=False),
     )
 
 
@@ -471,9 +456,7 @@ def all() -> Selector:
     """
     return Selector(
         lambda plx: plx.selectors.all(),
-        is_order_dependent=False,
-        changes_length=False,
-        aggregates=False,
+        ExprMetadata(kind=ExprKind.TRANSFORM, is_order_dependent=False),
     )
 
 
@@ -534,9 +517,7 @@ def datetime(
     """
     return Selector(
         lambda plx: plx.selectors.datetime(time_unit=time_unit, time_zone=time_zone),
-        is_order_dependent=False,
-        changes_length=False,
-        aggregates=False,
+        ExprMetadata(kind=ExprKind.TRANSFORM, is_order_dependent=False),
     )
 
 
diff --git a/narwhals/stable/v1/__init__.py b/narwhals/stable/v1/__init__.py
index 4b5511d6..dca67d7a 100644
--- a/narwhals/stable/v1/__init__.py
+++ b/narwhals/stable/v1/__init__.py
@@ -15,6 +15,8 @@ import narwhals as nw
 from narwhals import dependencies
 from narwhals import exceptions
 from narwhals import selectors
+from narwhals._expression_parsing import ExprKind
+from narwhals._expression_parsing import ExprMetadata
 from narwhals.dataframe import DataFrame as NwDataFrame
 from narwhals.dataframe import LazyFrame as NwLazyFrame
 from narwhals.dependencies import get_polars
@@ -963,9 +965,7 @@ class Expr(NwExpr):
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).head(n),
-            is_order_dependent=True,
-            changes_length=True,
-            aggregates=self._aggregates,
+            ExprMetadata(kind=ExprKind.CHANGES_LENGTH, is_order_dependent=True),
         )
 
     def tail(self: Self, n: int = 10) -> Self:
@@ -979,9 +979,7 @@ class Expr(NwExpr):
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).tail(n),
-            is_order_dependent=True,
-            changes_length=True,
-            aggregates=self._aggregates,
+            ExprMetadata(kind=ExprKind.CHANGES_LENGTH, is_order_dependent=True),
         )
 
     def gather_every(self: Self, n: int, offset: int = 0) -> Self:
@@ -996,9 +994,7 @@ class Expr(NwExpr):
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).gather_every(n=n, offset=offset),
-            is_order_dependent=True,
-            changes_length=True,
-            aggregates=self._aggregates,
+            ExprMetadata(kind=ExprKind.CHANGES_LENGTH, is_order_dependent=True),
         )
 
     def unique(self: Self, *, maintain_order: bool | None = None) -> Self:
@@ -1020,9 +1016,10 @@ class Expr(NwExpr):
             warn(message=msg, category=UserWarning, stacklevel=find_stacklevel())
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).unique(),
-            self._is_order_dependent,
-            changes_length=True,
-            aggregates=self._aggregates,
+            ExprMetadata(
+                kind=ExprKind.CHANGES_LENGTH,
+                is_order_dependent=self._metadata["is_order_dependent"],
+            ),
         )
 
     def sort(self: Self, *, descending: bool = False, nulls_last: bool = False) -> Self:
@@ -1039,9 +1036,7 @@ class Expr(NwExpr):
             lambda plx: self._to_compliant_expr(plx).sort(
                 descending=descending, nulls_last=nulls_last
             ),
-            is_order_dependent=True,
-            changes_length=self._changes_length,
-            aggregates=self._aggregates,
+            ExprMetadata(kind=self._metadata["kind"], is_order_dependent=True),
         )
 
     def arg_true(self: Self) -> Self:
@@ -1052,9 +1047,7 @@ class Expr(NwExpr):
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).arg_true(),
-            is_order_dependent=True,
-            changes_length=True,
-            aggregates=self._aggregates,
+            ExprMetadata(kind=ExprKind.CHANGES_LENGTH, is_order_dependent=True),
         )
 
     def sample(
@@ -1088,9 +1081,7 @@ class Expr(NwExpr):
             lambda plx: self._to_compliant_expr(plx).sample(
                 n, fraction=fraction, with_replacement=with_replacement, seed=seed
             ),
-            is_order_dependent=True,
-            changes_length=True,
-            aggregates=self._aggregates,
+            ExprMetadata(kind=ExprKind.CHANGES_LENGTH, is_order_dependent=True),
         )
 
 
@@ -1137,12 +1128,7 @@ def _stableify(
             level=obj._level,
         )
     if isinstance(obj, NwExpr):
-        return Expr(
-            obj._to_compliant_expr,
-            is_order_dependent=obj._is_order_dependent,
-            changes_length=obj._changes_length,
-            aggregates=obj._aggregates,
-        )
+        return Expr(obj._to_compliant_expr, obj._metadata)
     return obj
 
 
@@ -2101,10 +2087,7 @@ class Then(NwThen, Expr):
     @classmethod
     def from_then(cls: type, then: NwThen) -> Then:
         return cls(  # type: ignore[no-any-return]
-            then._to_compliant_expr,
-            is_order_dependent=then._is_order_dependent,
-            changes_length=then._changes_length,
-            aggregates=then._aggregates,
+            then._to_compliant_expr, then._metadata
         )
 
     def otherwise(self: Self, value: Any) -> Expr:
diff --git a/utils/check_api_reference.py b/utils/check_api_reference.py
index fa5a4a5c..87f1b237 100644
--- a/utils/check_api_reference.py
+++ b/utils/check_api_reference.py
@@ -6,6 +6,8 @@ import sys
 import polars as pl
 
 import narwhals as nw
+from narwhals._expression_parsing import ExprKind
+from narwhals._expression_parsing import ExprMetadata
 from narwhals.utils import remove_prefix
 from narwhals.utils import remove_suffix
 
@@ -163,7 +165,7 @@ for namespace in NAMESPACES.difference({"name"}):
 expr_methods = [
     i
     for i in nw.Expr(
-        lambda: 0, is_order_dependent=False, changes_length=False, aggregates=False
+        lambda: 0, ExprMetadata(kind=ExprKind.TRANSFORM, is_order_dependent=False)
     ).__dir__()
     if not i[0].isupper() and i[0] != "_"
 ]
@@ -189,10 +191,7 @@ for namespace in NAMESPACES:
         i
         for i in getattr(
             nw.Expr(
-                lambda: 0,
-                is_order_dependent=False,
-                changes_length=False,
-                aggregates=False,
+                lambda: 0, ExprMetadata(kind=ExprKind.TRANSFORM, is_order_dependent=False)
             ),
             namespace,
         ).__dir__()
@@ -238,7 +237,7 @@ if extra := set(documented).difference(dtypes):
 expr = [
     i
     for i in nw.Expr(
-        lambda: 0, is_order_dependent=False, changes_length=False, aggregates=False
+        lambda: 0, ExprMetadata(kind=ExprKind.TRANSFORM, is_order_dependent=False)
     ).__dir__()
     if not i[0].isupper() and i[0] != "_"
 ]
@@ -262,10 +261,7 @@ for namespace in NAMESPACES.difference({"name"}):
         i
         for i in getattr(
             nw.Expr(
-                lambda: 0,
-                is_order_dependent=False,
-                changes_length=False,
-                aggregates=False,
+                lambda: 0, ExprMetadata(kind=ExprKind.TRANSFORM, is_order_dependent=False)
             ),
             namespace,
         ).__dir__()


Test patch:
diff --git a/tests/expr_and_series/order_dependent_lazy_test.py b/tests/expr_and_series/order_dependent_lazy_test.py
index 769f508a..d4323750 100644
--- a/tests/expr_and_series/order_dependent_lazy_test.py
+++ b/tests/expr_and_series/order_dependent_lazy_test.py
@@ -18,6 +18,17 @@ def test_order_dependent_raises_in_lazy(constructor: Constructor) -> None:
     lf = nw.from_native(constructor({"a": [1, 2, 3]})).lazy()
     with pytest.raises(OrderDependentExprError, match="Order-dependent expressions"):
         lf.select(nw.col("a").diff())
+    with pytest.raises(OrderDependentExprError, match="Order-dependent expressions"):
+        lf.select(nw.sum_horizontal(nw.col("a").diff()))
+    with pytest.raises(OrderDependentExprError, match="Order-dependent expressions"):
+        lf.select(nw.sum_horizontal(nw.col("a").diff(), nw.col("a")))
+
+    for agg in ["max", "min", "mean", "sum", "median", "std", "var"]:
+        with pytest.raises(OrderDependentExprError, match="Order-dependent expressions"):
+            lf.select(getattr(nw.col("a").diff(), agg)())
+    for agg in ["any", "all"]:
+        with pytest.raises(OrderDependentExprError, match="Order-dependent expressions"):
+            lf.select(getattr((nw.col("a").diff() > 0), agg)())
 
 
 def test_dask_order_dependent_ops() -> None:
diff --git a/tests/expr_and_series/unique_test.py b/tests/expr_and_series/unique_test.py
index 3c893d99..30b8b816 100644
--- a/tests/expr_and_series/unique_test.py
+++ b/tests/expr_and_series/unique_test.py
@@ -7,6 +7,7 @@ import pytest
 import narwhals as nw
 import narwhals.stable.v1 as nw_v1
 from narwhals.exceptions import LengthChangingExprError
+from narwhals.exceptions import ShapeError
 from tests.utils import Constructor
 from tests.utils import ConstructorEager
 from tests.utils import assert_equal_data
@@ -43,6 +44,8 @@ def test_unique_illegal_combination(constructor: Constructor) -> None:
     df = nw.from_native(constructor(data))
     with pytest.raises(LengthChangingExprError):
         df.select((nw.col("a").unique() + nw.col("b").unique()).sum())
+    with pytest.raises(ShapeError):
+        df.select(nw.col("a").unique() + nw.col("b"))
 
 
 def test_unique_series(constructor_eager: ConstructorEager) -> None:
diff --git a/tests/series_only/hist_test.py b/tests/series_only/hist_test.py
index fc0feed6..8c5315d0 100644
--- a/tests/series_only/hist_test.py
+++ b/tests/series_only/hist_test.py
@@ -88,6 +88,9 @@ def test_hist_bin(
     include_breakpoint: bool,
     request: pytest.FixtureRequest,
 ) -> None:
+    if "cudf" in str(constructor_eager):
+        # TODO(unassigned): too many spurious failures, report and revisit
+        return
     if "pyarrow_table" in str(constructor_eager) and PYARROW_VERSION < (13,):
         request.applymarker(pytest.mark.xfail)
 
@@ -170,6 +173,9 @@ def test_hist_count(
     include_breakpoint: bool,
     request: pytest.FixtureRequest,
 ) -> None:
+    if "cudf" in str(constructor_eager):
+        # TODO(unassigned): too many spurious failures, report and revisit
+        return
     if "pyarrow_table" in str(constructor_eager) and PYARROW_VERSION < (13,):
         request.applymarker(pytest.mark.xfail)
 
@@ -234,6 +240,9 @@ def test_hist_count_no_spread(
     *,
     request: pytest.FixtureRequest,
 ) -> None:
+    if "cudf" in str(constructor_eager):
+        # TODO(unassigned): too many spurious failures, report and revisit
+        return
     if "pyarrow_table" in str(constructor_eager) and PYARROW_VERSION < (13,):
         request.applymarker(pytest.mark.xfail)
 
@@ -281,6 +290,9 @@ def test_hist_no_data(
     *,
     request: pytest.FixtureRequest,
 ) -> None:
+    if "cudf" in str(constructor_eager):
+        # TODO(unassigned): too many spurious failures, report and revisit
+        return
     if "pyarrow_table" in str(constructor_eager) and PYARROW_VERSION < (13,):
         request.applymarker(pytest.mark.xfail)
     s = nw.from_native(constructor_eager({"values": []})).select(
@@ -305,6 +317,9 @@ def test_hist_small_bins(
     *,
     request: pytest.FixtureRequest,
 ) -> None:
+    if "cudf" in str(constructor_eager):
+        # TODO(unassigned): too many spurious failures, report and revisit
+        return
     if "pyarrow_table" in str(constructor_eager) and PYARROW_VERSION < (13,):
         request.applymarker(pytest.mark.xfail)
     s = nw.from_native(constructor_eager({"values": [1, 2, 3]}))
@@ -319,6 +334,9 @@ def test_hist_small_bins(
     "ignore:`Series.hist` is being called from the stable API although considered an unstable feature."
 )
 def test_hist_non_monotonic(constructor_eager: ConstructorEager) -> None:
+    if "cudf" in str(constructor_eager):
+        # TODO(unassigned): too many spurious failures, report and revisit
+        return
     df = nw.from_native(constructor_eager({"int": [0, 1, 2, 3, 4, 5, 6]}))
 
     with pytest.raises(ComputeError, match="monotonic"):
@@ -362,6 +380,10 @@ def test_hist_bin_hypotheis(
 ) -> None:
     import polars as pl
 
+    if "cudf" in str(constructor_eager):
+        # TODO(unassigned): too many spurious failures, report and revisit
+        return
+
     df = nw.from_native(constructor_eager({"values": data})).select(
         nw.col("values").cast(nw.Float64)
     )
@@ -416,6 +438,10 @@ def test_hist_count_hypothesis(
 ) -> None:
     import polars as pl
 
+    if "cudf" in str(constructor_eager):
+        # TODO(unassigned): too many spurious failures, report and revisit
+        return
+
     df = nw.from_native(constructor_eager({"values": data})).select(
         nw.col("values").cast(nw.Float64)
     )