Repository: narwhals-dev/narwhals

Issue:
test: test group_by(...).agg(nw.col(...).var())
Realised today that we don't have any tests for `df.group_by(...).agg(nw.col(...).var())`. Let's add one!

This should be very similar to the std one:

https://github.com/narwhals-dev/narwhals/blob/44cf9e39840f85901d39f8f44b24b6ea39050c3b/tests/group_by_test.py#L130-L136

You could even just parametrise over the aggregation function and `expected`. Or just add it below


Discussion:

Patch:
diff --git a/docs/api-reference/expr.md b/docs/api-reference/expr.md
index 61901a99..523a45f0 100644
--- a/docs/api-reference/expr.md
+++ b/docs/api-reference/expr.md
@@ -58,5 +58,6 @@
         - sum
         - tail
         - unique
+        - var
       show_source: false
       show_bases: false
diff --git a/docs/api-reference/series.md b/docs/api-reference/series.md
index 79e7be8d..818e71b5 100644
--- a/docs/api-reference/series.md
+++ b/docs/api-reference/series.md
@@ -76,6 +76,7 @@
         - to_native
         - unique
         - value_counts
+        - var
         - zip_with
       show_source: false
       show_bases: false
diff --git a/narwhals/_arrow/expr.py b/narwhals/_arrow/expr.py
index ae8f2d03..845181bd 100644
--- a/narwhals/_arrow/expr.py
+++ b/narwhals/_arrow/expr.py
@@ -242,9 +242,12 @@ class ArrowExpr(CompliantExpr[ArrowSeries]):
     def n_unique(self: Self) -> Self:
         return reuse_series_implementation(self, "n_unique", returns_scalar=True)
 
-    def std(self: Self, ddof: int = 1) -> Self:
+    def std(self: Self, ddof: int) -> Self:
         return reuse_series_implementation(self, "std", ddof=ddof, returns_scalar=True)
 
+    def var(self: Self, ddof: int) -> Self:
+        return reuse_series_implementation(self, "var", ddof=ddof, returns_scalar=True)
+
     def skew(self: Self) -> Self:
         return reuse_series_implementation(self, "skew", returns_scalar=True)
 
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index 900a6036..f97bc604 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -330,6 +330,13 @@ class ArrowSeries(CompliantSeries):
             pc.stddev(self._native_series, ddof=ddof), _return_py_scalar
         )
 
+    def var(self: Self, ddof: int, *, _return_py_scalar: bool = True) -> float:
+        import pyarrow.compute as pc
+
+        return maybe_extract_py_scalar(  # type: ignore[no-any-return]
+            pc.variance(self._native_series, ddof=ddof), _return_py_scalar
+        )
+
     def skew(self: Self, *, _return_py_scalar: bool = True) -> float | None:
         import pyarrow.compute as pc
 
diff --git a/narwhals/_dask/expr.py b/narwhals/_dask/expr.py
index 748d332b..ac3b3c5b 100644
--- a/narwhals/_dask/expr.py
+++ b/narwhals/_dask/expr.py
@@ -432,7 +432,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             returns_scalar=True,
         )
 
-    def std(self, ddof: int = 1) -> Self:
+    def std(self, ddof: int) -> Self:
         return self._from_call(
             lambda _input, ddof: _input.std(ddof=ddof),
             "std",
@@ -440,6 +440,14 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             returns_scalar=True,
         )
 
+    def var(self, ddof: int) -> Self:
+        return self._from_call(
+            lambda _input, ddof: _input.var(ddof=ddof),
+            "var",
+            ddof,
+            returns_scalar=True,
+        )
+
     def skew(self: Self) -> Self:
         return self._from_call(
             lambda _input: _input.skew(),
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index a83d83c4..ef9c7a4d 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -246,9 +246,12 @@ class PandasLikeExpr(CompliantExpr[PandasLikeSeries]):
     def median(self) -> Self:
         return reuse_series_implementation(self, "median", returns_scalar=True)
 
-    def std(self, *, ddof: int = 1) -> Self:
+    def std(self, *, ddof: int) -> Self:
         return reuse_series_implementation(self, "std", ddof=ddof, returns_scalar=True)
 
+    def var(self, *, ddof: int) -> Self:
+        return reuse_series_implementation(self, "var", ddof=ddof, returns_scalar=True)
+
     def skew(self: Self) -> Self:
         return reuse_series_implementation(self, "skew", returns_scalar=True)
 
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index bd799039..f8d2e2cf 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -622,10 +622,14 @@ class PandasLikeSeries(CompliantSeries):
         ser = self._native_series
         return ser.median()
 
-    def std(self: Self, *, ddof: int = 1) -> float:
+    def std(self: Self, *, ddof: int) -> float:
         ser = self._native_series
         return ser.std(ddof=ddof)  # type: ignore[no-any-return]
 
+    def var(self: Self, *, ddof: int) -> float:
+        ser = self._native_series
+        return ser.var(ddof=ddof)  # type: ignore[no-any-return]
+
     def skew(self: Self) -> float | None:
         ser = self._native_series
         ser_not_null = ser.dropna()
diff --git a/narwhals/_spark_like/expr.py b/narwhals/_spark_like/expr.py
index 5695faf5..6074dff2 100644
--- a/narwhals/_spark_like/expr.py
+++ b/narwhals/_spark_like/expr.py
@@ -204,7 +204,7 @@ class SparkLikeExpr(CompliantExpr["Column"]):
 
         return self._from_call(_min, "min", returns_scalar=True)
 
-    def std(self, ddof: int = 1) -> Self:
+    def std(self, ddof: int) -> Self:
         import numpy as np  # ignore-banned-import
 
         def _std(_input: Column) -> Column:  # pragma: no cover
diff --git a/narwhals/expr.py b/narwhals/expr.py
index 674bb19a..1bc55baa 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -706,6 +706,56 @@ class Expr:
         """
         return self.__class__(lambda plx: self._to_compliant_expr(plx).std(ddof=ddof))
 
+    def var(self, *, ddof: int = 1) -> Self:
+        """Get variance.
+
+        Arguments:
+            ddof: "Delta Degrees of Freedom": the divisor used in the calculation is N - ddof,
+                     where N represents the number of elements. By default ddof is 1.
+
+        Returns:
+            A new expression.
+
+        Examples:
+            >>> import polars as pl
+            >>> import pandas as pd
+            >>> import pyarrow as pa
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoFrameT
+            >>> df_pd = pd.DataFrame({"a": [20, 25, 60], "b": [1.5, 1, -1.4]})
+            >>> df_pl = pl.DataFrame({"a": [20, 25, 60], "b": [1.5, 1, -1.4]})
+            >>> df_pa = pa.table({"a": [20, 25, 60], "b": [1.5, 1, -1.4]})
+
+            Let's define a dataframe-agnostic function:
+
+            >>> def agnostic_var(df_native: IntoFrameT) -> IntoFrameT:
+            ...     df = nw.from_native(df_native)
+            ...     return df.select(nw.col("a", "b").var(ddof=0)).to_native()
+
+            We can then pass any supported library such as Pandas, Polars, or PyArrow to `func`:
+
+            >>> agnostic_var(df_pd)
+                        a         b
+            0  316.666667  1.602222
+            >>> agnostic_var(df_pl)
+            shape: (1, 2)
+            ┌────────────┬──────────┐
+            │ a          ┆ b        │
+            │ ---        ┆ ---      │
+            │ f64        ┆ f64      │
+            ╞════════════╪══════════╡
+            │ 316.666667 ┆ 1.602222 │
+            └────────────┴──────────┘
+            >>> agnostic_var(df_pa)
+            pyarrow.Table
+            a: double
+            b: double
+            ----
+            a: [[316.6666666666667]]
+            b: [[1.6022222222222222]]
+        """
+        return self.__class__(lambda plx: self._to_compliant_expr(plx).var(ddof=ddof))
+
     def map_batches(
         self,
         function: Callable[[Any], Self],
diff --git a/narwhals/series.py b/narwhals/series.py
index 2ef2efc7..8601d72c 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -1029,7 +1029,7 @@ class Series(Generic[IntoSeriesT]):
         """Get the standard deviation of this Series.
 
         Arguments:
-            ddof: “Delta Degrees of Freedom”: the divisor used in the calculation is N - ddof,
+            ddof: "Delta Degrees of Freedom": the divisor used in the calculation is N - ddof,
                      where N represents the number of elements.
 
         Examples:
@@ -1056,6 +1056,37 @@ class Series(Generic[IntoSeriesT]):
         """
         return self._compliant_series.std(ddof=ddof)
 
+    def var(self, *, ddof: int = 1) -> Any:
+        """Get the variance of this Series.
+
+        Arguments:
+            ddof: "Delta Degrees of Freedom": the divisor used in the calculation is N - ddof,
+                     where N represents the number of elements.
+
+        Examples:
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoSeries
+            >>> s = [1, 2, 3]
+            >>> s_pd = pd.Series(s)
+            >>> s_pl = pl.Series(s)
+
+            We define a library agnostic function:
+
+            >>> def agnostic_var(s_native: IntoSeries):
+            ...     s = nw.from_native(s_native, series_only=True)
+            ...     return s.var()
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> agnostic_var(s_pd)
+            np.float64(1.0)
+            >>> agnostic_var(s_pl)
+            1.0
+        """
+        return self._compliant_series.var(ddof=ddof)
+
     def clip(
         self, lower_bound: Any | None = None, upper_bound: Any | None = None
     ) -> Self:
diff --git a/pyproject.toml b/pyproject.toml
index f6aaaa7a..cc92d1b7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -152,7 +152,7 @@ exclude_also = [
   "if sys.version_info() <",
   "if (:?self._)?implementation is Implementation.MODIN",
   "if (:?self._)?implementation is Implementation.CUDF",
-  'request.applymarker\(pytest.mark.xfail\)',
+  'request.applymarker\(pytest.mark.xfail',
   '\w+._backend_version < ',
   'backend_version <',
   'if "cudf" in str\(constructor'


Test patch:
diff --git a/tests/expr_and_series/std_test.py b/tests/expr_and_series/std_test.py
index db51c657..b8310080 100644
--- a/tests/expr_and_series/std_test.py
+++ b/tests/expr_and_series/std_test.py
@@ -1,46 +1,44 @@
 from __future__ import annotations
 
+import pytest
+
 import narwhals.stable.v1 as nw
 from tests.utils import Constructor
 from tests.utils import ConstructorEager
 from tests.utils import assert_equal_data
 
 data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
+data_with_nulls = {"a": [1, 3, 2, None], "b": [4, 4, 6, None], "z": [7.0, 8, 9, None]}
+
+expected_results = {
+    "a_ddof_1": [1.0],
+    "a_ddof_0": [0.816497],
+    "b_ddof_2": [1.632993],
+    "z_ddof_0": [0.816497],
+}
 
 
-def test_std(constructor: Constructor) -> None:
-    df = nw.from_native(constructor(data))
+@pytest.mark.parametrize("input_data", [data, data_with_nulls])
+def test_std(constructor: Constructor, input_data: dict[str, list[float | None]]) -> None:
+    df = nw.from_native(constructor(input_data))
     result = df.select(
-        nw.col("a").std().alias("a_ddof_default"),
         nw.col("a").std(ddof=1).alias("a_ddof_1"),
         nw.col("a").std(ddof=0).alias("a_ddof_0"),
         nw.col("b").std(ddof=2).alias("b_ddof_2"),
         nw.col("z").std(ddof=0).alias("z_ddof_0"),
     )
-    expected = {
-        "a_ddof_default": [1.0],
-        "a_ddof_1": [1.0],
-        "a_ddof_0": [0.816497],
-        "b_ddof_2": [1.632993],
-        "z_ddof_0": [0.816497],
-    }
-    assert_equal_data(result, expected)
+    assert_equal_data(result, expected_results)
 
 
-def test_std_series(constructor_eager: ConstructorEager) -> None:
-    df = nw.from_native(constructor_eager(data), eager_only=True)
+@pytest.mark.parametrize("input_data", [data, data_with_nulls])
+def test_std_series(
+    constructor_eager: ConstructorEager, input_data: dict[str, list[float | None]]
+) -> None:
+    df = nw.from_native(constructor_eager(input_data), eager_only=True)
     result = {
-        "a_ddof_default": [df["a"].std()],
         "a_ddof_1": [df["a"].std(ddof=1)],
         "a_ddof_0": [df["a"].std(ddof=0)],
         "b_ddof_2": [df["b"].std(ddof=2)],
         "z_ddof_0": [df["z"].std(ddof=0)],
     }
-    expected = {
-        "a_ddof_default": [1.0],
-        "a_ddof_1": [1.0],
-        "a_ddof_0": [0.816497],
-        "b_ddof_2": [1.632993],
-        "z_ddof_0": [0.816497],
-    }
-    assert_equal_data(result, expected)
+    assert_equal_data(result, expected_results)
diff --git a/tests/expr_and_series/var_test.py b/tests/expr_and_series/var_test.py
new file mode 100644
index 00000000..bab97d38
--- /dev/null
+++ b/tests/expr_and_series/var_test.py
@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+import pytest
+
+import narwhals.stable.v1 as nw
+from tests.utils import Constructor
+from tests.utils import ConstructorEager
+from tests.utils import assert_equal_data
+
+data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
+data_with_nulls = {"a": [1, 3, 2, None], "b": [4, 4, 6, None], "z": [7.0, 8, 9, None]}
+
+expected_results = {
+    "a_ddof_1": [1.0],
+    "a_ddof_0": [0.6666666666666666],
+    "b_ddof_2": [2.666666666666667],
+    "z_ddof_0": [0.6666666666666666],
+}
+
+
+@pytest.mark.parametrize("input_data", [data, data_with_nulls])
+def test_var(constructor: Constructor, input_data: dict[str, list[float | None]]) -> None:
+    df = nw.from_native(constructor(input_data))
+    result = df.select(
+        nw.col("a").var(ddof=1).alias("a_ddof_1"),
+        nw.col("a").var(ddof=0).alias("a_ddof_0"),
+        nw.col("b").var(ddof=2).alias("b_ddof_2"),
+        nw.col("z").var(ddof=0).alias("z_ddof_0"),
+    )
+    assert_equal_data(result, expected_results)
+
+
+@pytest.mark.parametrize("input_data", [data, data_with_nulls])
+def test_var_series(
+    constructor_eager: ConstructorEager, input_data: dict[str, list[float | None]]
+) -> None:
+    df = nw.from_native(constructor_eager(input_data), eager_only=True)
+    result = {
+        "a_ddof_1": [df["a"].var(ddof=1)],
+        "a_ddof_0": [df["a"].var(ddof=0)],
+        "b_ddof_2": [df["b"].var(ddof=2)],
+        "z_ddof_0": [df["z"].var(ddof=0)],
+    }
+    assert_equal_data(result, expected_results)
diff --git a/tests/group_by_test.py b/tests/group_by_test.py
index e51cbac6..61e63e74 100644
--- a/tests/group_by_test.py
+++ b/tests/group_by_test.py
@@ -105,6 +105,7 @@ def test_group_by_iter(constructor_eager: ConstructorEager) -> None:
         ("max", {"a": [1, 2], "b": [2, 3]}),
         ("min", {"a": [1, 2], "b": [1, 3]}),
         ("std", {"a": [1, 2], "b": [0.707107, None]}),
+        ("var", {"a": [1, 2], "b": [0.5, None]}),
         ("len", {"a": [1, 2], "b": [3, 1]}),
         ("n_unique", {"a": [1, 2], "b": [3, 1]}),
         ("count", {"a": [1, 2], "b": [2, 1]}),
@@ -118,6 +119,9 @@ def test_group_by_depth_1_agg(
 ) -> None:
     if "cudf" in str(constructor) and attr == "n_unique":
         request.applymarker(pytest.mark.xfail)
+    if "pandas_pyarrow" in str(constructor) and attr == "var" and PANDAS_VERSION < (2, 1):
+        # Known issue with variance calculation in pandas 2.0.x with pyarrow backend in groupby operations"
+        request.applymarker(pytest.mark.xfail)
     data = {"a": [1, 1, 1, 2], "b": [1, None, 2, 3]}
     expr = getattr(nw.col("b"), attr)()
     result = nw.from_native(constructor(data)).group_by("a").agg(expr).sort("a")