Repository: narwhals-dev/narwhals
Pull request #1600 (2024-12-15 16:41:26)

docs: more args and returns documentation in DataFrame class
<!--
# 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
- [x] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues

- Related issue #1293
- Closes #\<issue number\>

## Checklist

- [x] Code follows style guide (ruff)
- [ ] Tests added
- [ ] Documented the changes

## If you have comments or can explain your changes, please do so below
I keep chipping away at this. :)


Patch:
diff --git a/.github/workflows/check_docs_build.yml b/.github/workflows/check_docs_build.yml
index fd878414..c85fa35d 100644
--- a/.github/workflows/check_docs_build.yml
+++ b/.github/workflows/check_docs_build.yml
@@ -23,13 +23,9 @@ jobs:
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
-          cache-dependency-glob: "**requirements*.txt"
-      - name: install-reqs
-        run: uv pip install --upgrade tox virtualenv setuptools pip -r requirements-dev.txt --system
-      - name: install-docs-reqs
-        run: uv pip install --upgrade -r docs/requirements-docs.txt --system
+          cache-dependency-glob: "pyproject.toml"
       - name: local-install
-        run: uv pip install -e . --system
+        run: uv pip install -e .[docs] --system
       - name: check-no-errors
         run: |
           python -m mkdocs build 2>&1 | tee output.txt
diff --git a/.github/workflows/check_tpch_queries.yml b/.github/workflows/check_tpch_queries.yml
index b727ab56..d98d205b 100644
--- a/.github/workflows/check_tpch_queries.yml
+++ b/.github/workflows/check_tpch_queries.yml
@@ -23,11 +23,9 @@ jobs:
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
-          cache-dependency-glob: "**requirements*.txt"
-      - name: install-reqs
-        run: uv pip install --upgrade -r requirements-dev.txt --system
+          cache-dependency-glob: "pyproject.toml"
       - name: local-install
-        run: uv pip install -e . --system
+        run: uv pip install -e ".[dev]" --system
       - name: generate-data
         run: cd tpch && python generate_data.py
       - name: tpch-tests 
diff --git a/.github/workflows/extremes.yml b/.github/workflows/extremes.yml
index 166b17fb..efaeb85f 100644
--- a/.github/workflows/extremes.yml
+++ b/.github/workflows/extremes.yml
@@ -23,16 +23,23 @@ jobs:
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
-          cache-dependency-glob: "**requirements*.txt"
+          cache-dependency-glob: "pyproject.toml"
       - name: install-minimum-versions
-        run: uv pip install tox virtualenv setuptools pandas==0.25.3 polars==0.20.3 numpy==1.17.5 pyarrow==11.0.0 scipy==1.5.0 scikit-learn==1.1.0 tzdata --system
+        run: uv pip install pipdeptree tox virtualenv setuptools pandas==0.25.3 polars==0.20.3 numpy==1.17.5 pyarrow==11.0.0 "pyarrow-stubs<17" scipy==1.5.0 scikit-learn==1.1.0 tzdata --system
       - name: install-reqs
         run: |
-          uv pip install -r requirements-dev.txt --system
-          : # pyspark >= 3.3.0 is not compatible with pandas==0.25.3
-          uv pip uninstall pyspark --system
+          uv pip install -e ".[dev]" --system
       - name: show-deps
         run: uv pip freeze
+      - name: Assert dependencies
+        run: |
+          DEPS=$(uv pip freeze)
+          echo "$DEPS" | grep 'pandas==0.25.3'
+          echo "$DEPS" | grep 'polars==0.20.3'
+          echo "$DEPS" | grep 'numpy==1.17.5'
+          echo "$DEPS" | grep 'pyarrow==11.0.0'
+          echo "$DEPS" | grep 'scipy==1.5.0'
+          echo "$DEPS" | grep 'scikit-learn==1.1.0'
       - name: Run pytest
         run: pytest tests --cov=narwhals --cov=tests --cov-fail-under=50 --runslow
 
@@ -53,13 +60,15 @@ jobs:
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
-          cache-dependency-glob: "**requirements*.txt"
+          cache-dependency-glob: "pyproject.toml"
       - name: install-minimum-versions
-        run: uv pip install tox virtualenv setuptools pandas==1.1.5 polars==0.20.3 numpy==1.17.5 pyarrow==11.0.0 pyspark==3.3.0 scipy==1.5.0 scikit-learn==1.1.0 tzdata --system
+        run: uv pip install pipdeptree tox virtualenv setuptools pandas==1.1.5 polars==0.20.3 numpy==1.17.5 pyarrow==11.0.0 "pyarrow-stubs<17" pyspark==3.3.0 scipy==1.5.0 scikit-learn==1.1.0 tzdata --system
       - name: install-reqs
-        run: uv pip install -r requirements-dev.txt --system
+        run: uv pip install -e ".[dev]" --system
       - name: show-deps
         run: uv pip freeze
+      - name: show-deptree
+        run: pipdeptree
       - name: Run pytest
         run: pytest tests --cov=narwhals --cov=tests --cov-fail-under=50 --runslow
 
@@ -80,11 +89,11 @@ jobs:
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
-          cache-dependency-glob: "**requirements*.txt"
+          cache-dependency-glob: "pyproject.toml"
       - name: install-not-so-old-versions
         run: uv pip install tox virtualenv setuptools pandas==2.0.3 polars==0.20.8 numpy==1.24.4 pyarrow==14.0.0 pyspark==3.4.0 scipy==1.8.0 scikit-learn==1.3.0 dask[dataframe]==2024.7 tzdata --system
       - name: install-reqs
-        run: uv pip install -r requirements-dev.txt --system
+        run: uv pip install -e ".[dev]" --system
       - name: show-deps
         run: uv pip freeze
       - name: Run pytest
@@ -107,9 +116,9 @@ jobs:
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
-          cache-dependency-glob: "**requirements*.txt"
+          cache-dependency-glob: "pyproject.toml"
       - name: install-reqs
-        run: uv pip install --upgrade tox virtualenv setuptools pip -r requirements-dev.txt --system
+        run: uv pip install -e ".[dev]" --system
       - name: install-kaggle
         run: uv pip install kaggle --system
       - name: Download Kaggle notebook artifact
diff --git a/.github/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml
index 354dafd4..8aaaba51 100644
--- a/.github/workflows/mkdocs.yml
+++ b/.github/workflows/mkdocs.yml
@@ -27,6 +27,9 @@ jobs:
           path: .cache
           restore-keys: |
             mkdocs-material-
-      - run: pip install -r docs/requirements-docs.txt -e . pandas polars pyarrow
+      - name: griffe
+        # hopefully temporary until https://github.com/mkdocstrings/mkdocstrings/issues/716
+        run: pip install git+https://github.com/MarcoGorelli/griffe.git@no-overloads
+      - run: pip install -e .[docs]
 
       - run: mkdocs gh-deploy --force
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 86e98ebb..5d728590 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -96,9 +96,13 @@ Here's how you can set up your local development environment to contribute.
       uv venv -p 3.12 --seed
       ```
    4. Activate it. On Linux, this is `. .venv/bin/activate`, on Windows `.\.venv\Scripts\activate`.
-2. Install Narwhals: `uv pip install -e .`
-3. Install test requirements: `uv pip install -r requirements-dev.txt`
-4. Install docs requirements: `uv pip install -r docs/requirements-docs.txt`
+2. Install Narwhals: `uv pip install -e ".[dev, docs]"`
+3. Install a fork of griffe:
+   ```
+   uv pip install git+https://github.com/MarcoGorelli/griffe.git@no-overloads
+   ```
+   This is hopefully temporary until https://github.com/mkdocstrings/mkdocstrings/issues/716
+   is addressed.
 
 You should also install pre-commit:
 ```
@@ -138,6 +142,22 @@ nox
 
 Notice that nox will also require to have all the python versions that are defined in the `noxfile.py` installed in your system.
 
+#### Hypothesis tests
+
+We use Hypothesis to generate some random tests, to check for robustness.
+To keep local test suite times down, not all of these run by default - you can
+run them by passing the `--runslow` flag to PyTest.
+
+#### Testing Dask and Modin
+
+To keep local development test times down, Dask and Modin are excluded from dev
+dependencies, and their tests only run in CI. If you install them with
+
+```
+uv pip install -U dask[dataframe] modin
+```
+then their tests will run too.
+
 #### Testing cuDF
 
 We can't currently test in CI against cuDF, but you can test it manually in Kaggle using GPUs. Please follow this [Kaggle notebook](https://www.kaggle.com/code/marcogorelli/testing-cudf-in-narwhals) to run the tests.
diff --git a/docs/api-reference/expr.md b/docs/api-reference/expr.md
index 61901a99..fef26d1e 100644
--- a/docs/api-reference/expr.md
+++ b/docs/api-reference/expr.md
@@ -48,7 +48,9 @@
         - quantile
         - replace_strict
         - rolling_mean
+        - rolling_std
         - rolling_sum
+        - rolling_var
         - round
         - sample
         - shift
@@ -58,5 +60,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..d5a446dc 100644
--- a/docs/api-reference/series.md
+++ b/docs/api-reference/series.md
@@ -56,7 +56,9 @@
         - rename
         - replace_strict
         - rolling_mean
+        - rolling_std
         - rolling_sum
+        - rolling_var
         - round
         - sample
         - scatter
@@ -76,6 +78,7 @@
         - to_native
         - unique
         - value_counts
+        - var
         - zip_with
       show_source: false
       show_bases: false
diff --git a/docs/installation.md b/docs/installation.md
index 67792981..16639d50 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -30,7 +30,7 @@ To verify the installation, start the Python REPL and execute:
 ```python
 >>> import narwhals
 >>> narwhals.__version__
-'1.18.3'
+'1.18.4'
 ```
 
 If you see the version number, then the installation was successful!
diff --git a/narwhals/__init__.py b/narwhals/__init__.py
index 20183ee0..0ca43e3b 100644
--- a/narwhals/__init__.py
+++ b/narwhals/__init__.py
@@ -79,7 +79,7 @@ from narwhals.utils import maybe_get_index
 from narwhals.utils import maybe_reset_index
 from narwhals.utils import maybe_set_index
 
-__version__ = "1.18.3"
+__version__ = "1.18.4"
 
 __all__ = [
     "Array",
diff --git a/narwhals/_arrow/expr.py b/narwhals/_arrow/expr.py
index ae8f2d03..b960ffa4 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)
 
@@ -508,6 +511,40 @@ class ArrowExpr(CompliantExpr[ArrowSeries]):
             center=center,
         )
 
+    def rolling_var(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None,
+        center: bool,
+        ddof: int,
+    ) -> Self:
+        return reuse_series_implementation(
+            self,
+            "rolling_var",
+            window_size=window_size,
+            min_periods=min_periods,
+            center=center,
+            ddof=ddof,
+        )
+
+    def rolling_std(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None,
+        center: bool,
+        ddof: int,
+    ) -> Self:
+        return reuse_series_implementation(
+            self,
+            "rolling_std",
+            window_size=window_size,
+            min_periods=min_periods,
+            center=center,
+            ddof=ddof,
+        )
+
     @property
     def dt(self: Self) -> ArrowExprDateTimeNamespace:
         return ArrowExprDateTimeNamespace(self)
diff --git a/narwhals/_arrow/group_by.py b/narwhals/_arrow/group_by.py
index 7e2422a1..93835463 100644
--- a/narwhals/_arrow/group_by.py
+++ b/narwhals/_arrow/group_by.py
@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from copy import copy
+import collections
 from typing import TYPE_CHECKING
 from typing import Any
 from typing import Callable
@@ -69,7 +69,6 @@ class ArrowGroupBy:
             namespace=self._df.__narwhals_namespace__(),
             **named_aggs,
         )
-        output_names: list[str] = copy(self._keys)
         for expr in exprs:
             if expr._output_names is None:
                 msg = (
@@ -78,13 +77,11 @@ class ArrowGroupBy:
                     "`nw.col('a', 'b')`\n"
                 )
                 raise ValueError(msg)
-            output_names.extend(expr._output_names)
 
         return agg_arrow(
             self._grouped,
             exprs,
             self._keys,
-            output_names,
             self._df._from_native_frame,
         )
 
@@ -126,7 +123,6 @@ def agg_arrow(
     grouped: pa.TableGroupBy,
     exprs: Sequence[CompliantExpr[ArrowSeries]],
     keys: list[str],
-    output_names: list[str],
     from_dataframe: Callable[[Any], ArrowDataFrame],
 ) -> ArrowDataFrame:
     import pyarrow.compute as pc
@@ -141,64 +137,78 @@ def agg_arrow(
             all_simple_aggs = False
             break
 
-    if all_simple_aggs:
-        # Mapping from output name to
-        # (aggregation_args, pyarrow_output_name)  # noqa: ERA001
-        simple_aggregations: dict[str, tuple[tuple[Any, ...], str]] = {}
-        for expr in exprs:
-            if expr._depth == 0:
-                # e.g. agg(nw.len()) # noqa: ERA001
-                if (
-                    expr._output_names is None or expr._function_name != "len"
-                ):  # pragma: no cover
-                    msg = "Safety assertion failed, please report a bug to https://github.com/narwhals-dev/narwhals/issues"
-                    raise AssertionError(msg)
-                simple_aggregations[expr._output_names[0]] = (
-                    (keys[0], "count", pc.CountOptions(mode="all")),
-                    f"{keys[0]}_count",
-                )
-                continue
+    if not all_simple_aggs:
+        msg = (
+            "Non-trivial complex aggregation found.\n\n"
+            "Hint: you were probably trying to apply a non-elementary aggregation with a "
+            "pyarrow table.\n"
+            "Please rewrite your query such that group-by aggregations "
+            "are elementary. For example, instead of:\n\n"
+            "    df.group_by('a').agg(nw.col('b').round(2).mean())\n\n"
+            "use:\n\n"
+            "    df.with_columns(nw.col('b').round(2)).group_by('a').agg(nw.col('b').mean())\n\n"
+        )
+        raise ValueError(msg)
+
+    aggs: list[tuple[str, str, pc.FunctionOptions | None]] = []
+    expected_pyarrow_column_names: list[str] = keys.copy()
+    new_column_names: list[str] = keys.copy()
 
-            # e.g. agg(nw.mean('a')) # noqa: ERA001
+    for expr in exprs:
+        if expr._depth == 0:
+            # e.g. agg(nw.len()) # noqa: ERA001
             if (
-                expr._depth != 1 or expr._root_names is None or expr._output_names is None
+                expr._output_names is None or expr._function_name != "len"
             ):  # pragma: no cover
                 msg = "Safety assertion failed, please report a bug to https://github.com/narwhals-dev/narwhals/issues"
                 raise AssertionError(msg)
 
-            function_name = remove_prefix(expr._function_name, "col->")
-            function_name, option = polars_to_arrow_aggregations().get(
-                function_name, (function_name, None)
-            )
+            new_column_names.append(expr._output_names[0])
+            expected_pyarrow_column_names.append(f"{keys[0]}_count")
+            aggs.append((keys[0], "count", pc.CountOptions(mode="all")))
 
-            for root_name, output_name in zip(expr._root_names, expr._output_names):
-                simple_aggregations[output_name] = (
-                    (root_name, function_name, option),
-                    f"{root_name}_{function_name}",
-                )
+            continue
 
-        aggs: list[Any] = []
-        name_mapping = {}
-        for output_name, (
-            aggregation_args,
-            pyarrow_output_name,
-        ) in simple_aggregations.items():
-            aggs.append(aggregation_args)
-            name_mapping[pyarrow_output_name] = output_name
-        result_simple = grouped.aggregate(aggs)
-        result_simple = result_simple.rename_columns(
-            [name_mapping.get(col, col) for col in result_simple.column_names]
-        ).select(output_names)
-        return from_dataframe(result_simple)
-
-    msg = (
-        "Non-trivial complex aggregation found.\n\n"
-        "Hint: you were probably trying to apply a non-elementary aggregation with a "
-        "pyarrow table.\n"
-        "Please rewrite your query such that group-by aggregations "
-        "are elementary. For example, instead of:\n\n"
-        "    df.group_by('a').agg(nw.col('b').round(2).mean())\n\n"
-        "use:\n\n"
-        "    df.with_columns(nw.col('b').round(2)).group_by('a').agg(nw.col('b').mean())\n\n"
-    )
-    raise ValueError(msg)
+        # e.g. agg(nw.mean('a')) # noqa: ERA001
+        if (
+            expr._depth != 1 or expr._root_names is None or expr._output_names is None
+        ):  # pragma: no cover
+            msg = "Safety assertion failed, please report a bug to https://github.com/narwhals-dev/narwhals/issues"
+            raise AssertionError(msg)
+
+        function_name = remove_prefix(expr._function_name, "col->")
+        function_name, option = polars_to_arrow_aggregations().get(
+            function_name, (function_name, None)
+        )
+
+        new_column_names.extend(expr._output_names)
+        expected_pyarrow_column_names.extend(
+            [f"{root_name}_{function_name}" for root_name in expr._root_names]
+        )
+        aggs.extend(
+            [(root_name, function_name, option) for root_name in expr._root_names]
+        )
+
+    result_simple = grouped.aggregate(aggs)
+
+    # Rename columns, being very careful
+    expected_old_names_indices: dict[str, list[int]] = collections.defaultdict(list)
+    for idx, item in enumerate(expected_pyarrow_column_names):
+        expected_old_names_indices[item].append(idx)
+    if not (
+        set(result_simple.column_names) == set(expected_pyarrow_column_names)
+        and len(result_simple.column_names) == len(expected_pyarrow_column_names)
+    ):  # pragma: no cover
+        msg = (
+            f"Safety assertion failed, expected {expected_pyarrow_column_names} "
+            f"got {result_simple.column_names}, "
+            "please report a bug at https://github.com/narwhals-dev/narwhals/issues"
+        )
+        raise AssertionError(msg)
+    index_map: list[int] = [
+        expected_old_names_indices[item].pop(0) for item in result_simple.column_names
+    ]
+    new_column_names = [new_column_names[i] for i in index_map]
+
+    result_simple = result_simple.rename_columns(new_column_names)
+    return from_dataframe(result_simple)
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index 900a6036..2f29ab9d 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -13,6 +13,7 @@ from narwhals._arrow.utils import cast_for_truediv
 from narwhals._arrow.utils import floordiv_compat
 from narwhals._arrow.utils import narwhals_to_native_dtype
 from narwhals._arrow.utils import native_to_narwhals_dtype
+from narwhals._arrow.utils import pad_series
 from narwhals._arrow.utils import parse_datetime_format
 from narwhals.utils import Implementation
 from narwhals.utils import generate_temporary_column_name
@@ -330,6 +331,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
 
@@ -928,27 +936,12 @@ class ArrowSeries(CompliantSeries):
         min_periods: int | None,
         center: bool,
     ) -> Self:
-        import pyarrow as pa
         import pyarrow.compute as pc
 
         min_periods = min_periods if min_periods is not None else window_size
-        if center:
-            offset_left = window_size // 2
-            offset_right = offset_left - (
-                window_size % 2 == 0
-            )  # subtract one if window_size is even
-
-            native_series = self._native_series
+        padded_series, offset = pad_series(self, window_size=window_size, center=center)
 
-            pad_left = pa.array([None] * offset_left, type=native_series.type)
-            pad_right = pa.array([None] * offset_right, type=native_series.type)
-            padded_arr = self._from_native_series(
-                pa.concat_arrays([pad_left, native_series.combine_chunks(), pad_right])
-            )
-        else:
-            padded_arr = self
-
-        cum_sum = padded_arr.cum_sum(reverse=False).fill_null(
+        cum_sum = padded_series.cum_sum(reverse=False).fill_null(
             value=None, strategy="forward", limit=None
         )
         rolling_sum = (
@@ -958,7 +951,7 @@ class ArrowSeries(CompliantSeries):
             else cum_sum
         )
 
-        valid_count = padded_arr.cum_count(reverse=False)
+        valid_count = padded_series.cum_count(reverse=False)
         count_in_window = valid_count - valid_count.shift(window_size).fill_null(
             value=0, strategy=None, limit=None
         )
@@ -970,9 +963,7 @@ class ArrowSeries(CompliantSeries):
                 None,
             )
         )
-        if center:
-            result = result[offset_left + offset_right :]
-        return result
+        return result[offset:]
 
     def rolling_mean(
         self: Self,
@@ -981,27 +972,12 @@ class ArrowSeries(CompliantSeries):
         min_periods: int | None,
         center: bool,
     ) -> Self:
-        import pyarrow as pa
         import pyarrow.compute as pc
 
         min_periods = min_periods if min_periods is not None else window_size
-        if center:
-            offset_left = window_size // 2
-            offset_right = offset_left - (
-                window_size % 2 == 0
-            )  # subtract one if window_size is even
-
-            native_series = self._native_series
+        padded_series, offset = pad_series(self, window_size=window_size, center=center)
 
-            pad_left = pa.array([None] * offset_left, type=native_series.type)
-            pad_right = pa.array([None] * offset_right, type=native_series.type)
-            padded_arr = self._from_native_series(
-                pa.concat_arrays([pad_left, native_series.combine_chunks(), pad_right])
-            )
-        else:
-            padded_arr = self
-
-        cum_sum = padded_arr.cum_sum(reverse=False).fill_null(
+        cum_sum = padded_series.cum_sum(reverse=False).fill_null(
             value=None, strategy="forward", limit=None
         )
         rolling_sum = (
@@ -1011,7 +987,7 @@ class ArrowSeries(CompliantSeries):
             else cum_sum
         )
 
-        valid_count = padded_arr.cum_count(reverse=False)
+        valid_count = padded_series.cum_count(reverse=False)
         count_in_window = valid_count - valid_count.shift(window_size).fill_null(
             value=0, strategy=None, limit=None
         )
@@ -1026,9 +1002,74 @@ class ArrowSeries(CompliantSeries):
             )
             / count_in_window
         )
-        if center:
-            result = result[offset_left + offset_right :]
-        return result
+        return result[offset:]
+
+    def rolling_var(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None,
+        center: bool,
+        ddof: int,
+    ) -> Self:
+        import pyarrow.compute as pc  # ignore-banned-import
+
+        min_periods = min_periods if min_periods is not None else window_size
+        padded_series, offset = pad_series(self, window_size=window_size, center=center)
+
+        cum_sum = padded_series.cum_sum(reverse=False).fill_null(
+            value=None, strategy="forward", limit=None
+        )
+        rolling_sum = (
+            cum_sum
+            - cum_sum.shift(window_size).fill_null(value=0, strategy=None, limit=None)
+            if window_size != 0
+            else cum_sum
+        )
+
+        cum_sum_sq = (
+            padded_series.__pow__(2)
+            .cum_sum(reverse=False)
+            .fill_null(value=None, strategy="forward", limit=None)
+        )
+        rolling_sum_sq = (
+            cum_sum_sq
+            - cum_sum_sq.shift(window_size).fill_null(value=0, strategy=None, limit=None)
+            if window_size != 0
+            else cum_sum_sq
+        )
+
+        valid_count = padded_series.cum_count(reverse=False)
+        count_in_window = valid_count - valid_count.shift(window_size).fill_null(
+            value=0, strategy=None, limit=None
+        )
+
+        result = self._from_native_series(
+            pc.if_else(
+                (count_in_window >= min_periods)._native_series,
+                (rolling_sum_sq - (rolling_sum**2 / count_in_window))._native_series,
+                None,
+            )
+        ) / self._from_native_series(
+            pc.max_element_wise((count_in_window - ddof)._native_series, 0)
+        )
+
+        return result[offset:]
+
+    def rolling_std(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None,
+        center: bool,
+        ddof: int,
+    ) -> Self:
+        return (
+            self.rolling_var(
+                window_size=window_size, min_periods=min_periods, center=center, ddof=ddof
+            )
+            ** 0.5
+        )
 
     def __iter__(self: Self) -> Iterator[Any]:
         yield from (
diff --git a/narwhals/_arrow/utils.py b/narwhals/_arrow/utils.py
index f9a8fa89..8c895b81 100644
--- a/narwhals/_arrow/utils.py
+++ b/narwhals/_arrow/utils.py
@@ -502,3 +502,39 @@ def _parse_time_format(arr: pa.Array) -> str:
         if pc.all(matches.is_valid()).as_py():
             return time_fmt
     return ""
+
+
+def pad_series(
+    series: ArrowSeries, *, window_size: int, center: bool
+) -> tuple[ArrowSeries, int]:
+    """Pad series with None values on the left and/or right side, depending on the specified parameters.
+
+    Arguments:
+        series: The input ArrowSeries to be padded.
+        window_size: The desired size of the window.
+        center: Specifies whether to center the padding or not.
+
+    Returns:
+        A tuple containing the padded ArrowSeries and the offset value.
+    """
+    import pyarrow as pa  # ignore-banned-import
+
+    if center:
+        offset_left = window_size // 2
+        offset_right = offset_left - (
+            window_size % 2 == 0
+        )  # subtract one if window_size is even
+
+        native_series = series._native_series
+
+        pad_left = pa.array([None] * offset_left, type=native_series.type)
+        pad_right = pa.array([None] * offset_right, type=native_series.type)
+        padded_arr = series._from_native_series(
+            pa.concat_arrays([pad_left, native_series.combine_chunks(), pad_right])
+        )
+        offset = offset_left + offset_right
+    else:
+        padded_arr = series
+        offset = 0
+
+    return padded_arr, offset
diff --git a/narwhals/_dask/expr.py b/narwhals/_dask/expr.py
index 748d332b..0e898579 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(),
@@ -920,6 +928,64 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             returns_scalar=False,
         )
 
+    def rolling_var(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None,
+        center: bool,
+        ddof: int,
+    ) -> Self:
+        def func(
+            _input: dask_expr.Series,
+            _window: int,
+            _min_periods: int | None,
+            _center: bool,  # noqa: FBT001
+            _ddof: int,
+        ) -> dask_expr.Series:
+            return _input.rolling(
+                window=_window, min_periods=_min_periods, center=_center
+            ).var(ddof=ddof)
+
+        return self._from_call(
+            func,
+            "rolling_var",
+            window_size,
+            min_periods,
+            center,
+            ddof,
+            returns_scalar=False,
+        )
+
+    def rolling_std(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None,
+        center: bool,
+        ddof: int,
+    ) -> Self:
+        def func(
+            _input: dask_expr.Series,
+            _window: int,
+            _min_periods: int | None,
+            _center: bool,  # noqa: FBT001
+            _ddof: int,
+        ) -> dask_expr.Series:
+            return _input.rolling(
+                window=_window, min_periods=_min_periods, center=_center
+            ).std(ddof=ddof)
+
+        return self._from_call(
+            func,
+            "rolling_std",
+            window_size,
+            min_periods,
+            center,
+            ddof,
+            returns_scalar=False,
+        )
+
 
 class DaskExprStringNamespace:
     def __init__(self, expr: DaskExpr) -> None:
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index a83d83c4..8d507fb4 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -9,6 +9,7 @@ from typing import Sequence
 from narwhals._expression_parsing import reuse_series_implementation
 from narwhals._expression_parsing import reuse_series_namespace_implementation
 from narwhals._pandas_like.series import PandasLikeSeries
+from narwhals._pandas_like.utils import rename
 from narwhals.dependencies import get_numpy
 from narwhals.dependencies import is_numpy_array
 from narwhals.exceptions import ColumnNotFoundError
@@ -23,6 +24,17 @@ if TYPE_CHECKING:
     from narwhals.utils import Implementation
     from narwhals.utils import Version
 
+CUMULATIVE_FUNCTIONS_TO_PANDAS_EQUIVALENT = {
+    "col->cum_sum": "cumsum",
+    "col->cum_min": "cummin",
+    "col->cum_max": "cummax",
+    "col->cum_prod": "cumprod",
+    # Pandas cumcount starts counting from 0 while Polars starts from 1
+    # Pandas cumcount counts nulls while Polars does not
+    # So, instead of using "cumcount" we use "cumsum" on notna() to get the same result
+    "col->cum_count": "cumsum",
+}
+
 
 class PandasLikeExpr(CompliantExpr[PandasLikeSeries]):
     def __init__(
@@ -246,9 +258,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)
 
@@ -397,19 +412,54 @@ class PandasLikeExpr(CompliantExpr[PandasLikeSeries]):
         )
 
     def over(self, keys: list[str]) -> Self:
-        def func(df: PandasLikeDataFrame) -> list[PandasLikeSeries]:
-            if self._output_names is None:
-                msg = (
-                    "Anonymous expressions are not supported in over.\n"
-                    "Instead of `nw.all()`, try using a named expression, such as "
-                    "`nw.col('a', 'b')`\n"
+        if self._function_name in CUMULATIVE_FUNCTIONS_TO_PANDAS_EQUIVALENT:
+
+            def func(df: PandasLikeDataFrame) -> list[PandasLikeSeries]:
+                if (
+                    self._output_names is None or self._root_names is None
+                ):  # pragma: no cover
+                    # Technically unreachable, but keep this for safety
+                    msg = (
+                        "Anonymous expressions are not supported in over.\n"
+                        "Instead of `nw.all()`, try using a named expression, such as "
+                        "`nw.col('a', 'b')`\n"
+                    )
+                    raise ValueError(msg)
+
+                if self._function_name == "col->cum_count":
+                    plx = self.__narwhals_namespace__()
+                    df = df.with_columns(~plx.col(*self._root_names).is_null())
+
+                res_native = df._native_frame.groupby(list(keys), as_index=False)[
+                    self._root_names
+                ].transform(
+                    CUMULATIVE_FUNCTIONS_TO_PANDAS_EQUIVALENT[self._function_name]
                 )
-                raise ValueError(msg)
-            tmp = df.group_by(*keys, drop_null_keys=False).agg(self)
-            tmp = df.select(*keys).join(
-                tmp, how="left", left_on=keys, right_on=keys, suffix="_right"
-            )
-            return [tmp[name] for name in self._output_names]
+                result_frame = df._from_native_frame(
+                    rename(
+                        res_native,
+                        columns=dict(zip(self._root_names, self._output_names)),
+                        implementation=self._implementation,
+                        backend_version=self._backend_version,
+                    )
+                )
+                return [result_frame[name] for name in self._output_names]
+
+        else:
+
+            def func(df: PandasLikeDataFrame) -> list[PandasLikeSeries]:
+                if self._output_names is None:
+                    msg = (
+                        "Anonymous expressions are not supported in over.\n"
+                        "Instead of `nw.all()`, try using a named expression, such as "
+                        "`nw.col('a', 'b')`\n"
+                    )
+                    raise ValueError(msg)
+                tmp = df.group_by(*keys, drop_null_keys=False).agg(self)
+                tmp = df.select(*keys).join(
+                    tmp, how="left", left_on=keys, right_on=keys, suffix="_right"
+                )
+                return [tmp[name] for name in self._output_names]
 
         return self.__class__(
             func,
@@ -539,6 +589,40 @@ class PandasLikeExpr(CompliantExpr[PandasLikeSeries]):
             center=center,
         )
 
+    def rolling_var(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None,
+        center: bool,
+        ddof: int,
+    ) -> Self:
+        return reuse_series_implementation(
+            self,
+            "rolling_var",
+            window_size=window_size,
+            min_periods=min_periods,
+            center=center,
+            ddof=ddof,
+        )
+
+    def rolling_std(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None,
+        center: bool,
+        ddof: int,
+    ) -> Self:
+        return reuse_series_implementation(
+            self,
+            "rolling_std",
+            window_size=window_size,
+            min_periods=min_periods,
+            center=center,
+            ddof=ddof,
+        )
+
     @property
     def str(self: Self) -> PandasLikeExprStringNamespace:
         return PandasLikeExprStringNamespace(self)
diff --git a/narwhals/_pandas_like/group_by.py b/narwhals/_pandas_like/group_by.py
index a1abe2eb..f9ea86dc 100644
--- a/narwhals/_pandas_like/group_by.py
+++ b/narwhals/_pandas_like/group_by.py
@@ -12,7 +12,6 @@ from typing import Sequence
 from narwhals._expression_parsing import is_simple_aggregation
 from narwhals._expression_parsing import parse_into_exprs
 from narwhals._pandas_like.utils import native_series_from_iterable
-from narwhals._pandas_like.utils import rename
 from narwhals._pandas_like.utils import select_columns_by_name
 from narwhals.utils import Implementation
 from narwhals.utils import find_stacklevel
@@ -167,9 +166,11 @@ def agg_pandas(  # noqa: PLR0915
     # We need to do this separately from the rest so that we
     # can pass the `dropna` kwargs.
     nunique_aggs: dict[str, str] = {}
+    simple_aggs: dict[str, list[str]] = collections.defaultdict(list)
+    expected_old_names: list[str] = []
+    new_names: list[str] = []
 
     if all_aggs_are_simple:
-        simple_aggregations: dict[str, tuple[str, str]] = {}
         for expr in exprs:
             if expr._depth == 0:
                 # e.g. agg(nw.len()) # noqa: ERA001
@@ -181,7 +182,9 @@ def agg_pandas(  # noqa: PLR0915
                     expr._function_name, expr._function_name
                 )
                 for output_name in expr._output_names:
-                    simple_aggregations[output_name] = (keys[0], function_name)
+                    new_names.append(output_name)
+                    expected_old_names.append(f"{keys[0]}_{function_name}")
+                    simple_aggs[keys[0]].append(function_name)
                 continue
 
             # e.g. agg(nw.mean('a')) # noqa: ERA001
@@ -200,27 +203,43 @@ def agg_pandas(  # noqa: PLR0915
                 if is_n_unique:
                     nunique_aggs[output_name] = root_name
                 else:
-                    simple_aggregations[output_name] = (root_name, function_name)
+                    new_names.append(output_name)
+                    expected_old_names.append(f"{root_name}_{function_name}")
+                    simple_aggs[root_name].append(function_name)
 
-        simple_aggs = collections.defaultdict(list)
-        name_mapping = {}
-        for output_name, named_agg in simple_aggregations.items():
-            simple_aggs[named_agg[0]].append(named_agg[1])
-            name_mapping[f"{named_agg[0]}_{named_agg[1]}"] = output_name
         if simple_aggs:
             result_simple_aggs = grouped.agg(simple_aggs)
             result_simple_aggs.columns = [
                 f"{a}_{b}" for a, b in result_simple_aggs.columns
             ]
-            result_simple_aggs = rename(
-                result_simple_aggs,
-                columns=name_mapping,
-                implementation=implementation,
-                backend_version=backend_version,
+            if not (
+                set(result_simple_aggs.columns) == set(expected_old_names)
+                and len(result_simple_aggs.columns) == len(expected_old_names)
+            ):  # pragma: no cover
+                msg = (
+                    f"Safety assertion failed, expected {expected_old_names} "
+                    f"got {result_simple_aggs.columns}, "
+                    "please report a bug at https://github.com/narwhals-dev/narwhals/issues"
+                )
+                raise AssertionError(msg)
+
+            # Rename columns, being very careful
+            expected_old_names_indices: dict[str, list[int]] = collections.defaultdict(
+                list
             )
+            for idx, item in enumerate(expected_old_names):
+                expected_old_names_indices[item].append(idx)
+            index_map: list[int] = [
+                expected_old_names_indices[item].pop(0)
+                for item in result_simple_aggs.columns
+            ]
+            new_names = [new_names[i] for i in index_map]
+            result_simple_aggs.columns = new_names
+
             # Keep inplace=True to avoid making a redundant copy.
             # This may need updating, depending on https://github.com/pandas-dev/pandas/pull/51466/files
             result_simple_aggs.reset_index(inplace=True)  # noqa: PD002
+
         if nunique_aggs:
             result_nunique_aggs = grouped[list(nunique_aggs.values())].nunique(
                 dropna=False
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index bd799039..a8b57c5c 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()
@@ -1055,6 +1059,32 @@ class PandasLikeSeries(CompliantSeries):
         ).mean()
         return self._from_native_series(result)
 
+    def rolling_var(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None,
+        center: bool,
+        ddof: int,
+    ) -> Self:
+        result = self._native_series.rolling(
+            window=window_size, min_periods=min_periods, center=center
+        ).var(ddof=ddof)
+        return self._from_native_series(result)
+
+    def rolling_std(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None,
+        center: bool,
+        ddof: int,
+    ) -> Self:
+        result = self._native_series.rolling(
+            window=window_size, min_periods=min_periods, center=center
+        ).std(ddof=ddof)
+        return self._from_native_series(result)
+
     def __iter__(self: Self) -> Iterator[Any]:
         yield from self._native_series.__iter__()
 
diff --git a/narwhals/_polars/expr.py b/narwhals/_polars/expr.py
index 4c6e59a4..6c50c99a 100644
--- a/narwhals/_polars/expr.py
+++ b/narwhals/_polars/expr.py
@@ -79,6 +79,48 @@ class PolarsExpr:
             )
         return self._from_native_expr(native_expr)
 
+    def rolling_var(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None,
+        center: bool,
+        ddof: int,
+    ) -> Self:
+        if self._backend_version < (1,):  # pragma: no cover
+            msg = "`rolling_var` not implemented for polars older than 1.0"
+            raise NotImplementedError(msg)
+
+        return self._from_native_expr(
+            self._native_expr.rolling_var(
+                window_size=window_size,
+                min_periods=min_periods,
+                center=center,
+                ddof=ddof,
+            )
+        )
+
+    def rolling_std(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None,
+        center: bool,
+        ddof: int,
+    ) -> Self:
+        if self._backend_version < (1,):  # pragma: no cover
+            msg = "`rolling_std` not implemented for polars older than 1.0"
+            raise NotImplementedError(msg)
+
+        return self._from_native_expr(
+            self._native_expr.rolling_std(
+                window_size=window_size,
+                min_periods=min_periods,
+                center=center,
+                ddof=ddof,
+            )
+        )
+
     def map_batches(
         self,
         function: Callable[..., Self],
diff --git a/narwhals/_polars/series.py b/narwhals/_polars/series.py
index 424c5bfb..d7088bb0 100644
--- a/narwhals/_polars/series.py
+++ b/narwhals/_polars/series.py
@@ -284,6 +284,48 @@ class PolarsSeries:
 
         return self._from_native_series(native_result)
 
+    def rolling_var(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None,
+        center: bool,
+        ddof: int,
+    ) -> Self:
+        if self._backend_version < (1,):  # pragma: no cover
+            msg = "`rolling_var` not implemented for polars older than 1.0"
+            raise NotImplementedError(msg)
+
+        return self._from_native_series(
+            self._native_series.rolling_var(
+                window_size=window_size,
+                min_periods=min_periods,
+                center=center,
+                ddof=ddof,
+            )
+        )
+
+    def rolling_std(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None,
+        center: bool,
+        ddof: int,
+    ) -> Self:
+        if self._backend_version < (1,):  # pragma: no cover
+            msg = "`rolling_std` not implemented for polars older than 1.0"
+            raise NotImplementedError(msg)
+
+        return self._from_native_series(
+            self._native_series.rolling_std(
+                window_size=window_size,
+                min_periods=min_periods,
+                center=center,
+                ddof=ddof,
+            )
+        )
+
     def sort(self: Self, *, descending: bool, nulls_last: bool) -> Self:
         if self._backend_version < (0, 20, 6):
             result = self._native_series.sort(descending=descending)
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/dataframe.py b/narwhals/dataframe.py
index 7ea45d59..634f676f 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -337,12 +337,28 @@ class BaseFrame(Generic[FrameT]):
 
 
 class DataFrame(BaseFrame[DataFrameT]):
-    """Narwhals DataFrame, backed by a native dataframe.
-
-    The native dataframe might be pandas.DataFrame, polars.DataFrame, ...
-
-    This class is not meant to be instantiated directly - instead, use
-    `narwhals.from_native`.
+    """Narwhals DataFrame, backed by a native eager dataframe.
+
+    !!! warning
+        This class is not meant to be instantiated directly - instead:
+
+        - If the native object is a eager dataframe from one of the supported
+            backend (e.g. pandas.DataFrame, polars.DataFrame, pyarrow.Table),
+            you can use [`narwhals.from_native`](../narwhals/#narwhals.from_native):
+            ```py
+            narwhals.from_native(native_dataframe)
+            narwhals.from_native(native_dataframe, eager_only=True)
+            ```
+
+        - If the object is a dictionary of column names and generic sequences mapping
+            (e.g. `dict[str, list]`), you can create a DataFrame via
+            [`narwhals.from_dict`](../narwhals/#narwhals.from_dict):
+            ```py
+            narwhals.from_dict(
+                data={"a": [1, 2, 3]},
+                native_namespace=narwhals.get_native_namespace(another_object),
+            )
+            ```
     """
 
     @property
@@ -1084,6 +1100,9 @@ class DataFrame(BaseFrame[DataFrameT]):
         Arguments:
             index: Row number.
 
+        Returns:
+            A tuple of the values in the selected row.
+
         Notes:
             cuDF doesn't support this method.
 
@@ -1117,6 +1136,14 @@ class DataFrame(BaseFrame[DataFrameT]):
     def pipe(self, function: Callable[[Any], Self], *args: Any, **kwargs: Any) -> Self:
         """Pipe function call.
 
+        Arguments:
+            function: Function to apply.
+            args: Positional arguments to pass to function.
+            kwargs: Keyword arguments to pass to function.
+
+        Returns:
+            The original object with the function applied.
+
         Examples:
             >>> import polars as pl
             >>> import pandas as pd
@@ -1159,12 +1186,15 @@ class DataFrame(BaseFrame[DataFrameT]):
         return super().pipe(function, *args, **kwargs)
 
     def drop_nulls(self: Self, subset: str | list[str] | None = None) -> Self:
-        """Drop null values.
+        """Drop rows that contain null values.
 
         Arguments:
             subset: Column name(s) for which null values are considered. If set to None
                 (default), use all columns.
 
+        Returns:
+            The original object with the rows removed that contained the null values.
+
         Notes:
             pandas and Polars handle null values differently. Polars distinguishes
             between NaN and Null, whereas pandas doesn't.
@@ -1205,6 +1235,12 @@ class DataFrame(BaseFrame[DataFrameT]):
     def with_row_index(self, name: str = "index") -> Self:
         """Insert column which enumerates rows.
 
+        Arguments:
+            name: The name of the column as a string. The default is "index".
+
+        Returns:
+            The original object with the column added.
+
         Examples:
             Construct pandas as polars DataFrames:
 
@@ -1248,6 +1284,9 @@ class DataFrame(BaseFrame[DataFrameT]):
     def schema(self) -> Schema:
         r"""Get an ordered mapping of column names to their data type.
 
+        Returns:
+            A Narwhals Schema object that displays the mapping of column names.
+
         Examples:
             >>> import polars as pl
             >>> import pandas as pd
@@ -1284,6 +1323,9 @@ class DataFrame(BaseFrame[DataFrameT]):
     def collect_schema(self: Self) -> Schema:
         r"""Get an ordered mapping of column names to their data type.
 
+        Returns:
+            A Narwhals Schema object that displays the mapping of column names.
+
         Examples:
             >>> import polars as pl
             >>> import pandas as pd
@@ -1321,6 +1363,9 @@ class DataFrame(BaseFrame[DataFrameT]):
     def columns(self) -> list[str]:
         """Get column names.
 
+        Returns:
+            The column names stored in a list.
+
         Examples:
             >>> import pandas as pd
             >>> import polars as pl
@@ -1381,6 +1426,9 @@ class DataFrame(BaseFrame[DataFrameT]):
                 in the same order as the frame columns. Setting named=True will
                 return rows of dictionaries instead.
 
+        Returns:
+            The data as a list of rows.
+
         Examples:
             >>> import pandas as pd
             >>> import polars as pl
@@ -1436,6 +1484,9 @@ class DataFrame(BaseFrame[DataFrameT]):
                 internally while iterating over the data.
                 See https://docs.pola.rs/api/python/stable/reference/dataframe/api/polars.DataFrame.iter_rows.html
 
+        Returns:
+            An iterator over the DataFrame of rows.
+
         Notes:
             cuDF doesn't support this method.
 
@@ -1545,6 +1596,9 @@ class DataFrame(BaseFrame[DataFrameT]):
             **named_exprs: Additional columns to select, specified as keyword arguments.
                             The columns will be renamed to the keyword used.
 
+        Returns:
+            The dataframe containing only the selected columns.
+
         Examples:
             >>> import pandas as pd
             >>> import polars as pl
@@ -1658,6 +1712,9 @@ class DataFrame(BaseFrame[DataFrameT]):
         Arguments:
             mapping: Key value pairs that map from old name to new name.
 
+        Returns:
+            The dataframe with the specified columns renamed.
+
         Examples:
             >>> import pandas as pd
             >>> import polars as pl
@@ -1700,6 +1757,9 @@ class DataFrame(BaseFrame[DataFrameT]):
             n: Number of rows to return. If a negative value is passed, return all rows
                 except the last `abs(n)`.
 
+        Returns:
+            A subset of the dataframe of shape (n, n_columns).
+
         Examples:
             >>> import pandas as pd
             >>> import polars as pl
@@ -1746,6 +1806,9 @@ class DataFrame(BaseFrame[DataFrameT]):
             n: Number of rows to return. If a negative value is passed, return all rows
                 except the first `abs(n)`.
 
+        Returns:
+            A subset of the dataframe of shape (n, n_columns).
+
         Examples:
             >>> import pandas as pd
             >>> import polars as pl
@@ -1788,6 +1851,9 @@ class DataFrame(BaseFrame[DataFrameT]):
     def drop(self, *columns: str | Iterable[str], strict: bool = True) -> Self:
         """Remove columns from the dataframe.
 
+        Returns:
+            The dataframe with the specified columns removed.
+
         Arguments:
             *columns: Names of the columns that should be removed from the dataframe.
             strict: Validate that all column names exist in the schema and throw an
@@ -1874,6 +1940,9 @@ class DataFrame(BaseFrame[DataFrameT]):
                 expensive to compute. Settings this to `True` blocks the possibility
                 to run on the streaming engine for Polars.
 
+        Returns:
+            The dataframe with the duplicate rows removed.
+
         Examples:
             >>> import pandas as pd
             >>> import polars as pl
@@ -1923,6 +1992,9 @@ class DataFrame(BaseFrame[DataFrameT]):
                 Each constraint will behave the same as `nw.col(name).eq(value)`, and will be implicitly
                 joined with the other filter conditions using &.
 
+        Returns:
+            The filtered dataframe.
+
         Examples:
             >>> import pandas as pd
             >>> import polars as pl
@@ -2137,6 +2209,9 @@ class DataFrame(BaseFrame[DataFrameT]):
                 specified per column by passing a sequence of booleans.
             nulls_last: Place null values last.
 
+        Returns:
+            The sorted dataframe.
+
         Warning:
             Unlike Polars, it is not possible to specify a sequence of booleans for
             `nulls_last` in order to control per-column behaviour. Instead a single
@@ -2502,6 +2577,9 @@ class DataFrame(BaseFrame[DataFrameT]):
     def is_empty(self: Self) -> bool:
         r"""Check if the dataframe is empty.
 
+        Returns:
+            A boolean indicating whether the dataframe is empty (True) or not (False).
+
         Examples:
             >>> import narwhals as nw
             >>> import pandas as pd
@@ -2584,6 +2662,9 @@ class DataFrame(BaseFrame[DataFrameT]):
     def null_count(self: Self) -> Self:
         r"""Create a new DataFrame that shows the null counts per column.
 
+        Returns:
+            A dataframe of shape (1, n_columns).
+
         Notes:
             pandas and Polars handle null values differently. Polars distinguishes
             between NaN and Null, whereas pandas doesn't.
@@ -2635,6 +2716,13 @@ class DataFrame(BaseFrame[DataFrameT]):
     def item(self: Self, row: int | None = None, column: int | str | None = None) -> Any:
         r"""Return the DataFrame as a scalar, or return the element at the given row/column.
 
+        Arguments:
+            row: The *n*-th row.
+            column: The column selected via an integer or a string (column name).
+
+        Returns:
+            A scalar or the specified element in the dataframe.
+
         Notes:
             If row/col not provided, this is equivalent to df[0,0], with a check that the shape is (1,1).
             With row/col, this is equivalent to df[row,col].
@@ -2666,6 +2754,9 @@ class DataFrame(BaseFrame[DataFrameT]):
     def clone(self) -> Self:
         r"""Create a copy of this DataFrame.
 
+        Returns:
+            An identical copy of the original dataframe.
+
         Examples:
             >>> import narwhals as nw
             >>> import pandas as pd
@@ -2705,6 +2796,9 @@ class DataFrame(BaseFrame[DataFrameT]):
             n: Gather every *n*-th row.
             offset: Starting index.
 
+        Returns:
+            The dataframe containing only the selected rows.
+
         Examples:
             >>> import narwhals as nw
             >>> import pandas as pd
@@ -2774,6 +2868,9 @@ class DataFrame(BaseFrame[DataFrameT]):
             separator: Used as separator/delimiter in generated column names in case of
                 multiple `values` columns.
 
+        Returns:
+            A new dataframe.
+
         Examples:
             >>> import narwhals as nw
             >>> import pandas as pd
@@ -2825,6 +2922,9 @@ class DataFrame(BaseFrame[DataFrameT]):
     def to_arrow(self: Self) -> pa.Table:
         r"""Convert to arrow table.
 
+        Returns:
+            A new PyArrow table.
+
         Examples:
             >>> import narwhals as nw
             >>> import pandas as pd
@@ -2874,6 +2974,9 @@ class DataFrame(BaseFrame[DataFrameT]):
             seed: Seed for the random number generator. If set to None (default), a random
                 seed is generated for each sample operation.
 
+        Returns:
+            A new dataframe.
+
         Notes:
             The results may not be consistent across libraries.
 
@@ -2940,6 +3043,9 @@ class DataFrame(BaseFrame[DataFrameT]):
             variable_name: Name to give to the `variable` column. Defaults to "variable".
             value_name: Name to give to the `value` column. Defaults to "value".
 
+        Returns:
+            The unpivoted dataframe.
+
         Notes:
             If you're coming from pandas, this is similar to `pandas.DataFrame.melt`,
             but with `index` replacing `id_vars` and `on` replacing `value_vars`.
@@ -3004,12 +3110,16 @@ class DataFrame(BaseFrame[DataFrameT]):
 
 
 class LazyFrame(BaseFrame[FrameT]):
-    """Narwhals DataFrame, backed by a native dataframe.
-
-    The native dataframe might be pandas.DataFrame, polars.LazyFrame, ...
-
-    This class is not meant to be instantiated directly - instead, use
-    `narwhals.from_native`.
+    """Narwhals LazyFrame, backed by a native lazyframe.
+
+    !!! warning
+        This class is not meant to be instantiated directly - instead use
+        [`narwhals.from_native`](../narwhals/#narwhals.from_native) with a native
+        object that is a lazy dataframe from one of the supported
+        backend (e.g. polars.LazyFrame, dask_expr._collection.DataFrame):
+        ```py
+        narwhals.from_native(native_lazyframe)
+        ```
     """
 
     @property
diff --git a/narwhals/expr.py b/narwhals/expr.py
index 674bb19a..24ddd3f4 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],
@@ -1733,6 +1783,7 @@ class Expr:
             b: [[true,true,false,false]]
         """
         if isinstance(other, Iterable) and not isinstance(other, (str, bytes)):
+            other = extract_compliant(self, other)
             return self.__class__(lambda plx: self._to_compliant_expr(plx).is_in(other))
         else:
             msg = "Narwhals `is_in` doesn't accept expressions as an argument, as opposed to Polars. You should provide an iterable instead."
@@ -2212,7 +2263,7 @@ class Expr:
 
             Let's define a dataframe-agnostic function:
 
-            >>> def my_library_agnostic_function(df_native: IntoFrameT) -> IntoFrameT:
+            >>> def agnostic_min_over_b(df_native: IntoFrameT) -> IntoFrameT:
             ...     df = nw.from_native(df_native)
             ...     return df.with_columns(
             ...         a_min_per_group=nw.col("a").min().over("b")
@@ -2220,12 +2271,12 @@ class Expr:
 
             We can then pass any supported library such as Pandas, Polars, or PyArrow to `func`:
 
-            >>> my_library_agnostic_function(df_pd)
+            >>> agnostic_min_over_b(df_pd)
                a  b  a_min_per_group
             0  1  1                1
             1  2  1                1
             2  3  2                3
-            >>> my_library_agnostic_function(df_pl)
+            >>> agnostic_min_over_b(df_pl)
             shape: (3, 3)
             ┌─────┬─────┬─────────────────┐
             │ a   ┆ b   ┆ a_min_per_group │
@@ -2236,7 +2287,7 @@ class Expr:
             │ 2   ┆ 1   ┆ 1               │
             │ 3   ┆ 2   ┆ 3               │
             └─────┴─────┴─────────────────┘
-            >>> my_library_agnostic_function(df_pa)
+            >>> agnostic_min_over_b(df_pa)
             pyarrow.Table
             a: int64
             b: int64
@@ -2245,6 +2296,30 @@ class Expr:
             a: [[1,2,3]]
             b: [[1,1,2]]
             a_min_per_group: [[1,1,3]]
+
+            Cumulative operations are also supported, but (currently) only for
+            pandas and Polars:
+
+            >>> def agnostic_cum_sum(df_native: IntoFrameT) -> IntoFrameT:
+            ...     df = nw.from_native(df_native)
+            ...     return df.with_columns(c=nw.col("a").cum_sum().over("b")).to_native()
+
+            >>> agnostic_cum_sum(df_pd)
+               a  b  c
+            0  1  1  1
+            1  2  1  3
+            2  3  2  3
+            >>> agnostic_cum_sum(df_pl)
+            shape: (3, 3)
+            ┌─────┬─────┬─────┐
+            │ a   ┆ b   ┆ c   │
+            │ --- ┆ --- ┆ --- │
+            │ i64 ┆ i64 ┆ i64 │
+            ╞═════╪═════╪═════╡
+            │ 1   ┆ 1   ┆ 1   │
+            │ 2   ┆ 1   ┆ 3   │
+            │ 3   ┆ 2   ┆ 3   │
+            └─────┴─────┴─────┘
         """
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).over(flatten(keys))
@@ -3527,6 +3602,193 @@ class Expr:
             )
         )
 
+    def rolling_var(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None = None,
+        center: bool = False,
+        ddof: int = 1,
+    ) -> Self:
+        """Apply a rolling variance (moving variance) over the values.
+
+        !!! warning
+            This functionality is considered **unstable**. It may be changed at any point
+            without it being considered a breaking change.
+
+        A window of length `window_size` will traverse the values. The resulting values
+        will be aggregated to their variance.
+
+        The window at a given row will include the row itself and the `window_size - 1`
+        elements before it.
+
+        Arguments:
+            window_size: The length of the window in number of elements. It must be a
+                strictly positive integer.
+            min_periods: The number of values in the window that should be non-null before
+                computing a result. If set to `None` (default), it will be set equal to
+                `window_size`. If provided, it must be a strictly positive integer, and
+                less than or equal to `window_size`.
+            center: Set the labels at the center of the window.
+            ddof: Delta Degrees of Freedom; the divisor for a length N window is N - ddof.
+
+        Returns:
+            A new expression.
+
+        Examples:
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoFrameT
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> data = {"a": [1.0, 2.0, None, 4.0]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
+
+            We define a library agnostic function:
+
+            >>> def agnostic_rolling_var(df_native: IntoFrameT) -> IntoFrameT:
+            ...     df = nw.from_native(df_native)
+            ...     return df.with_columns(
+            ...         b=nw.col("a").rolling_var(window_size=3, min_periods=1)
+            ...     ).to_native()
+
+            We can then pass any supported library such as Pandas, Polars, or PyArrow to `func`:
+
+            >>> agnostic_rolling_var(df_pd)
+                 a    b
+            0  1.0  NaN
+            1  2.0  0.5
+            2  NaN  0.5
+            3  4.0  2.0
+
+            >>> agnostic_rolling_var(df_pl)  #  doctest:+SKIP
+            shape: (4, 2)
+            ┌──────┬──────┐
+            │ a    ┆ b    │
+            │ ---  ┆ ---  │
+            │ f64  ┆ f64  │
+            ╞══════╪══════╡
+            │ 1.0  ┆ null │
+            │ 2.0  ┆ 0.5  │
+            │ null ┆ 0.5  │
+            │ 4.0  ┆ 2.0  │
+            └──────┴──────┘
+
+            >>> agnostic_rolling_var(df_pa)  #  doctest:+ELLIPSIS
+            pyarrow.Table
+            a: double
+            b: double
+            ----
+            a: [[1,2,null,4]]
+            b: [[nan,0.5,0.5,2]]
+        """
+        window_size, min_periods = _validate_rolling_arguments(
+            window_size=window_size, min_periods=min_periods
+        )
+
+        return self.__class__(
+            lambda plx: self._to_compliant_expr(plx).rolling_var(
+                window_size=window_size, min_periods=min_periods, center=center, ddof=ddof
+            )
+        )
+
+    def rolling_std(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None = None,
+        center: bool = False,
+        ddof: int = 1,
+    ) -> Self:
+        """Apply a rolling standard deviation (moving standard deviation) over the values.
+
+        !!! warning
+            This functionality is considered **unstable**. It may be changed at any point
+            without it being considered a breaking change.
+
+        A window of length `window_size` will traverse the values. The resulting values
+        will be aggregated to their standard deviation.
+
+        The window at a given row will include the row itself and the `window_size - 1`
+        elements before it.
+
+        Arguments:
+            window_size: The length of the window in number of elements. It must be a
+                strictly positive integer.
+            min_periods: The number of values in the window that should be non-null before
+                computing a result. If set to `None` (default), it will be set equal to
+                `window_size`. If provided, it must be a strictly positive integer, and
+                less than or equal to `window_size`.
+            center: Set the labels at the center of the window.
+            ddof: Delta Degrees of Freedom; the divisor for a length N window is N - ddof.
+
+        Returns:
+            A new expression.
+
+        Examples:
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoFrameT
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> data = {"a": [1.0, 2.0, None, 4.0]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
+
+            We define a library agnostic function:
+
+            >>> def agnostic_rolling_std(df_native: IntoFrameT) -> IntoFrameT:
+            ...     df = nw.from_native(df_native)
+            ...     return df.with_columns(
+            ...         b=nw.col("a").rolling_std(window_size=3, min_periods=1)
+            ...     ).to_native()
+
+            We can then pass any supported library such as Pandas, Polars, or PyArrow to `func`:
+
+            >>> agnostic_rolling_std(df_pd)
+                 a         b
+            0  1.0       NaN
+            1  2.0  0.707107
+            2  NaN  0.707107
+            3  4.0  1.414214
+
+            >>> agnostic_rolling_std(df_pl)  #  doctest:+SKIP
+            shape: (4, 2)
+            ┌──────┬──────────┐
+            │ a    ┆ b        │
+            │ ---  ┆ ---      │
+            │ f64  ┆ f64      │
+            ╞══════╪══════════╡
+            │ 1.0  ┆ null     │
+            │ 2.0  ┆ 0.707107 │
+            │ null ┆ 0.707107 │
+            │ 4.0  ┆ 1.414214 │
+            └──────┴──────────┘
+
+            >>> agnostic_rolling_std(df_pa)  #  doctest:+ELLIPSIS
+            pyarrow.Table
+            a: double
+            b: double
+            ----
+            a: [[1,2,null,4]]
+            b: [[nan,0.7071067811865476,0.7071067811865476,1.4142135623730951]]
+        """
+        window_size, min_periods = _validate_rolling_arguments(
+            window_size=window_size, min_periods=min_periods
+        )
+
+        return self.__class__(
+            lambda plx: self._to_compliant_expr(plx).rolling_std(
+                window_size=window_size,
+                min_periods=min_periods,
+                center=center,
+                ddof=ddof,
+            )
+        )
+
     @property
     def str(self: Self) -> ExprStringNamespace[Self]:
         return ExprStringNamespace(self)
@@ -5825,7 +6087,8 @@ def nth(*indices: int | Sequence[int]) -> Expr:
     """Creates an expression that references one or more columns by their index(es).
 
     Notes:
-        `nth` is not supported for Polars version<1.0.0. Please use [`col`](/api-reference/narwhals/#narwhals.col) instead.
+        `nth` is not supported for Polars version<1.0.0. Please use
+        [`col`](../narwhals/#narwhals.col) instead.
 
     Arguments:
         indices: One or more indices representing the columns to retrieve.
diff --git a/narwhals/functions.py b/narwhals/functions.py
index bf2cbed3..c9363e38 100644
--- a/narwhals/functions.py
+++ b/narwhals/functions.py
@@ -218,36 +218,49 @@ def new_series(
     Examples:
         >>> import pandas as pd
         >>> import polars as pl
+        >>> import pyarrow as pa
         >>> import narwhals as nw
+        >>> from narwhals.typing import IntoFrameT, IntoSeriesT
         >>> data = {"a": [1, 2, 3], "b": [4, 5, 6]}
 
         Let's define a dataframe-agnostic function:
 
-        >>> @nw.narwhalify
-        ... def func(df):
-        ...     values = [4, 1, 2]
-        ...     native_namespace = nw.get_native_namespace(df)
+        >>> def agnostic_new_series(df_native: IntoFrameT) -> IntoSeriesT:
+        ...     values = [4, 1, 2, 3]
+        ...     native_namespace = nw.get_native_namespace(df_native)
         ...     return nw.new_series(
-        ...         name="c",
+        ...         name="a",
         ...         values=values,
         ...         dtype=nw.Int32,
         ...         native_namespace=native_namespace,
-        ...     )
+        ...     ).to_native()
 
-        Let's see what happens when passing pandas / Polars input:
+        We can then pass any supported eager library, such as pandas / Polars / PyArrow:
 
-        >>> func(pd.DataFrame(data))
+        >>> agnostic_new_series(pd.DataFrame(data))
         0    4
         1    1
         2    2
-        Name: c, dtype: int32
-        >>> func(pl.DataFrame(data))  # doctest: +NORMALIZE_WHITESPACE
-        shape: (3,)
-        Series: 'c' [i32]
+        3    3
+        Name: a, dtype: int32
+        >>> agnostic_new_series(pl.DataFrame(data))  # doctest: +NORMALIZE_WHITESPACE
+        shape: (4,)
+        Series: 'a' [i32]
         [
            4
            1
            2
+           3
+        ]
+        >>> agnostic_new_series(pa.table(data))
+        <pyarrow.lib.ChunkedArray object at ...>
+        [
+          [
+            4,
+            1,
+            2,
+            3
+          ]
         ]
     """
     return _new_series_impl(
@@ -348,23 +361,23 @@ def from_dict(
         >>> import polars as pl
         >>> import pyarrow as pa
         >>> import narwhals as nw
+        >>> from narwhals.typing import IntoFrameT
         >>> data = {"a": [1, 2, 3], "b": [4, 5, 6]}
 
         Let's create a new dataframe of the same class as the dataframe we started with, from a dict of new data:
 
-        >>> @nw.narwhalify
-        ... def func(df):
+        >>> def agnostic_from_dict(df_native: IntoFrameT) -> IntoFrameT:
         ...     new_data = {"c": [5, 2], "d": [1, 4]}
-        ...     native_namespace = nw.get_native_namespace(df)
-        ...     return nw.from_dict(new_data, native_namespace=native_namespace)
+        ...     native_namespace = nw.get_native_namespace(df_native)
+        ...     return nw.from_dict(new_data, native_namespace=native_namespace).to_native()
 
         Let's see what happens when passing pandas, Polars or PyArrow input:
 
-        >>> func(pd.DataFrame(data))
+        >>> agnostic_from_dict(pd.DataFrame(data))
            c  d
         0  5  1
         1  2  4
-        >>> func(pl.DataFrame(data))
+        >>> agnostic_from_dict(pl.DataFrame(data))
         shape: (2, 2)
         ┌─────┬─────┐
         │ c   ┆ d   │
@@ -374,7 +387,7 @@ def from_dict(
         │ 5   ┆ 1   │
         │ 2   ┆ 4   │
         └─────┴─────┘
-        >>> func(pa.table(data))
+        >>> agnostic_from_dict(pa.table(data))
         pyarrow.Table
         c: int64
         d: int64
@@ -761,25 +774,26 @@ def from_arrow(
         >>> import polars as pl
         >>> import pyarrow as pa
         >>> import narwhals as nw
+        >>> from narwhals.typing import IntoFrameT
         >>> data = {"a": [1, 2, 3], "b": [4, 5, 6]}
 
         Let's define a dataframe-agnostic function which creates a PyArrow
         Table.
 
-        >>> @nw.narwhalify
-        ... def func(df):
-        ...     return nw.from_arrow(df, native_namespace=pa)
+        >>> def agnostic_to_arrow(df_native: IntoFrameT) -> IntoFrameT:
+        ...     df = nw.from_native(df_native)
+        ...     return nw.from_arrow(df, native_namespace=pa).to_native()
 
         Let's see what happens when passing pandas / Polars input:
 
-        >>> func(pd.DataFrame(data))  # doctest: +SKIP
+        >>> agnostic_to_arrow(pd.DataFrame(data))
         pyarrow.Table
         a: int64
         b: int64
         ----
         a: [[1,2,3]]
         b: [[4,5,6]]
-        >>> func(pl.DataFrame(data))  # doctest: +SKIP
+        >>> agnostic_to_arrow(pl.DataFrame(data))
         pyarrow.Table
         a: int64
         b: int64
diff --git a/narwhals/series.py b/narwhals/series.py
index 2ef2efc7..98baab29 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -33,11 +33,26 @@ if TYPE_CHECKING:
 class Series(Generic[IntoSeriesT]):
     """Narwhals Series, backed by a native series.
 
-    The native series might be pandas.Series, polars.Series, ...
+    !!! warning
+        This class is not meant to be instantiated directly - instead:
+
+        - If the native object is a series from one of the supported backend (e.g.
+            pandas.Series, polars.Series, pyarrow.ChunkedArray), you can use
+            [`narwhals.from_native`](../narwhals/#narwhals.from_native):
+            ```py
+            narwhals.from_native(native_series, allow_series=True)
+            narwhals.from_native(native_series, series_only=True)
+            ```
 
-    This class is not meant to be instantiated directly - instead, use
-    `narwhals.from_native`, making sure to pass `allow_series=True` or
-    `series_only=True`.
+        - If the object is a generic sequence (e.g. a list or a tuple of values), you can
+            create a series via [`narwhals.new_series`](../narwhals/#narwhals.new_series):
+            ```py
+            narwhals.new_series(
+                name=name,
+                values=values,
+                native_namespace=narwhals.get_native_namespace(another_object),
+            )
+            ```
     """
 
     @property
@@ -1029,7 +1044,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 +1071,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:
@@ -3327,7 +3373,7 @@ class Series(Generic[IntoSeriesT]):
             center: Set the labels at the center of the window.
 
         Returns:
-            A new expression.
+            A new series.
 
         Examples:
             >>> import narwhals as nw
@@ -3420,7 +3466,7 @@ class Series(Generic[IntoSeriesT]):
             center: Set the labels at the center of the window.
 
         Returns:
-            A new expression.
+            A new series.
 
         Examples:
             >>> import narwhals as nw
@@ -3484,6 +3530,192 @@ class Series(Generic[IntoSeriesT]):
             )
         )
 
+    def rolling_var(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None = None,
+        center: bool = False,
+        ddof: int = 1,
+    ) -> Self:
+        """Apply a rolling variance (moving variance) over the values.
+
+        !!! warning
+            This functionality is considered **unstable**. It may be changed at any point
+            without it being considered a breaking change.
+
+        A window of length `window_size` will traverse the values. The resulting values
+        will be aggregated to their variance.
+
+        The window at a given row will include the row itself and the `window_size - 1`
+        elements before it.
+
+        Arguments:
+            window_size: The length of the window in number of elements. It must be a
+                strictly positive integer.
+            min_periods: The number of values in the window that should be non-null before
+                computing a result. If set to `None` (default), it will be set equal to
+                `window_size`. If provided, it must be a strictly positive integer, and
+                less than or equal to `window_size`.
+            center: Set the labels at the center of the window.
+            ddof: Delta Degrees of Freedom; the divisor for a length N window is N - ddof.
+
+        Returns:
+            A new series.
+
+        Examples:
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoSeriesT
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> data = [1.0, 3.0, 1.0, 4.0]
+            >>> s_pd = pd.Series(data)
+            >>> s_pl = pl.Series(data)
+            >>> s_pa = pa.chunked_array([data])
+
+            We define a library agnostic function:
+
+            >>> def agnostic_rolling_var(s_native: IntoSeriesT) -> IntoSeriesT:
+            ...     s = nw.from_native(s_native, series_only=True)
+            ...     return s.rolling_var(window_size=2, min_periods=1).to_native()
+
+            We can then pass any supported library such as Pandas, Polars, or PyArrow to `func`:
+
+            >>> agnostic_rolling_var(s_pd)
+            0    NaN
+            1    2.0
+            2    2.0
+            3    4.5
+            dtype: float64
+
+            >>> agnostic_rolling_var(s_pl)  # doctest:+NORMALIZE_WHITESPACE
+            shape: (4,)
+            Series: '' [f64]
+            [
+               null
+               2.0
+               2.0
+               4.5
+            ]
+
+            >>> agnostic_rolling_var(s_pa)  # doctest:+ELLIPSIS
+            <pyarrow.lib.ChunkedArray object at ...>
+            [
+              [
+                nan,
+                2,
+                2,
+                4.5
+              ]
+            ]
+        """
+        window_size, min_periods = _validate_rolling_arguments(
+            window_size=window_size, min_periods=min_periods
+        )
+
+        if len(self) == 0:  # pragma: no cover
+            return self
+
+        return self._from_compliant_series(
+            self._compliant_series.rolling_var(
+                window_size=window_size, min_periods=min_periods, center=center, ddof=ddof
+            )
+        )
+
+    def rolling_std(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None = None,
+        center: bool = False,
+        ddof: int = 1,
+    ) -> Self:
+        """Apply a rolling standard deviation (moving standard deviation) over the values.
+
+        !!! warning
+            This functionality is considered **unstable**. It may be changed at any point
+            without it being considered a breaking change.
+
+        A window of length `window_size` will traverse the values. The resulting values
+        will be aggregated to their standard deviation.
+
+        The window at a given row will include the row itself and the `window_size - 1`
+        elements before it.
+
+        Arguments:
+            window_size: The length of the window in number of elements. It must be a
+                strictly positive integer.
+            min_periods: The number of values in the window that should be non-null before
+                computing a result. If set to `None` (default), it will be set equal to
+                `window_size`. If provided, it must be a strictly positive integer, and
+                less than or equal to `window_size`.
+            center: Set the labels at the center of the window.
+            ddof: Delta Degrees of Freedom; the divisor for a length N window is N - ddof.
+
+        Returns:
+            A new series.
+
+        Examples:
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoSeriesT
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> data = [1.0, 3.0, 1.0, 4.0]
+            >>> s_pd = pd.Series(data)
+            >>> s_pl = pl.Series(data)
+            >>> s_pa = pa.chunked_array([data])
+
+            We define a library agnostic function:
+
+            >>> def agnostic_rolling_std(s_native: IntoSeriesT) -> IntoSeriesT:
+            ...     s = nw.from_native(s_native, series_only=True)
+            ...     return s.rolling_std(window_size=2, min_periods=1).to_native()
+
+            We can then pass any supported library such as Pandas, Polars, or PyArrow to `func`:
+
+            >>> agnostic_rolling_std(s_pd)
+            0         NaN
+            1    1.414214
+            2    1.414214
+            3    2.121320
+            dtype: float64
+
+            >>> agnostic_rolling_std(s_pl)  # doctest:+NORMALIZE_WHITESPACE
+            shape: (4,)
+            Series: '' [f64]
+            [
+               null
+               1.414214
+               1.414214
+               2.12132
+            ]
+
+            >>> agnostic_rolling_std(s_pa)  # doctest:+ELLIPSIS
+            <pyarrow.lib.ChunkedArray object at ...>
+            [
+              [
+                nan,
+                1.4142135623730951,
+                1.4142135623730951,
+                2.1213203435596424
+              ]
+            ]
+        """
+        window_size, min_periods = _validate_rolling_arguments(
+            window_size=window_size, min_periods=min_periods
+        )
+
+        if len(self) == 0:  # pragma: no cover
+            return self
+
+        return self._from_compliant_series(
+            self._compliant_series.rolling_std(
+                window_size=window_size, min_periods=min_periods, center=center, ddof=ddof
+            )
+        )
+
     def __iter__(self: Self) -> Iterator[Any]:
         yield from self._compliant_series.__iter__()
 
diff --git a/narwhals/stable/v1/__init__.py b/narwhals/stable/v1/__init__.py
index 3d482d35..eaf821ab 100644
--- a/narwhals/stable/v1/__init__.py
+++ b/narwhals/stable/v1/__init__.py
@@ -92,12 +92,28 @@ T = TypeVar("T")
 
 
 class DataFrame(NwDataFrame[IntoDataFrameT]):
-    """Narwhals DataFrame, backed by a native dataframe.
-
-    The native dataframe might be pandas.DataFrame, polars.DataFrame, ...
-
-    This class is not meant to be instantiated directly - instead, use
-    `narwhals.from_native`.
+    """Narwhals DataFrame, backed by a native eager dataframe.
+
+    !!! warning
+        This class is not meant to be instantiated directly - instead:
+
+        - If the native object is a eager dataframe from one of the supported
+            backend (e.g. pandas.DataFrame, polars.DataFrame, pyarrow.Table),
+            you can use [`narwhals.from_native`](../narwhals/#narwhals.from_native):
+            ```py
+            narwhals.from_native(native_dataframe)
+            narwhals.from_native(native_dataframe, eager_only=True)
+            ```
+
+        - If the object is a dictionary of column names and generic sequences mapping
+            (e.g. `dict[str, list]`), you can create a DataFrame via
+            [`narwhals.from_dict`](../narwhals/#narwhals.from_dict):
+            ```py
+            narwhals.from_dict(
+                data={"a": [1, 2, 3]},
+                native_namespace=narwhals.get_native_namespace(another_object),
+            )
+            ```
     """
 
     # We need to override any method which don't return Self so that type
@@ -364,12 +380,16 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
 
 
 class LazyFrame(NwLazyFrame[IntoFrameT]):
-    """Narwhals DataFrame, backed by a native dataframe.
-
-    The native dataframe might be pandas.DataFrame, polars.LazyFrame, ...
-
-    This class is not meant to be instantiated directly - instead, use
-    `narwhals.from_native`.
+    """Narwhals LazyFrame, backed by a native lazyframe.
+
+    !!! warning
+        This class is not meant to be instantiated directly - instead use
+        [`narwhals.from_native`](../narwhals/#narwhals.from_native) with a native
+        object that is a lazy dataframe from one of the supported
+        backend (e.g. polars.LazyFrame, dask_expr._collection.DataFrame):
+        ```py
+        narwhals.from_native(native_lazyframe)
+        ```
     """
 
     @property
@@ -425,11 +445,26 @@ class LazyFrame(NwLazyFrame[IntoFrameT]):
 class Series(NwSeries[Any]):
     """Narwhals Series, backed by a native series.
 
-    The native series might be pandas.Series, polars.Series, ...
-
-    This class is not meant to be instantiated directly - instead, use
-    `narwhals.from_native`, making sure to pass `allow_series=True` or
-    `series_only=True`.
+    !!! warning
+        This class is not meant to be instantiated directly - instead:
+
+        - If the native object is a series from one of the supported backend (e.g.
+            pandas.Series, polars.Series, pyarrow.ChunkedArray), you can use
+            [`narwhals.from_native`](../narwhals/#narwhals.from_native):
+            ```py
+            narwhals.from_native(native_series, allow_series=True)
+            narwhals.from_native(native_series, series_only=True)
+            ```
+
+        - If the object is a generic sequence (e.g. a list or a tuple of values), you can
+            create a series via [`narwhals.new_series`](../narwhals/#narwhals.new_series):
+            ```py
+            narwhals.new_series(
+                name=name,
+                values=values,
+                native_namespace=narwhals.get_native_namespace(another_object),
+            )
+            ```
     """
 
     # We need to override any method which don't return Self so that type
@@ -671,7 +706,7 @@ class Series(NwSeries[Any]):
             center: Set the labels at the center of the window.
 
         Returns:
-            A new expression.
+            A new series.
 
         Examples:
             >>> import narwhals as nw
@@ -763,7 +798,7 @@ class Series(NwSeries[Any]):
             center: Set the labels at the center of the window.
 
         Returns:
-            A new expression.
+            A new series.
 
         Examples:
             >>> import narwhals as nw
@@ -826,6 +861,196 @@ class Series(NwSeries[Any]):
             center=center,
         )
 
+    def rolling_var(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None = None,
+        center: bool = False,
+        ddof: int = 1,
+    ) -> Self:
+        """Apply a rolling variance (moving variance) over the values.
+
+        !!! warning
+            This functionality is considered **unstable**. It may be changed at any point
+            without it being considered a breaking change.
+
+        A window of length `window_size` will traverse the values. The resulting values
+        will be aggregated to their variance.
+
+        The window at a given row will include the row itself and the `window_size - 1`
+        elements before it.
+
+        Arguments:
+            window_size: The length of the window in number of elements. It must be a
+                strictly positive integer.
+            min_periods: The number of values in the window that should be non-null before
+                computing a result. If set to `None` (default), it will be set equal to
+                `window_size`. If provided, it must be a strictly positive integer, and
+                less than or equal to `window_size`.
+            center: Set the labels at the center of the window.
+            ddof: Delta Degrees of Freedom; the divisor for a length N window is N - ddof.
+
+        Returns:
+            A new series.
+
+        Examples:
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoSeriesT
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> data = [1.0, 3.0, 1.0, 4.0]
+            >>> s_pd = pd.Series(data)
+            >>> s_pl = pl.Series(data)
+            >>> s_pa = pa.chunked_array([data])
+
+            We define a library agnostic function:
+
+            >>> def agnostic_rolling_var(s_native: IntoSeriesT) -> IntoSeriesT:
+            ...     s = nw.from_native(s_native, series_only=True)
+            ...     return s.rolling_var(window_size=2, min_periods=1).to_native()
+
+            We can then pass any supported library such as Pandas, Polars, or PyArrow to `func`:
+
+            >>> agnostic_rolling_var(s_pd)
+            0    NaN
+            1    2.0
+            2    2.0
+            3    4.5
+            dtype: float64
+
+            >>> agnostic_rolling_var(s_pl)  # doctest:+NORMALIZE_WHITESPACE
+            shape: (4,)
+            Series: '' [f64]
+            [
+               null
+               2.0
+               2.0
+               4.5
+            ]
+
+            >>> agnostic_rolling_var(s_pa)  # doctest:+ELLIPSIS
+            <pyarrow.lib.ChunkedArray object at ...>
+            [
+              [
+                nan,
+                2,
+                2,
+                4.5
+              ]
+            ]
+        """
+        from narwhals.exceptions import NarwhalsUnstableWarning
+        from narwhals.utils import find_stacklevel
+
+        msg = (
+            "`Series.rolling_var` is being called from the stable API although considered "
+            "an unstable feature."
+        )
+        warn(message=msg, category=NarwhalsUnstableWarning, stacklevel=find_stacklevel())
+        return super().rolling_var(
+            window_size=window_size,
+            min_periods=min_periods,
+            center=center,
+            ddof=ddof,
+        )
+
+    def rolling_std(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None = None,
+        center: bool = False,
+        ddof: int = 1,
+    ) -> Self:
+        """Apply a rolling standard deviation (moving standard deviation) over the values.
+
+        !!! warning
+            This functionality is considered **unstable**. It may be changed at any point
+            without it being considered a breaking change.
+
+        A window of length `window_size` will traverse the values. The resulting values
+        will be aggregated to their standard deviation.
+
+        The window at a given row will include the row itself and the `window_size - 1`
+        elements before it.
+
+        Arguments:
+            window_size: The length of the window in number of elements. It must be a
+                strictly positive integer.
+            min_periods: The number of values in the window that should be non-null before
+                computing a result. If set to `None` (default), it will be set equal to
+                `window_size`. If provided, it must be a strictly positive integer, and
+                less than or equal to `window_size`.
+            center: Set the labels at the center of the window.
+            ddof: Delta Degrees of Freedom; the divisor for a length N window is N - ddof.
+
+        Returns:
+            A new series.
+
+        Examples:
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoSeriesT
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> data = [1.0, 3.0, 1.0, 4.0]
+            >>> s_pd = pd.Series(data)
+            >>> s_pl = pl.Series(data)
+            >>> s_pa = pa.chunked_array([data])
+
+            We define a library agnostic function:
+
+            >>> def agnostic_rolling_std(s_native: IntoSeriesT) -> IntoSeriesT:
+            ...     s = nw.from_native(s_native, series_only=True)
+            ...     return s.rolling_std(window_size=2, min_periods=1).to_native()
+
+            We can then pass any supported library such as Pandas, Polars, or PyArrow to `func`:
+
+            >>> agnostic_rolling_std(s_pd)
+            0         NaN
+            1    1.414214
+            2    1.414214
+            3    2.121320
+            dtype: float64
+
+            >>> agnostic_rolling_std(s_pl)  # doctest:+NORMALIZE_WHITESPACE
+            shape: (4,)
+            Series: '' [f64]
+            [
+               null
+               1.414214
+               1.414214
+               2.12132
+            ]
+
+            >>> agnostic_rolling_std(s_pa)  # doctest:+ELLIPSIS
+            <pyarrow.lib.ChunkedArray object at ...>
+            [
+              [
+                nan,
+                1.4142135623730951,
+                1.4142135623730951,
+                2.1213203435596424
+              ]
+            ]
+        """
+        from narwhals.exceptions import NarwhalsUnstableWarning
+        from narwhals.utils import find_stacklevel
+
+        msg = (
+            "`Series.rolling_std` is being called from the stable API although considered "
+            "an unstable feature."
+        )
+        warn(message=msg, category=NarwhalsUnstableWarning, stacklevel=find_stacklevel())
+        return super().rolling_std(
+            window_size=window_size,
+            min_periods=min_periods,
+            center=center,
+            ddof=ddof,
+        )
+
 
 class Expr(NwExpr):
     def _l1_norm(self) -> Self:
@@ -1124,6 +1349,197 @@ class Expr(NwExpr):
             center=center,
         )
 
+    def rolling_var(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None = None,
+        center: bool = False,
+        ddof: int = 1,
+    ) -> Self:
+        """Apply a rolling variance (moving variance) over the values.
+
+        !!! warning
+            This functionality is considered **unstable**. It may be changed at any point
+            without it being considered a breaking change.
+
+        A window of length `window_size` will traverse the values. The resulting values
+        will be aggregated to their variance.
+
+        The window at a given row will include the row itself and the `window_size - 1`
+        elements before it.
+
+        Arguments:
+            window_size: The length of the window in number of elements. It must be a
+                strictly positive integer.
+            min_periods: The number of values in the window that should be non-null before
+                computing a result. If set to `None` (default), it will be set equal to
+                `window_size`. If provided, it must be a strictly positive integer, and
+                less than or equal to `window_size`.
+            center: Set the labels at the center of the window.
+            ddof: Delta Degrees of Freedom; the divisor for a length N window is N - ddof.
+
+        Returns:
+            A new expression.
+
+        Examples:
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoFrameT
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> data = {"a": [1.0, 2.0, None, 4.0]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
+
+            We define a library agnostic function:
+
+            >>> def agnostic_rolling_var(df_native: IntoFrameT) -> IntoFrameT:
+            ...     df = nw.from_native(df_native)
+            ...     return df.with_columns(
+            ...         b=nw.col("a").rolling_var(window_size=3, min_periods=1)
+            ...     ).to_native()
+
+            We can then pass any supported library such as Pandas, Polars, or PyArrow to `func`:
+
+            >>> agnostic_rolling_var(df_pd)
+                 a    b
+            0  1.0  NaN
+            1  2.0  0.5
+            2  NaN  0.5
+            3  4.0  2.0
+
+            >>> agnostic_rolling_var(df_pl)  #  doctest:+SKIP
+            shape: (4, 2)
+            ┌──────┬──────┐
+            │ a    ┆ b    │
+            │ ---  ┆ ---  │
+            │ f64  ┆ f64  │
+            ╞══════╪══════╡
+            │ 1.0  ┆ null │
+            │ 2.0  ┆ 0.5  │
+            │ null ┆ 0.5  │
+            │ 4.0  ┆ 2.0  │
+            └──────┴──────┘
+
+            >>> agnostic_rolling_var(df_pa)  #  doctest:+ELLIPSIS
+            pyarrow.Table
+            a: double
+            b: double
+            ----
+            a: [[1,2,null,4]]
+            b: [[nan,0.5,0.5,2]]
+        """
+        from narwhals.exceptions import NarwhalsUnstableWarning
+        from narwhals.utils import find_stacklevel
+
+        msg = (
+            "`Expr.rolling_var` is being called from the stable API although considered "
+            "an unstable feature."
+        )
+        warn(message=msg, category=NarwhalsUnstableWarning, stacklevel=find_stacklevel())
+        return super().rolling_var(
+            window_size=window_size, min_periods=min_periods, center=center, ddof=ddof
+        )
+
+    def rolling_std(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None = None,
+        center: bool = False,
+        ddof: int = 1,
+    ) -> Self:
+        """Apply a rolling standard deviation (moving standard deviation) over the values.
+
+        !!! warning
+            This functionality is considered **unstable**. It may be changed at any point
+            without it being considered a breaking change.
+
+        A window of length `window_size` will traverse the values. The resulting values
+        will be aggregated to their standard deviation.
+
+        The window at a given row will include the row itself and the `window_size - 1`
+        elements before it.
+
+        Arguments:
+            window_size: The length of the window in number of elements. It must be a
+                strictly positive integer.
+            min_periods: The number of values in the window that should be non-null before
+                computing a result. If set to `None` (default), it will be set equal to
+                `window_size`. If provided, it must be a strictly positive integer, and
+                less than or equal to `window_size`
+            center: Set the labels at the center of the window.
+            ddof: Delta Degrees of Freedom; the divisor for a length N window is N - ddof.
+
+        Returns:
+            A new expression.
+
+        Examples:
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoFrameT
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> data = {"a": [1.0, 2.0, None, 4.0]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
+
+            We define a library agnostic function:
+
+            >>> def agnostic_rolling_std(df_native: IntoFrameT) -> IntoFrameT:
+            ...     df = nw.from_native(df_native)
+            ...     return df.with_columns(
+            ...         b=nw.col("a").rolling_std(window_size=3, min_periods=1)
+            ...     ).to_native()
+
+            We can then pass any supported library such as Pandas, Polars, or PyArrow to `func`:
+
+            >>> agnostic_rolling_std(df_pd)
+                 a         b
+            0  1.0       NaN
+            1  2.0  0.707107
+            2  NaN  0.707107
+            3  4.0  1.414214
+
+            >>> agnostic_rolling_std(df_pl)  #  doctest:+SKIP
+            shape: (4, 2)
+            ┌──────┬──────────┐
+            │ a    ┆ b        │
+            │ ---  ┆ ---      │
+            │ f64  ┆ f64      │
+            ╞══════╪══════════╡
+            │ 1.0  ┆ null     │
+            │ 2.0  ┆ 0.707107 │
+            │ null ┆ 0.707107 │
+            │ 4.0  ┆ 1.414214 │
+            └──────┴──────────┘
+
+            >>> agnostic_rolling_std(df_pa)  #  doctest:+ELLIPSIS
+            pyarrow.Table
+            a: double
+            b: double
+            ----
+            a: [[1,2,null,4]]
+            b: [[nan,0.7071067811865476,0.7071067811865476,1.4142135623730951]]
+        """
+        from narwhals.exceptions import NarwhalsUnstableWarning
+        from narwhals.utils import find_stacklevel
+
+        msg = (
+            "`Expr.rolling_std` is being called from the stable API although considered "
+            "an unstable feature."
+        )
+        warn(message=msg, category=NarwhalsUnstableWarning, stacklevel=find_stacklevel())
+        return super().rolling_std(
+            window_size=window_size,
+            min_periods=min_periods,
+            center=center,
+            ddof=ddof,
+        )
+
 
 class Schema(NwSchema):
     """Ordered mapping of column names to their data type.
@@ -1953,7 +2369,8 @@ def nth(*indices: int | Sequence[int]) -> Expr:
     """Creates an expression that references one or more columns by their index(es).
 
     Notes:
-        `nth` is not supported for Polars version<1.0.0. Please use [`col`](/api-reference/narwhals/#narwhals.col) instead.
+        `nth` is not supported for Polars version<1.0.0. Please use
+        [`col`](../narwhals/#narwhals.col) instead.
 
     Arguments:
         indices: One or more indices representing the columns to retrieve.
@@ -3079,36 +3496,49 @@ def new_series(
     Examples:
         >>> import pandas as pd
         >>> import polars as pl
+        >>> import pyarrow as pa
         >>> import narwhals as nw
+        >>> from narwhals.typing import IntoFrameT, IntoSeriesT
         >>> data = {"a": [1, 2, 3], "b": [4, 5, 6]}
 
         Let's define a dataframe-agnostic function:
 
-        >>> @nw.narwhalify
-        ... def func(df):
-        ...     values = [4, 1, 2]
-        ...     native_namespace = nw.get_native_namespace(df)
+        >>> def agnostic_new_series(df_native: IntoFrameT) -> IntoSeriesT:
+        ...     values = [4, 1, 2, 3]
+        ...     native_namespace = nw.get_native_namespace(df_native)
         ...     return nw.new_series(
-        ...         name="c",
+        ...         name="a",
         ...         values=values,
         ...         dtype=nw.Int32,
         ...         native_namespace=native_namespace,
-        ...     )
+        ...     ).to_native()
 
-        Let's see what happens when passing pandas / Polars input:
+        We can then pass any supported eager library, such as pandas / Polars / PyArrow:
 
-        >>> func(pd.DataFrame(data))
+        >>> agnostic_new_series(pd.DataFrame(data))
         0    4
         1    1
         2    2
-        Name: c, dtype: int32
-        >>> func(pl.DataFrame(data))  # doctest: +NORMALIZE_WHITESPACE
-        shape: (3,)
-        Series: 'c' [i32]
+        3    3
+        Name: a, dtype: int32
+        >>> agnostic_new_series(pl.DataFrame(data))  # doctest: +NORMALIZE_WHITESPACE
+        shape: (4,)
+        Series: 'a' [i32]
         [
            4
            1
            2
+           3
+        ]
+        >>> agnostic_new_series(pa.table(data))
+        <pyarrow.lib.ChunkedArray object at ...>
+        [
+          [
+            4,
+            1,
+            2,
+            3
+          ]
         ]
     """
     return _stableify(  # type: ignore[no-any-return]
@@ -3139,25 +3569,26 @@ def from_arrow(
         >>> import polars as pl
         >>> import pyarrow as pa
         >>> import narwhals as nw
+        >>> from narwhals.typing import IntoFrameT
         >>> data = {"a": [1, 2, 3], "b": [4, 5, 6]}
 
         Let's define a dataframe-agnostic function which creates a PyArrow
         Table.
 
-        >>> @nw.narwhalify
-        ... def func(df):
-        ...     return nw.from_arrow(df, native_namespace=pa)
+        >>> def agnostic_to_arrow(df_native: IntoFrameT) -> IntoFrameT:
+        ...     df = nw.from_native(df_native)
+        ...     return nw.from_arrow(df, native_namespace=pa).to_native()
 
         Let's see what happens when passing pandas / Polars input:
 
-        >>> func(pd.DataFrame(data))  # doctest: +SKIP
+        >>> agnostic_to_arrow(pd.DataFrame(data))
         pyarrow.Table
         a: int64
         b: int64
         ----
         a: [[1,2,3]]
         b: [[4,5,6]]
-        >>> func(pl.DataFrame(data))  # doctest: +SKIP
+        >>> agnostic_to_arrow(pl.DataFrame(data))
         pyarrow.Table
         a: int64
         b: int64
@@ -3199,23 +3630,23 @@ def from_dict(
         >>> import polars as pl
         >>> import pyarrow as pa
         >>> import narwhals as nw
+        >>> from narwhals.typing import IntoFrameT
         >>> data = {"a": [1, 2, 3], "b": [4, 5, 6]}
 
         Let's create a new dataframe of the same class as the dataframe we started with, from a dict of new data:
 
-        >>> @nw.narwhalify
-        ... def func(df):
+        >>> def agnostic_from_dict(df_native: IntoFrameT) -> IntoFrameT:
         ...     new_data = {"c": [5, 2], "d": [1, 4]}
-        ...     native_namespace = nw.get_native_namespace(df)
-        ...     return nw.from_dict(new_data, native_namespace=native_namespace)
+        ...     native_namespace = nw.get_native_namespace(df_native)
+        ...     return nw.from_dict(new_data, native_namespace=native_namespace).to_native()
 
         Let's see what happens when passing pandas, Polars or PyArrow input:
 
-        >>> func(pd.DataFrame(data))
+        >>> agnostic_from_dict(pd.DataFrame(data))
            c  d
         0  5  1
         1  2  4
-        >>> func(pl.DataFrame(data))
+        >>> agnostic_from_dict(pl.DataFrame(data))
         shape: (2, 2)
         ┌─────┬─────┐
         │ c   ┆ d   │
@@ -3225,7 +3656,7 @@ def from_dict(
         │ 5   ┆ 1   │
         │ 2   ┆ 4   │
         └─────┴─────┘
-        >>> func(pa.table(data))
+        >>> agnostic_from_dict(pa.table(data))
         pyarrow.Table
         c: int64
         d: int64
diff --git a/noxfile.py b/noxfile.py
index ccb6bae2..5525c781 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -14,7 +14,7 @@ PYTHON_VERSIONS = ["3.8", "3.9", "3.10", "3.11", "3.12"]
 
 
 def run_common(session: Session, coverage_threshold: float) -> None:
-    session.install("-e.", "-r", "requirements-dev.txt")
+    session.install('-e ".[dev]"')
 
     if session.python != "3.8":
         session.install("ibis-framework>=6.0.0", "rich", "packaging", "pyarrow_hotfix")
diff --git a/pyproject.toml b/pyproject.toml
index bcf4671d..aaba74e4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "narwhals"
-version = "1.18.3"
+version = "1.18.4"
 authors = [
   { name = "Marco Gorelli", email = "33491632+MarcoGorelli@users.noreply.github.com" },
 ]
@@ -28,7 +28,6 @@ exclude = [
   "CONTRIBUTING.md",
   "mkdocs.yml",
   "noxfile.py",
-  "requirements-dev.txt",
 ]
 
 [project.optional-dependencies]
@@ -39,6 +38,38 @@ polars = ["polars>=0.20.3"]
 pyarrow = ["pyarrow>=11.0.0"]
 dask = ["dask[dataframe]>=2024.7"]
 pyspark = ["pyspark>=3.3.0"]
+dev = [
+  "tqdm",
+  "covdefaults",
+  "duckdb",
+  "pandas",
+  "polars",
+  "pyarrow",
+  "pyspark; python_version >= '3.9' and python_version < '3.12'",
+  "pyarrow-stubs",
+  "pytest",
+  "pytest-cov",
+  "pytest-randomly",
+  "pytest-env",
+  "hypothesis",
+  "hypothesis[numpy]",
+  "scikit-learn",
+  "typing_extensions",
+  "dask[dataframe]; python_version >= '3.9'",
+]
+docs = [
+  "black",  # required by mkdocstrings_handlers
+  "jinja2",
+  "duckdb",
+  "markdown-exec[ansi]",
+  "mkdocs",
+  "mkdocs-autorefs",
+  "mkdocs-material",
+  "mkdocstrings[python]",
+  "pandas",
+  "polars>=1.0.0",
+  "pyarrow",
+]
 
 [project.urls]
 "Homepage" = "https://github.com/narwhals-dev/narwhals"
@@ -152,7 +183,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'
diff --git a/requirements-dev.txt b/requirements-dev.txt
deleted file mode 100644
index ff842920..00000000
--- a/requirements-dev.txt
+++ /dev/null
@@ -1,18 +0,0 @@
-tqdm
-covdefaults
-duckdb
-pandas
-polars
-pre-commit
-pyarrow
-pyspark; python_version < '3.12'
-pyarrow-stubs
-pytest
-pytest-cov
-pytest-randomly
-pytest-env
-hypothesis
-hypothesis[numpy]
-scikit-learn
-typing_extensions
-dask[dataframe]; python_version >= '3.9'


Test patch:
diff --git a/.github/workflows/downstream_tests.yml b/.github/workflows/downstream_tests.yml
index 5b98f9a3..2c560225 100644
--- a/.github/workflows/downstream_tests.yml
+++ b/.github/workflows/downstream_tests.yml
@@ -23,7 +23,7 @@ jobs:
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
-          cache-dependency-glob: "**requirements*.txt"
+          cache-dependency-glob: "pyproject.toml"
       - name: clone-altair
         run: |
             git clone https://github.com/vega/altair.git --depth=1
@@ -70,7 +70,7 @@ jobs:
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
-          cache-dependency-glob: "**requirements*.txt"
+          cache-dependency-glob: "pyproject.toml"
       - name: clone-marimo
         run: |
             git clone https://github.com/marimo-team/marimo.git --depth=1
@@ -131,7 +131,7 @@ jobs:
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
-          cache-dependency-glob: "**requirements*.txt"
+          cache-dependency-glob: "pyproject.toml"
       - name: clone-scikit-lego
         run: git clone https://github.com/koaning/scikit-lego.git --depth 1
       - name: install-basics
@@ -140,8 +140,6 @@ jobs:
         run: |
             cd scikit-lego
             uv pip install -e ".[test]" --system
-            # temporary pin to get ci green
-            uv pip install "scikit-learn<1.6" --system
       - name: install-narwhals-dev
         run: |
             uv pip uninstall narwhals --system
@@ -170,7 +168,7 @@ jobs:
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
-          cache-dependency-glob: "**requirements*.txt"
+          cache-dependency-glob: "pyproject.toml"
       - name: clone-shiny
         run: |
             git clone https://github.com/posit-dev/py-shiny.git
@@ -195,6 +193,43 @@ jobs:
             cd py-shiny
             make narwhals-test-integration
   
+  tea-tasting:
+    strategy:
+      matrix:
+        python-version: ["3.12"]
+        os: [ubuntu-latest]
+    runs-on: ${{ matrix.os }}
+    steps:
+      - uses: actions/checkout@v4
+      - name: clone-tea-tasting
+        run: |
+            git clone https://github.com/e10v/tea-tasting.git --depth=1
+            cd tea-tasting
+            git log
+      - name: install-pdm
+        uses: pdm-project/setup-pdm@v4
+        with:
+          python-version: ${{ matrix.python-version }}
+          cache: true
+          cache-dependency-path: tea-tasting/pdm.lock
+      - name: install-tea-tasting-test
+        run: |
+            cd tea-tasting
+            pdm sync -G test
+      - name: install-narwhals-dev
+        run: |
+            cd tea-tasting
+            pdm remove narwhals
+            pdm add ./..
+      - name: show-deps
+        run: |
+            cd tea-tasting
+            pdm list
+      - name: run-pytest
+        run: |
+            cd tea-tasting
+            pdm run test
+  
   tubular:
     strategy:
       matrix:
@@ -212,7 +247,7 @@ jobs:
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
-          cache-dependency-glob: "**requirements*.txt"
+          cache-dependency-glob: "pyproject.toml"
       - name: clone-tubular
         run: |
             git clone https://github.com/lvgig/tubular --depth=1
@@ -223,7 +258,7 @@ jobs:
       - name: install-tubular-dev
         run: |
             cd tubular
-            uv pip install -e .[dev] --system
+            uv pip install -e ".[dev]" --system
       - name: install-narwhals-dev
         run: |
             uv pip uninstall narwhals --system
@@ -252,7 +287,7 @@ jobs:
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
-          cache-dependency-glob: "**requirements*.txt"
+          cache-dependency-glob: "pyproject.toml"
       - name: clone-plotly
         run: |
             git clone https://github.com/plotly/plotly.py --depth=1
@@ -292,7 +327,7 @@ jobs:
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
-          cache-dependency-glob: "**requirements*.txt"
+          cache-dependency-glob: "pyproject.toml"
       - name: clone-hierarchicalforecast
         run: |
             git clone https://github.com/Nixtla/hierarchicalforecast --depth=1
diff --git a/.github/workflows/downstream_tests_slow.yml b/.github/workflows/downstream_tests_slow.yml
index 91aba65a..e26598d8 100644
--- a/.github/workflows/downstream_tests_slow.yml
+++ b/.github/workflows/downstream_tests_slow.yml
@@ -25,7 +25,7 @@ jobs:
           with:
             enable-cache: "true"
             cache-suffix: ${{ matrix.python-version }}
-            cache-dependency-glob: "**requirements*.txt"
+            cache-dependency-glob: "pyproject.toml"
         - name: clone-vegafusion
           run: |
               git clone --single-branch -b v2 https://github.com/vega/vegafusion.git
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
index 406778fa..8fd4d184 100644
--- a/.github/workflows/pytest.yml
+++ b/.github/workflows/pytest.yml
@@ -23,9 +23,9 @@ jobs:
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
-          cache-dependency-glob: "**requirements*.txt"
+          cache-dependency-glob: "pyproject.toml"
       - name: install-reqs
-        run: uv pip install --upgrade tox virtualenv setuptools -r requirements-dev.txt --system
+        run: uv pip install -e ".[dev]" --system
       - name: show-deps
         run: uv pip freeze
       - name: Run pytest
@@ -48,11 +48,13 @@ jobs:
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
-          cache-dependency-glob: "**requirements*.txt"
+          cache-dependency-glob: "pyproject.toml"
       - name: install-reqs
-        run: uv pip install --upgrade tox virtualenv setuptools -r requirements-dev.txt --system
+        run: uv pip install -e ".[dev]" --system
+      - name: install-dask
+        run: uv pip install --upgrade dask[dataframe] --system
       - name: install-modin
-        run: uv pip install --upgrade modin[dask] --system
+        run: uv pip install --upgrade modin --system
       - name: show-deps
         run: uv pip freeze
       - name: Run pytest
@@ -75,9 +77,9 @@ jobs:
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
-          cache-dependency-glob: "**requirements*.txt"
+          cache-dependency-glob: "pyproject.toml"
       - name: install-reqs
-        run: uv pip install --upgrade tox virtualenv setuptools -r requirements-dev.txt --system
+        run: uv pip install -e ".[dev]" --system
       - name: install-modin
         run: uv pip install --upgrade modin[dask] --system
       - name: show-deps
diff --git a/.github/workflows/random_ci_pytest.yml b/.github/workflows/random_ci_pytest.yml
index ffaa5da0..599dab3c 100644
--- a/.github/workflows/random_ci_pytest.yml
+++ b/.github/workflows/random_ci_pytest.yml
@@ -21,13 +21,13 @@ jobs:
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
-          cache-dependency-glob: "**requirements*.txt"
+          cache-dependency-glob: "pyproject.toml"
       - name: install package
         run: uv pip install -e . --system
       - name: generate-random-versions
         run: python utils/generate_random_versions.py
       - name: install-reqs
-        run: uv pip install --upgrade tox virtualenv setuptools --system && uv pip install -r requirements-dev.txt --system
+        run: uv pip install -e ".[dev]" --system
       - name: uninstall scipy/sklearn
         run: uv pip uninstall scipy scikit-learn --system
       - name: install-random-verions
diff --git a/tests/expr_and_series/dt/timestamp_test.py b/tests/expr_and_series/dt/timestamp_test.py
index 21292662..51782794 100644
--- a/tests/expr_and_series/dt/timestamp_test.py
+++ b/tests/expr_and_series/dt/timestamp_test.py
@@ -199,6 +199,7 @@ def test_timestamp_invalid_unit_series(constructor_eager: ConstructorEager) -> N
 )
 @pytest.mark.skipif(PANDAS_VERSION < (2, 2), reason="bug in old pandas")
 @pytest.mark.skipif(POLARS_VERSION < (0, 20, 7), reason="bug in old Polars")
+@pytest.mark.slow
 def test_timestamp_hypothesis(
     inputs: datetime,
     time_unit: Literal["ms", "us", "ns"],
diff --git a/tests/expr_and_series/is_in_test.py b/tests/expr_and_series/is_in_test.py
index ee0080af..a2f1592b 100644
--- a/tests/expr_and_series/is_in_test.py
+++ b/tests/expr_and_series/is_in_test.py
@@ -35,3 +35,11 @@ def test_is_in_other(constructor: Constructor) -> None:
         ),
     ):
         nw.from_native(df_raw).with_columns(contains=nw.col("a").is_in("sets"))
+
+
+def test_filter_is_in_with_series(constructor_eager: ConstructorEager) -> None:
+    data = {"a": [1, 4, 2, 5], "b": [1, 0, 2, 0]}
+    df = nw.from_native(constructor_eager(data), eager_only=True)
+    result = df.filter(nw.col("a").is_in(df["b"]))
+    expected = {"a": [1, 2], "b": [1, 2]}
+    assert_equal_data(result, expected)
diff --git a/tests/expr_and_series/over_test.py b/tests/expr_and_series/over_test.py
index e4ab273c..54593421 100644
--- a/tests/expr_and_series/over_test.py
+++ b/tests/expr_and_series/over_test.py
@@ -1,10 +1,10 @@
 from __future__ import annotations
 
-from contextlib import nullcontext as does_not_raise
-
+import pandas as pd
 import pytest
 
 import narwhals.stable.v1 as nw
+from tests.utils import PANDAS_VERSION
 from tests.utils import Constructor
 from tests.utils import assert_equal_data
 
@@ -15,7 +15,10 @@ data = {
 }
 
 
-def test_over_single(constructor: Constructor) -> None:
+def test_over_single(request: pytest.FixtureRequest, constructor: Constructor) -> None:
+    if "dask_lazy_p2" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+
     df = nw.from_native(constructor(data))
     expected = {
         "a": ["a", "a", "b", "b", "b"],
@@ -24,21 +27,14 @@ def test_over_single(constructor: Constructor) -> None:
         "c_max": [5, 5, 3, 3, 3],
     }
 
-    context = (
-        pytest.raises(
-            NotImplementedError,
-            match="`Expr.over` is not supported for Dask backend with multiple partitions.",
-        )
-        if "dask_lazy_p2" in str(constructor)
-        else does_not_raise()
-    )
+    result = df.with_columns(c_max=nw.col("c").max().over("a"))
+    assert_equal_data(result, expected)
 
-    with context:
-        result = df.with_columns(c_max=nw.col("c").max().over("a"))
-        assert_equal_data(result, expected)
 
+def test_over_multiple(request: pytest.FixtureRequest, constructor: Constructor) -> None:
+    if "dask_lazy_p2" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
 
-def test_over_multiple(constructor: Constructor) -> None:
     df = nw.from_native(constructor(data))
     expected = {
         "a": ["a", "a", "b", "b", "b"],
@@ -47,18 +43,8 @@ def test_over_multiple(constructor: Constructor) -> None:
         "c_min": [5, 4, 1, 2, 1],
     }
 
-    context = (
-        pytest.raises(
-            NotImplementedError,
-            match="`Expr.over` is not supported for Dask backend with multiple partitions.",
-        )
-        if "dask_lazy_p2" in str(constructor)
-        else does_not_raise()
-    )
-
-    with context:
-        result = df.with_columns(c_min=nw.col("c").min().over("a", "b"))
-        assert_equal_data(result, expected)
+    result = df.with_columns(c_min=nw.col("c").min().over("a", "b"))
+    assert_equal_data(result, expected)
 
 
 def test_over_invalid(request: pytest.FixtureRequest, constructor: Constructor) -> None:
@@ -68,3 +54,121 @@ def test_over_invalid(request: pytest.FixtureRequest, constructor: Constructor)
     df = nw.from_native(constructor(data))
     with pytest.raises(ValueError, match="Anonymous expressions"):
         df.with_columns(c_min=nw.all().min().over("a", "b"))
+
+
+def test_over_cumsum(request: pytest.FixtureRequest, constructor: Constructor) -> None:
+    if "pyarrow_table" in str(constructor) or "dask_lazy_p2" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+    if "pandas_pyarrow" in str(constructor) and PANDAS_VERSION < (2, 1):
+        request.applymarker(pytest.mark.xfail)
+
+    df = nw.from_native(constructor(data))
+    expected = {
+        "a": ["a", "a", "b", "b", "b"],
+        "b": [1, 2, 3, 5, 3],
+        "c": [5, 4, 3, 2, 1],
+        "b_cumsum": [1, 3, 3, 8, 11],
+    }
+
+    result = df.with_columns(b_cumsum=nw.col("b").cum_sum().over("a"))
+    assert_equal_data(result, expected)
+
+
+def test_over_cumcount(request: pytest.FixtureRequest, constructor: Constructor) -> None:
+    if "pyarrow_table" in str(constructor) or "dask_lazy_p2" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+
+    df = nw.from_native(constructor(data))
+    expected = {
+        "a": ["a", "a", "b", "b", "b"],
+        "b": [1, 2, 3, 5, 3],
+        "c": [5, 4, 3, 2, 1],
+        "b_cumcount": [1, 2, 1, 2, 3],
+    }
+
+    result = df.with_columns(b_cumcount=nw.col("b").cum_count().over("a"))
+    assert_equal_data(result, expected)
+
+
+def test_over_cumcount_missing_values(
+    request: pytest.FixtureRequest, constructor: Constructor
+) -> None:
+    if "pyarrow_table" in str(constructor) or "dask_lazy_p2" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+
+    data_with_missing_value = {
+        "a": ["a", "a", "b", "b", "b"],
+        "b": [1, 2, 3, 5, None],
+        "c": [5, 4, 3, 2, 1],
+    }
+
+    df = nw.from_native(constructor(data_with_missing_value))
+    expected = {
+        "a": ["a", "a", "b", "b", "b"],
+        "b": [1, 2, 3, 5, None],
+        "c": [5, 4, 3, 2, 1],
+        "b_cumcount": [1, 2, 1, 2, 2],
+    }
+
+    result = df.with_columns(b_cumcount=nw.col("b").cum_count().over("a"))
+    assert_equal_data(result, expected)
+
+
+def test_over_cummax(request: pytest.FixtureRequest, constructor: Constructor) -> None:
+    if "pyarrow_table" in str(constructor) or "dask_lazy_p2" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+    if "pandas_pyarrow" in str(constructor) and PANDAS_VERSION < (2, 1):
+        request.applymarker(pytest.mark.xfail)
+    df = nw.from_native(constructor(data))
+    expected = {
+        "a": ["a", "a", "b", "b", "b"],
+        "b": [1, 2, 3, 5, 3],
+        "c": [5, 4, 3, 2, 1],
+        "b_cummax": [1, 2, 3, 5, 5],
+    }
+    result = df.with_columns(b_cummax=nw.col("b").cum_max().over("a"))
+    assert_equal_data(result, expected)
+
+
+def test_over_cummin(request: pytest.FixtureRequest, constructor: Constructor) -> None:
+    if "pyarrow_table" in str(constructor) or "dask_lazy_p2" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+
+    if "pandas_pyarrow" in str(constructor) and PANDAS_VERSION < (2, 1):
+        request.applymarker(pytest.mark.xfail)
+
+    df = nw.from_native(constructor(data))
+    expected = {
+        "a": ["a", "a", "b", "b", "b"],
+        "b": [1, 2, 3, 5, 3],
+        "c": [5, 4, 3, 2, 1],
+        "b_cummin": [1, 1, 3, 3, 3],
+    }
+
+    result = df.with_columns(b_cummin=nw.col("b").cum_min().over("a"))
+    assert_equal_data(result, expected)
+
+
+def test_over_cumprod(request: pytest.FixtureRequest, constructor: Constructor) -> None:
+    if "pyarrow_table" in str(constructor) or "dask_lazy_p2" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+
+    if "pandas_pyarrow" in str(constructor) and PANDAS_VERSION < (2, 1):
+        request.applymarker(pytest.mark.xfail)
+
+    df = nw.from_native(constructor(data))
+    expected = {
+        "a": ["a", "a", "b", "b", "b"],
+        "b": [1, 2, 3, 5, 3],
+        "c": [5, 4, 3, 2, 1],
+        "b_cumprod": [1, 2, 3, 15, 45],
+    }
+
+    result = df.with_columns(b_cumprod=nw.col("b").cum_prod().over("a"))
+    assert_equal_data(result, expected)
+
+
+def test_over_anonymous() -> None:
+    df = pd.DataFrame({"a": [1, 1, 2], "b": [4, 5, 6]})
+    with pytest.raises(ValueError, match="Anonymous expressions"):
+        nw.from_native(df).select(nw.all().cum_max().over("a"))
diff --git a/tests/expr_and_series/rolling_mean_test.py b/tests/expr_and_series/rolling_mean_test.py
index 3ffd8749..33c817bf 100644
--- a/tests/expr_and_series/rolling_mean_test.py
+++ b/tests/expr_and_series/rolling_mean_test.py
@@ -82,6 +82,7 @@ def test_rolling_mean_series(constructor_eager: ConstructorEager) -> None:
     values=st.lists(st.floats(-10, 10), min_size=3, max_size=10),
 )
 @pytest.mark.skipif(PANDAS_VERSION < (1,), reason="too old for pyarrow")
+@pytest.mark.slow
 @pytest.mark.filterwarnings("ignore:.*:narwhals.exceptions.NarwhalsUnstableWarning")
 def test_rolling_mean_hypothesis(center: bool, values: list[float]) -> None:  # noqa: FBT001
     s = pd.Series(values)
diff --git a/tests/expr_and_series/rolling_std_test.py b/tests/expr_and_series/rolling_std_test.py
new file mode 100644
index 00000000..3fdba949
--- /dev/null
+++ b/tests/expr_and_series/rolling_std_test.py
@@ -0,0 +1,96 @@
+from __future__ import annotations
+
+from typing import Any
+
+import numpy as np
+import pytest
+
+import narwhals.stable.v1 as nw
+from tests.utils import POLARS_VERSION
+from tests.utils import Constructor
+from tests.utils import ConstructorEager
+from tests.utils import assert_equal_data
+
+data = {"a": [1.0, 2.0, 1.0, 3.0, 1.0, 4.0, 1.0]}
+
+kwargs_and_expected = (
+    {
+        "name": "x1",
+        "kwargs": {"window_size": 3},
+        "expected": np.sqrt([float("nan"), float("nan"), 1 / 3, 1, 4 / 3, 7 / 3, 3]),
+    },
+    {
+        "name": "x2",
+        "kwargs": {"window_size": 3, "min_periods": 1},
+        "expected": np.sqrt([float("nan"), 0.5, 1 / 3, 1.0, 4 / 3, 7 / 3, 3]),
+    },
+    {
+        "name": "x3",
+        "kwargs": {"window_size": 2, "min_periods": 1},
+        "expected": np.sqrt([float("nan"), 0.5, 0.5, 2.0, 2.0, 4.5, 4.5]),
+    },
+    {
+        "name": "x4",
+        "kwargs": {"window_size": 5, "min_periods": 1, "center": True},
+        "expected": np.sqrt([1 / 3, 11 / 12, 4 / 5, 17 / 10, 2.0, 2.25, 3]),
+    },
+    {
+        "name": "x5",
+        "kwargs": {"window_size": 4, "min_periods": 1, "center": True},
+        "expected": np.sqrt([0.5, 1 / 3, 11 / 12, 11 / 12, 2.25, 2.25, 3]),
+    },
+    {
+        "name": "x6",
+        "kwargs": {"window_size": 3, "ddof": 2},
+        "expected": np.sqrt([float("nan"), float("nan"), 2 / 3, 2.0, 8 / 3, 14 / 3, 6.0]),
+    },
+)
+
+
+@pytest.mark.filterwarnings(
+    "ignore:`Expr.rolling_std` is being called from the stable API although considered an unstable feature."
+)
+@pytest.mark.parametrize("kwargs_and_expected", kwargs_and_expected)
+def test_rolling_std_expr(
+    request: pytest.FixtureRequest,
+    constructor: Constructor,
+    kwargs_and_expected: dict[str, Any],
+) -> None:
+    name = kwargs_and_expected["name"]
+    kwargs = kwargs_and_expected["kwargs"]
+    expected = kwargs_and_expected["expected"]
+
+    if "dask" in str(constructor) or (
+        "polars" in str(constructor) and POLARS_VERSION < (1,)
+    ):
+        # TODO(FBruzzesi): Dask is raising the following error:
+        # NotImplementedError: Partition size is less than overlapping window size.
+        # Try using ``df.repartition`` to increase the partition size.
+        request.applymarker(pytest.mark.xfail)
+
+    df = nw.from_native(constructor(data))
+    result = df.select(nw.col("a").rolling_std(**kwargs).alias(name))
+
+    assert_equal_data(result, {name: expected})
+
+
+@pytest.mark.filterwarnings(
+    "ignore:`Series.rolling_std` is being called from the stable API although considered an unstable feature."
+)
+@pytest.mark.parametrize("kwargs_and_expected", kwargs_and_expected)
+def test_rolling_std_series(
+    request: pytest.FixtureRequest,
+    constructor_eager: ConstructorEager,
+    kwargs_and_expected: dict[str, Any],
+) -> None:
+    if "polars" in str(constructor_eager) and POLARS_VERSION < (1,):
+        request.applymarker(pytest.mark.xfail)
+
+    name = kwargs_and_expected["name"]
+    kwargs = kwargs_and_expected["kwargs"]
+    expected = kwargs_and_expected["expected"]
+
+    df = nw.from_native(constructor_eager(data), eager_only=True)
+    result = df.select(df["a"].rolling_std(**kwargs).alias(name))
+
+    assert_equal_data(result, {name: expected})
diff --git a/tests/expr_and_series/rolling_sum_test.py b/tests/expr_and_series/rolling_sum_test.py
index cbf999a0..fae22552 100644
--- a/tests/expr_and_series/rolling_sum_test.py
+++ b/tests/expr_and_series/rolling_sum_test.py
@@ -200,6 +200,7 @@ def test_rolling_sum_series_invalid_params(
 )
 @pytest.mark.skipif(PANDAS_VERSION < (1,), reason="too old for pyarrow")
 @pytest.mark.filterwarnings("ignore:.*:narwhals.exceptions.NarwhalsUnstableWarning")
+@pytest.mark.slow
 def test_rolling_sum_hypothesis(center: bool, values: list[float]) -> None:  # noqa: FBT001
     s = pd.Series(values)
     n_missing = random.randint(0, len(s) - 1)  # noqa: S311
diff --git a/tests/expr_and_series/rolling_var_test.py b/tests/expr_and_series/rolling_var_test.py
new file mode 100644
index 00000000..32767c99
--- /dev/null
+++ b/tests/expr_and_series/rolling_var_test.py
@@ -0,0 +1,141 @@
+from __future__ import annotations
+
+import random
+from typing import Any
+
+import hypothesis.strategies as st
+import pandas as pd
+import polars as pl
+import pyarrow as pa
+import pytest
+from hypothesis import given
+
+import narwhals.stable.v1 as nw
+from tests.utils import PANDAS_VERSION
+from tests.utils import POLARS_VERSION
+from tests.utils import Constructor
+from tests.utils import ConstructorEager
+from tests.utils import assert_equal_data
+
+data = {"a": [1.0, 2.0, 1.0, 3.0, 1.0, 4.0, 1.0]}
+
+kwargs_and_expected = (
+    {
+        "name": "x1",
+        "kwargs": {"window_size": 3},
+        "expected": [float("nan"), float("nan"), 1 / 3, 1, 4 / 3, 7 / 3, 3],
+    },
+    {
+        "name": "x2",
+        "kwargs": {"window_size": 3, "min_periods": 1},
+        "expected": [float("nan"), 0.5, 1 / 3, 1.0, 4 / 3, 7 / 3, 3],
+    },
+    {
+        "name": "x3",
+        "kwargs": {"window_size": 2, "min_periods": 1},
+        "expected": [float("nan"), 0.5, 0.5, 2.0, 2.0, 4.5, 4.5],
+    },
+    {
+        "name": "x4",
+        "kwargs": {"window_size": 5, "min_periods": 1, "center": True},
+        "expected": [1 / 3, 11 / 12, 4 / 5, 17 / 10, 2.0, 2.25, 3],
+    },
+    {
+        "name": "x5",
+        "kwargs": {"window_size": 4, "min_periods": 1, "center": True},
+        "expected": [0.5, 1 / 3, 11 / 12, 11 / 12, 2.25, 2.25, 3],
+    },
+    {
+        "name": "x6",
+        "kwargs": {"window_size": 3, "ddof": 2},
+        "expected": [float("nan"), float("nan"), 2 / 3, 2.0, 8 / 3, 14 / 3, 6.0],
+    },
+)
+
+
+@pytest.mark.filterwarnings(
+    "ignore:`Expr.rolling_var` is being called from the stable API although considered an unstable feature."
+)
+@pytest.mark.parametrize("kwargs_and_expected", kwargs_and_expected)
+def test_rolling_var_expr(
+    request: pytest.FixtureRequest,
+    constructor: Constructor,
+    kwargs_and_expected: dict[str, Any],
+) -> None:
+    name = kwargs_and_expected["name"]
+    kwargs = kwargs_and_expected["kwargs"]
+    expected = kwargs_and_expected["expected"]
+
+    if "dask" in str(constructor) or (
+        "polars" in str(constructor) and POLARS_VERSION < (1,)
+    ):
+        # TODO(FBruzzesi): Dask is raising the following error:
+        # NotImplementedError: Partition size is less than overlapping window size.
+        # Try using ``df.repartition`` to increase the partition size.
+        request.applymarker(pytest.mark.xfail)
+
+    df = nw.from_native(constructor(data))
+    result = df.select(nw.col("a").rolling_var(**kwargs).alias(name))
+
+    assert_equal_data(result, {name: expected})
+
+
+@pytest.mark.filterwarnings(
+    "ignore:`Series.rolling_var` is being called from the stable API although considered an unstable feature."
+)
+@pytest.mark.parametrize("kwargs_and_expected", kwargs_and_expected)
+def test_rolling_var_series(
+    request: pytest.FixtureRequest,
+    constructor_eager: ConstructorEager,
+    kwargs_and_expected: dict[str, Any],
+) -> None:
+    if "polars" in str(constructor_eager) and POLARS_VERSION < (1,):
+        request.applymarker(pytest.mark.xfail)
+
+    name = kwargs_and_expected["name"]
+    kwargs = kwargs_and_expected["kwargs"]
+    expected = kwargs_and_expected["expected"]
+
+    df = nw.from_native(constructor_eager(data), eager_only=True)
+    result = df.select(df["a"].rolling_var(**kwargs).alias(name))
+
+    assert_equal_data(result, {name: expected})
+
+
+@given(  # type: ignore[misc]
+    center=st.booleans(),
+    values=st.lists(st.floats(-10, 10), min_size=5, max_size=10),
+)
+@pytest.mark.skipif(PANDAS_VERSION < (1,), reason="too old for pyarrow")
+@pytest.mark.skipif(POLARS_VERSION < (1,), reason="different null behavior")
+@pytest.mark.filterwarnings("ignore:.*:narwhals.exceptions.NarwhalsUnstableWarning")
+def test_rolling_var_hypothesis(center: bool, values: list[float]) -> None:  # noqa: FBT001
+    s = pd.Series(values)
+    window_size = random.randint(2, len(s))  # noqa: S311
+    min_periods = random.randint(2, window_size)  # noqa: S311
+    ddof = random.randint(0, min_periods - 1)  # noqa: S311
+    mask = random.sample(range(len(s)), 2)
+
+    s[mask] = None
+    df = pd.DataFrame({"a": s})
+    expected = (
+        s.rolling(window=window_size, center=center, min_periods=min_periods)
+        .var(ddof=ddof)
+        .to_frame("a")
+    )
+
+    result = nw.from_native(pa.Table.from_pandas(df)).select(
+        nw.col("a").rolling_var(
+            window_size, center=center, min_periods=min_periods, ddof=ddof
+        )
+    )
+    expected_dict = nw.from_native(expected, eager_only=True).to_dict(as_series=False)
+    assert_equal_data(result, expected_dict)
+
+    result = nw.from_native(pl.from_pandas(df)).select(
+        nw.col("a").rolling_var(
+            window_size, center=center, min_periods=min_periods, ddof=ddof
+        )
+    )
+    expected_dict = nw.from_native(expected, eager_only=True).to_dict(as_series=False)
+    assert_equal_data(result, expected_dict)
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/frame/invalid_test.py b/tests/frame/invalid_test.py
index 257dade2..af600321 100644
--- a/tests/frame/invalid_test.py
+++ b/tests/frame/invalid_test.py
@@ -43,6 +43,7 @@ def test_validate_laziness() -> None:
         nw.concat([nw.from_native(df, eager_only=True), nw.from_native(df).lazy()])  # type: ignore[list-item]
 
 
+@pytest.mark.slow
 @pytest.mark.skipif(NUMPY_VERSION < (1, 26, 4), reason="too old")
 def test_memmap() -> None:
     pytest.importorskip("sklearn")
diff --git a/tests/group_by_test.py b/tests/group_by_test.py
index e51cbac6..9efda1fa 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")
@@ -360,3 +364,19 @@ def test_group_by_shift_raises(
         ValueError, match=".*(failed to aggregate|Non-trivial complex aggregation found)"
     ):
         df.group_by("b").agg(nw.col("a").shift(1))
+
+
+def test_double_same_aggregation(
+    constructor: Constructor, request: pytest.FixtureRequest
+) -> None:
+    if "dask" in str(constructor) or "modin" in str(constructor):
+        # bugged in dask https://github.com/dask/dask/issues/11612
+        # and modin lol https://github.com/modin-project/modin/issues/7414
+        # At least cudf gets it right
+        request.applymarker(pytest.mark.xfail)
+    if "pandas" in str(constructor) and PANDAS_VERSION < (1,):
+        request.applymarker(pytest.mark.xfail)
+    df = nw.from_native(constructor({"a": [1, 1, 2], "b": [4, 5, 6]}))
+    result = df.group_by("a").agg(c=nw.col("b").mean(), d=nw.col("b").mean()).sort("a")
+    expected = {"a": [1, 2], "c": [4.5, 6], "d": [4.5, 6]}
+    assert_equal_data(result, expected)
diff --git a/tests/utils_test.py b/tests/utils_test.py
index c36cb168..26bd2ecf 100644
--- a/tests/utils_test.py
+++ b/tests/utils_test.py
@@ -247,6 +247,7 @@ def test_get_trivial_version_with_uninstalled_module() -> None:
 
 
 @given(n_bytes=st.integers(1, 100))  # type: ignore[misc]
+@pytest.mark.slow
 def test_generate_temporary_column_name(n_bytes: int) -> None:
     columns = ["abc", "XYZ"]