Repository: narwhals-dev/narwhals

Issue:
[Enh]: Support `Expr.len()`
### We would like to learn about your use case. For example, if this feature is needed  to adopt Narwhals in an open source project, could you please enter the link to it below?


_No response_

### Please describe the purpose of the new feature or describe the problem to solve.

`nw.len()` is not enough is some situation. Here a reproducible example in polars:

```py
import polars as pl

df = pl.DataFrame({"a": [-1.5, -.5, 0.5, 1.5, 2.5], "b": [1,2,1,2,1,]})
df.select(
    pl.col("a").filter(pl.col("b")==1).len().alias("1"),
    pl.col("a").filter(pl.col("b")==2).len().alias("2"),
)
```

```terminal
shape: (1, 2)
┌─────┬─────┐
│ 1   ┆ 2   │
│ --- ┆ --- │
│ u32 ┆ u32 │
╞═════╪═════╡
│ 3   ┆ 2   │
└─────┴─────┘
```

### Suggest a solution if possible.

_No response_

### If you have tried alternatives, please describe them below.

_No response_

### Additional information that may help us understand your needs.

_No response_

Discussion:

Patch:
diff --git a/docs/api-reference/expressions.md b/docs/api-reference/expressions.md
index a8062b03..a717fed7 100644
--- a/docs/api-reference/expressions.md
+++ b/docs/api-reference/expressions.md
@@ -21,6 +21,7 @@
         - is_last_distinct
         - is_null
         - is_unique
+        - len
         - max
         - mean
         - min
diff --git a/docs/api-reference/series.md b/docs/api-reference/series.md
index 41e0c361..f1d2ddfc 100644
--- a/docs/api-reference/series.md
+++ b/docs/api-reference/series.md
@@ -25,6 +25,7 @@
         - is_sorted
         - is_unique
         - item
+        - len
         - max
         - mean
         - min
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index 623a5865..1423f1e1 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -297,6 +297,9 @@ class PandasExpr:
     def round(self: Self, decimals: int) -> Self:
         return reuse_series_implementation(self, "round", decimals)
 
+    def len(self: Self) -> Self:
+        return reuse_series_implementation(self, "len", returns_scalar=True)
+
     @property
     def str(self) -> PandasExprStringNamespace:
         return PandasExprStringNamespace(self)
diff --git a/narwhals/expression.py b/narwhals/expression.py
index a8c09368..dda218e8 100644
--- a/narwhals/expression.py
+++ b/narwhals/expression.py
@@ -1573,6 +1573,46 @@ class Expr:
 
         return self.__class__(lambda plx: self._call(plx).round(decimals))
 
+    def len(self) -> Expr:
+        r"""
+        Return the number of elements in the column.
+
+        Null values count towards the total.
+
+        Examples:
+            >>> import narwhals as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> data = {"a": ["x", "y", "z"], "b": [1, 2, 1]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+
+            Let's define a dataframe-agnostic function that computes the len over different values of "b" column:
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.select(
+            ...         nw.col("a").filter(nw.col("b") == 1).len().alias("a1"),
+            ...         nw.col("a").filter(nw.col("b") == 2).len().alias("a2"),
+            ...     )
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(df_pd)  # doctest: +NORMALIZE_WHITESPACE
+                a1  a2
+            0    2   1
+            >>> func(df_pl)  # doctest: +NORMALIZE_WHITESPACE
+            shape: (1, 2)
+            ┌─────┬─────┐
+            │ a1  ┆ a2  │
+            │ --- ┆ --- │
+            │ u32 ┆ u32 │
+            ╞═════╪═════╡
+            │ 2   ┆ 1   │
+            └─────┴─────┘
+        """
+        return self.__class__(lambda plx: self._call(plx).len())
+
     @property
     def str(self) -> ExprStringNamespace:
         return ExprStringNamespace(self)
diff --git a/narwhals/series.py b/narwhals/series.py
index b5a0d60f..ed0eea25 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -126,6 +126,34 @@ class Series:
     def __len__(self) -> int:
         return len(self._series)
 
+    def len(self) -> int:
+        r"""
+        Return the number of elements in the Series.
+
+        Null values count towards the total.
+
+        Examples:
+            >>> import narwhals as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> data = [1, 2, None]
+            >>> s_pd = pd.Series(data)
+            >>> s_pl = pl.Series(data)
+
+            Let's define a dataframe-agnostic function that computes the len of the series:
+
+            >>> def func(s):
+            ...     return nw.from_native(s, allow_series=True).len()
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(s_pd)  # doctest: +NORMALIZE_WHITESPACE
+            3
+            >>> func(s_pl)  # doctest: +NORMALIZE_WHITESPACE
+            3
+        """
+        return len(self._series)
+
     @property
     def dtype(self) -> Any:
         """


Test patch:
diff --git a/tests/expr/len_test.py b/tests/expr/len_test.py
new file mode 100644
index 00000000..54389fbe
--- /dev/null
+++ b/tests/expr/len_test.py
@@ -0,0 +1,22 @@
+from typing import Any
+
+import pandas as pd
+import polars as pl
+import pytest
+
+import narwhals as nw
+from tests.utils import compare_dicts
+
+data = {"a": list("xyz"), "b": [1, 2, 1]}
+expected = {"a1": [2], "a2": [1]}
+
+
+@pytest.mark.parametrize("constructor", [pd.DataFrame, pl.DataFrame])
+def test_len(constructor: Any) -> None:
+    df_raw = constructor(data)
+    df = nw.from_native(df_raw).select(
+        nw.col("a").filter(nw.col("b") == 1).len().alias("a1"),
+        nw.col("a").filter(nw.col("b") == 2).len().alias("a2"),
+    )
+
+    compare_dicts(nw.to_native(df), expected)
diff --git a/tests/test_series.py b/tests/test_series.py
index 70904784..ec178fa8 100644
--- a/tests/test_series.py
+++ b/tests/test_series.py
@@ -48,6 +48,8 @@ df_lazy = pl.LazyFrame({"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]})
 def test_len(df_raw: Any) -> None:
     result = len(nw.Series(df_raw["a"]))
     assert result == 3
+    result = nw.Series(df_raw["a"]).len()
+    assert result == 3
     result = len(nw.LazyFrame(df_raw).collect()["a"])
     assert result == 3