Repository: narwhals-dev/narwhals
Pull request #1687 (2024-12-31 09:22:18)

fix: remove `maintain_order` from LazyFrame.unique
<!--
# Thanks for contributing a pull request! 
## Please make sure you see our contribution guidelines: https://github.com/narwhals-dev/narwhals/blob/main/CONTRIBUTING.md
-->

## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [ ] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues

- Related issue #\<issue number\>
- Closes #\<issue number\>

## Checklist

- [ ] Code follows style guide (ruff)
- [ ] Tests added
- [ ] Documented the changes

## If you have comments or can explain your changes, please do so below



Patch:
diff --git a/.github/workflows/check_tpch_queries.yml b/.github/workflows/check_tpch_queries.yml
index 888f4004..8924421f 100644
--- a/.github/workflows/check_tpch_queries.yml
+++ b/.github/workflows/check_tpch_queries.yml
@@ -25,7 +25,7 @@ jobs:
           cache-suffix: ${{ matrix.python-version }}
           cache-dependency-glob: "pyproject.toml"
       - name: local-install
-        run: uv pip install -e ".[dev, dask]" --system
+        run: uv pip install -e ".[dev, core, dask]" --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 d167d142..71b07d5f 100644
--- a/.github/workflows/extremes.yml
+++ b/.github/workflows/extremes.yml
@@ -41,7 +41,7 @@ jobs:
           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
+        run: pytest tests --cov=narwhals --cov=tests --cov-fail-under=50 --runslow --constructors=pandas,pyarrow,polars[eager],polars[lazy]
 
   pretty_old_versions:
     strategy:
@@ -60,7 +60,7 @@ jobs:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
           cache-dependency-glob: "pyproject.toml"
-      - name: install-minimum-versions
+      - name: install-pretty-old-versions
         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 -e ".[dev]" --system
@@ -79,7 +79,7 @@ jobs:
           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
+        run: pytest tests --cov=narwhals --cov=tests --cov-fail-under=50 --runslow --constructors=pandas,pyarrow,polars[eager],polars[lazy]
 
   not_so_old_versions:
     strategy:
@@ -116,7 +116,7 @@ jobs:
           echo "$DEPS" | grep 'scikit-learn==1.3.0'
           echo "$DEPS" | grep 'dask==2024.7'
       - name: Run pytest
-        run: pytest tests --cov=narwhals --cov=tests --cov-fail-under=50 --runslow
+        run: pytest tests --cov=narwhals --cov=tests --cov-fail-under=50 --runslow --constructors=pandas,pyarrow,polars[eager],polars[lazy],dask
 
   nightlies:
     strategy:
@@ -179,4 +179,6 @@ jobs:
           echo "$DEPS" | grep 'numpy'
           echo "$DEPS" | grep 'dask'
       - name: Run pytest
-        run: pytest tests --cov=narwhals --cov=tests --cov-fail-under=50 --runslow
+        run: |
+          pytest tests --cov=narwhals --cov=tests --cov-fail-under=50 --runslow \
+          --constructors=pandas,pandas[nullable],pandas[pyarrow],pyarrow,polars[eager],polars[lazy],dask
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7083129a..b61ed663 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -96,8 +96,9 @@ 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 ".[dev, docs]"`. If you also want to test Dask , PySpark, and
-   Modin, you can install them too with `uv pip install -e ".[dev, docs, extra]"`.
+2. Install Narwhals: `uv pip install -e ".[dev, core, docs]"`. This will include fast-ish core libraries.
+   If you also want to test other libraries like Dask , PySpark, and Modin, you can install them too with
+   `uv pip install -e ".[dev, core, docs, dask, pyspark, modin]"`.
 3. Install a fork of griffe:
    ```
    uv pip install git+https://github.com/MarcoGorelli/griffe.git@no-overloads
@@ -131,6 +132,10 @@ If you add code that should be tested, please add tests.
 - To run tests on the doctests, use `pytest narwhals --doctest-modules`
 - To run unit tests and doctests at the same time, run `pytest tests narwhals --cov=narwhals --doctest-modules`
 - To run tests multiprocessed, you may also want to use [pytest-xdist](https://github.com/pytest-dev/pytest-xdist) (optional)
+- To choose which backends to run tests with you, you can use the `--constructors` flag:
+  - to only run tests for pandas, Polars, and PyArrow, use `pytest --constructors=pandas,pyarrow,polars`
+  - to run tests for all CPU constructors, use `pytest --all-cpu-constructors`
+  - by default, tests run for pandas, pandas (PyArrow dtypes), PyArrow, and Polars.
 
 If you want to have less surprises when opening a PR, you can take advantage of [nox](https://nox.thea.codes/en/stable/index.html) to run the entire CI/CD test suite locally in your operating system.
 
diff --git a/narwhals/_arrow/dataframe.py b/narwhals/_arrow/dataframe.py
index b51cba7c..c2898f20 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -655,7 +655,7 @@ class ArrowDataFrame(CompliantDataFrame, CompliantLazyFrame):
         subset: list[str] | None,
         *,
         keep: Literal["any", "first", "last", "none"],
-        maintain_order: bool,
+        maintain_order: bool = False,
     ) -> Self:
         # The param `maintain_order` is only here for compatibility with the Polars API
         # and has no effect on the output.
diff --git a/narwhals/_dask/dataframe.py b/narwhals/_dask/dataframe.py
index 0edf4121..36d85bdb 100644
--- a/narwhals/_dask/dataframe.py
+++ b/narwhals/_dask/dataframe.py
@@ -200,11 +200,8 @@ class DaskLazyFrame(CompliantLazyFrame):
         self: Self,
         subset: list[str] | None,
         *,
-        keep: Literal["any", "first", "last", "none"] = "any",
-        maintain_order: bool = False,
+        keep: Literal["any", "none"] = "any",
     ) -> Self:
-        # The param `maintain_order` is only here for compatibility with the Polars API
-        # and has no effect on the output.
         native_frame = self._native_frame
         if keep == "none":
             subset = subset or self.columns
diff --git a/narwhals/_dask/expr.py b/narwhals/_dask/expr.py
index a194c106..8da2d422 100644
--- a/narwhals/_dask/expr.py
+++ b/narwhals/_dask/expr.py
@@ -9,6 +9,7 @@ from typing import NoReturn
 from typing import Sequence
 
 from narwhals._dask.utils import add_row_index
+from narwhals._dask.utils import binary_operation_returns_scalar
 from narwhals._dask.utils import maybe_evaluate
 from narwhals._dask.utils import narwhals_to_native_dtype
 from narwhals._pandas_like.utils import calculate_timestamp_date
@@ -136,10 +137,13 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             inputs = self._call(df)
             _kwargs = {key: maybe_evaluate(df, value) for key, value in kwargs.items()}
             for _input in inputs:
+                name = _input.name
+                if self._returns_scalar:
+                    _input = _input[0]
                 result = call(_input, **_kwargs)
                 if returns_scalar:
                     result = result.to_series()
-                result = result.rename(_input.name)
+                result = result.rename(name)
                 results.append(result)
             return results
 
@@ -174,7 +178,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             function_name=f"{self._function_name}->{expr_name}",
             root_names=root_names,
             output_names=output_names,
-            returns_scalar=self._returns_scalar or returns_scalar,
+            returns_scalar=returns_scalar,
             backend_version=self._backend_version,
             version=self._version,
             kwargs={**self._kwargs, **kwargs},
@@ -202,7 +206,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lambda _input, other: _input.__add__(other),
             "__add__",
             other=other,
-            returns_scalar=False,
+            returns_scalar=binary_operation_returns_scalar(self, other),
         )
 
     def __radd__(self, other: Any) -> Self:
@@ -210,7 +214,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lambda _input, other: _input.__radd__(other),
             "__radd__",
             other=other,
-            returns_scalar=False,
+            returns_scalar=binary_operation_returns_scalar(self, other),
         ).alias("literal")
 
     def __sub__(self, other: Any) -> Self:
@@ -218,7 +222,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lambda _input, other: _input.__sub__(other),
             "__sub__",
             other=other,
-            returns_scalar=False,
+            returns_scalar=binary_operation_returns_scalar(self, other),
         )
 
     def __rsub__(self, other: Any) -> Self:
@@ -226,7 +230,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lambda _input, other: _input.__rsub__(other),
             "__rsub__",
             other=other,
-            returns_scalar=False,
+            returns_scalar=binary_operation_returns_scalar(self, other),
         ).alias("literal")
 
     def __mul__(self, other: Any) -> Self:
@@ -234,7 +238,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lambda _input, other: _input.__mul__(other),
             "__mul__",
             other=other,
-            returns_scalar=False,
+            returns_scalar=binary_operation_returns_scalar(self, other),
         )
 
     def __rmul__(self, other: Any) -> Self:
@@ -242,7 +246,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lambda _input, other: _input.__rmul__(other),
             "__rmul__",
             other=other,
-            returns_scalar=False,
+            returns_scalar=binary_operation_returns_scalar(self, other),
         ).alias("literal")
 
     def __truediv__(self, other: Any) -> Self:
@@ -250,7 +254,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lambda _input, other: _input.__truediv__(other),
             "__truediv__",
             other=other,
-            returns_scalar=False,
+            returns_scalar=binary_operation_returns_scalar(self, other),
         )
 
     def __rtruediv__(self, other: Any) -> Self:
@@ -258,7 +262,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lambda _input, other: _input.__rtruediv__(other),
             "__rtruediv__",
             other=other,
-            returns_scalar=False,
+            returns_scalar=binary_operation_returns_scalar(self, other),
         ).alias("literal")
 
     def __floordiv__(self, other: Any) -> Self:
@@ -266,7 +270,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lambda _input, other: _input.__floordiv__(other),
             "__floordiv__",
             other=other,
-            returns_scalar=False,
+            returns_scalar=binary_operation_returns_scalar(self, other),
         )
 
     def __rfloordiv__(self, other: Any) -> Self:
@@ -274,7 +278,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lambda _input, other: _input.__rfloordiv__(other),
             "__rfloordiv__",
             other=other,
-            returns_scalar=False,
+            returns_scalar=binary_operation_returns_scalar(self, other),
         ).alias("literal")
 
     def __pow__(self, other: Any) -> Self:
@@ -282,7 +286,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lambda _input, other: _input.__pow__(other),
             "__pow__",
             other=other,
-            returns_scalar=False,
+            returns_scalar=binary_operation_returns_scalar(self, other),
         )
 
     def __rpow__(self, other: Any) -> Self:
@@ -290,7 +294,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lambda _input, other: _input.__rpow__(other),
             "__rpow__",
             other=other,
-            returns_scalar=False,
+            returns_scalar=binary_operation_returns_scalar(self, other),
         ).alias("literal")
 
     def __mod__(self, other: Any) -> Self:
@@ -298,7 +302,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lambda _input, other: _input.__mod__(other),
             "__mod__",
             other=other,
-            returns_scalar=False,
+            returns_scalar=binary_operation_returns_scalar(self, other),
         )
 
     def __rmod__(self, other: Any) -> Self:
@@ -306,7 +310,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lambda _input, other: _input.__rmod__(other),
             "__rmod__",
             other=other,
-            returns_scalar=False,
+            returns_scalar=binary_operation_returns_scalar(self, other),
         ).alias("literal")
 
     def __eq__(self, other: DaskExpr) -> Self:  # type: ignore[override]
@@ -314,7 +318,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lambda _input, other: _input.__eq__(other),
             "__eq__",
             other=other,
-            returns_scalar=False,
+            returns_scalar=binary_operation_returns_scalar(self, other),
         )
 
     def __ne__(self, other: DaskExpr) -> Self:  # type: ignore[override]
@@ -322,7 +326,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lambda _input, other: _input.__ne__(other),
             "__ne__",
             other=other,
-            returns_scalar=False,
+            returns_scalar=binary_operation_returns_scalar(self, other),
         )
 
     def __ge__(self, other: DaskExpr) -> Self:
@@ -330,7 +334,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lambda _input, other: _input.__ge__(other),
             "__ge__",
             other=other,
-            returns_scalar=False,
+            returns_scalar=binary_operation_returns_scalar(self, other),
         )
 
     def __gt__(self, other: DaskExpr) -> Self:
@@ -338,7 +342,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lambda _input, other: _input.__gt__(other),
             "__gt__",
             other=other,
-            returns_scalar=False,
+            returns_scalar=binary_operation_returns_scalar(self, other),
         )
 
     def __le__(self, other: DaskExpr) -> Self:
@@ -346,7 +350,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lambda _input, other: _input.__le__(other),
             "__le__",
             other=other,
-            returns_scalar=False,
+            returns_scalar=binary_operation_returns_scalar(self, other),
         )
 
     def __lt__(self, other: DaskExpr) -> Self:
@@ -354,7 +358,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lambda _input, other: _input.__lt__(other),
             "__lt__",
             other=other,
-            returns_scalar=False,
+            returns_scalar=binary_operation_returns_scalar(self, other),
         )
 
     def __and__(self, other: DaskExpr) -> Self:
@@ -362,7 +366,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lambda _input, other: _input.__and__(other),
             "__and__",
             other=other,
-            returns_scalar=False,
+            returns_scalar=binary_operation_returns_scalar(self, other),
         )
 
     def __rand__(self, other: DaskExpr) -> Self:
@@ -370,7 +374,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lambda _input, other: _input.__rand__(other),
             "__rand__",
             other=other,
-            returns_scalar=False,
+            returns_scalar=binary_operation_returns_scalar(self, other),
         ).alias("literal")
 
     def __or__(self, other: DaskExpr) -> Self:
@@ -378,7 +382,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lambda _input, other: _input.__or__(other),
             "__or__",
             other=other,
-            returns_scalar=False,
+            returns_scalar=binary_operation_returns_scalar(self, other),
         )
 
     def __ror__(self, other: DaskExpr) -> Self:
@@ -386,14 +390,14 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lambda _input, other: _input.__ror__(other),
             "__ror__",
             other=other,
-            returns_scalar=False,
+            returns_scalar=binary_operation_returns_scalar(self, other),
         ).alias("literal")
 
     def __invert__(self: Self) -> Self:
         return self._from_call(
             lambda _input: _input.__invert__(),
             "__invert__",
-            returns_scalar=False,
+            returns_scalar=self._returns_scalar,
         )
 
     def map_batches(
@@ -471,7 +475,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lambda _input, n: _input.shift(n),
             "shift",
             n=n,
-            returns_scalar=False,
+            returns_scalar=self._returns_scalar,
         )
 
     def cum_sum(self: Self, *, reverse: bool) -> Self:
@@ -482,7 +486,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
         return self._from_call(
             lambda _input: _input.cumsum(),
             "cum_sum",
-            returns_scalar=False,
+            returns_scalar=self._returns_scalar,
         )
 
     def cum_count(self: Self, *, reverse: bool) -> Self:
@@ -493,7 +497,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
         return self._from_call(
             lambda _input: (~_input.isna()).astype(int).cumsum(),
             "cum_count",
-            returns_scalar=False,
+            returns_scalar=self._returns_scalar,
         )
 
     def cum_min(self: Self, *, reverse: bool) -> Self:
@@ -504,7 +508,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
         return self._from_call(
             lambda _input: _input.cummin(),
             "cum_min",
-            returns_scalar=False,
+            returns_scalar=self._returns_scalar,
         )
 
     def cum_max(self: Self, *, reverse: bool) -> Self:
@@ -515,7 +519,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
         return self._from_call(
             lambda _input: _input.cummax(),
             "cum_max",
-            returns_scalar=False,
+            returns_scalar=self._returns_scalar,
         )
 
     def cum_prod(self: Self, *, reverse: bool) -> Self:
@@ -526,7 +530,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
         return self._from_call(
             lambda _input: _input.cumprod(),
             "cum_prod",
-            returns_scalar=False,
+            returns_scalar=self._returns_scalar,
         )
 
     def is_between(
@@ -547,7 +551,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lower_bound=lower_bound,
             upper_bound=upper_bound,
             closed=closed,
-            returns_scalar=False,
+            returns_scalar=self._returns_scalar,
         )
 
     def sum(self) -> Self:
@@ -569,7 +573,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lambda _input, decimals: _input.round(decimals),
             "round",
             decimals=decimals,
-            returns_scalar=False,
+            returns_scalar=self._returns_scalar,
         )
 
     def ewm_mean(
@@ -616,7 +620,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
         return self._from_call(
             lambda _input: _input.abs(),
             "abs",
-            returns_scalar=False,
+            returns_scalar=self._returns_scalar,
         )
 
     def all(self) -> Self:
@@ -663,7 +667,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             value=value,
             strategy=strategy,
             limit=limit,
-            returns_scalar=False,
+            returns_scalar=self._returns_scalar,
         )
 
     def clip(
@@ -678,14 +682,14 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             "clip",
             lower_bound=lower_bound,
             upper_bound=upper_bound,
-            returns_scalar=False,
+            returns_scalar=self._returns_scalar,
         )
 
     def diff(self: Self) -> Self:
         return self._from_call(
             lambda _input: _input.diff(),
             "diff",
-            returns_scalar=False,
+            returns_scalar=self._returns_scalar,
         )
 
     def n_unique(self: Self) -> Self:
@@ -699,7 +703,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
         return self._from_call(
             lambda _input: _input.isna(),
             "is_null",
-            returns_scalar=False,
+            returns_scalar=self._returns_scalar,
         )
 
     def len(self: Self) -> Self:
@@ -746,7 +750,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
         return self._from_call(
             func,
             "is_first_distinct",
-            returns_scalar=False,
+            returns_scalar=self._returns_scalar,
         )
 
     def is_last_distinct(self: Self) -> Self:
@@ -761,7 +765,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
         return self._from_call(
             func,
             "is_last_distinct",
-            returns_scalar=False,
+            returns_scalar=self._returns_scalar,
         )
 
     def is_duplicated(self: Self) -> Self:
@@ -777,7 +781,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
         return self._from_call(
             func,
             "is_duplicated",
-            returns_scalar=False,
+            returns_scalar=self._returns_scalar,
         )
 
     def is_unique(self: Self) -> Self:
@@ -793,7 +797,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
         return self._from_call(
             func,
             "is_unique",
-            returns_scalar=False,
+            returns_scalar=self._returns_scalar,
         )
 
     def is_in(self: Self, other: Any) -> Self:
@@ -801,7 +805,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             lambda _input, other: _input.isin(other),
             "is_in",
             other=other,
-            returns_scalar=False,
+            returns_scalar=self._returns_scalar,
         )
 
     def null_count(self: Self) -> Self:
@@ -884,7 +888,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             func,
             "cast",
             dtype=dtype,
-            returns_scalar=False,
+            returns_scalar=self._returns_scalar,
         )
 
     def is_finite(self: Self) -> Self:
@@ -893,7 +897,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
         return self._from_call(
             lambda _input: da.isfinite(_input),
             "is_finite",
-            returns_scalar=False,
+            returns_scalar=self._returns_scalar,
         )
 
     def rolling_sum(
@@ -919,7 +923,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             window_size=window_size,
             min_periods=min_periods,
             center=center,
-            returns_scalar=False,
+            returns_scalar=self._returns_scalar,
         )
 
     def rolling_mean(
@@ -945,7 +949,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             window_size=window_size,
             min_periods=min_periods,
             center=center,
-            returns_scalar=False,
+            returns_scalar=self._returns_scalar,
         )
 
     def rolling_var(
@@ -974,7 +978,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             min_periods=min_periods,
             center=center,
             ddof=ddof,
-            returns_scalar=False,
+            returns_scalar=self._returns_scalar,
         )
 
     def rolling_std(
@@ -1003,7 +1007,7 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             min_periods=min_periods,
             center=center,
             ddof=ddof,
-            returns_scalar=False,
+            returns_scalar=self._returns_scalar,
         )
 
 
@@ -1013,7 +1017,9 @@ class DaskExprStringNamespace:
 
     def len_chars(self) -> DaskExpr:
         return self._compliant_expr._from_call(
-            lambda _input: _input.str.len(), "len", returns_scalar=False
+            lambda _input: _input.str.len(),
+            "len",
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def replace(
@@ -1033,7 +1039,7 @@ class DaskExprStringNamespace:
             value=value,
             literal=literal,
             n=n,
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def replace_all(
@@ -1051,7 +1057,7 @@ class DaskExprStringNamespace:
             pattern=pattern,
             value=value,
             literal=literal,
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def strip_chars(self, characters: str | None = None) -> DaskExpr:
@@ -1059,7 +1065,7 @@ class DaskExprStringNamespace:
             lambda _input, characters: _input.str.strip(characters),
             "strip",
             characters=characters,
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def starts_with(self, prefix: str) -> DaskExpr:
@@ -1067,7 +1073,7 @@ class DaskExprStringNamespace:
             lambda _input, prefix: _input.str.startswith(prefix),
             "starts_with",
             prefix=prefix,
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def ends_with(self, suffix: str) -> DaskExpr:
@@ -1075,7 +1081,7 @@ class DaskExprStringNamespace:
             lambda _input, suffix: _input.str.endswith(suffix),
             "ends_with",
             suffix=suffix,
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def contains(self, pattern: str, *, literal: bool = False) -> DaskExpr:
@@ -1086,7 +1092,7 @@ class DaskExprStringNamespace:
             "contains",
             pattern=pattern,
             literal=literal,
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def slice(self, offset: int, length: int | None = None) -> DaskExpr:
@@ -1097,7 +1103,7 @@ class DaskExprStringNamespace:
             "slice",
             offset=offset,
             length=length,
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def to_datetime(self: Self, format: str | None) -> DaskExpr:  # noqa: A002
@@ -1107,21 +1113,21 @@ class DaskExprStringNamespace:
             lambda _input, format: dd.to_datetime(_input, format=format),
             "to_datetime",
             format=format,
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def to_uppercase(self) -> DaskExpr:
         return self._compliant_expr._from_call(
             lambda _input: _input.str.upper(),
             "to_uppercase",
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def to_lowercase(self) -> DaskExpr:
         return self._compliant_expr._from_call(
             lambda _input: _input.str.lower(),
             "to_lowercase",
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
 
@@ -1133,77 +1139,77 @@ class DaskExprDateTimeNamespace:
         return self._compliant_expr._from_call(
             lambda _input: _input.dt.date,
             "date",
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def year(self) -> DaskExpr:
         return self._compliant_expr._from_call(
             lambda _input: _input.dt.year,
             "year",
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def month(self) -> DaskExpr:
         return self._compliant_expr._from_call(
             lambda _input: _input.dt.month,
             "month",
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def day(self) -> DaskExpr:
         return self._compliant_expr._from_call(
             lambda _input: _input.dt.day,
             "day",
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def hour(self) -> DaskExpr:
         return self._compliant_expr._from_call(
             lambda _input: _input.dt.hour,
             "hour",
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def minute(self) -> DaskExpr:
         return self._compliant_expr._from_call(
             lambda _input: _input.dt.minute,
             "minute",
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def second(self) -> DaskExpr:
         return self._compliant_expr._from_call(
             lambda _input: _input.dt.second,
             "second",
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def millisecond(self) -> DaskExpr:
         return self._compliant_expr._from_call(
             lambda _input: _input.dt.microsecond // 1000,
             "millisecond",
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def microsecond(self) -> DaskExpr:
         return self._compliant_expr._from_call(
             lambda _input: _input.dt.microsecond,
             "microsecond",
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def nanosecond(self) -> DaskExpr:
         return self._compliant_expr._from_call(
             lambda _input: _input.dt.microsecond * 1000 + _input.dt.nanosecond,
             "nanosecond",
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def ordinal_day(self) -> DaskExpr:
         return self._compliant_expr._from_call(
             lambda _input: _input.dt.dayofyear,
             "ordinal_day",
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def to_string(self, format: str) -> DaskExpr:  # noqa: A002
@@ -1211,7 +1217,7 @@ class DaskExprDateTimeNamespace:
             lambda _input, format: _input.dt.strftime(format.replace("%.f", ".%f")),
             "strftime",
             format=format,
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def replace_time_zone(self, time_zone: str | None) -> DaskExpr:
@@ -1223,7 +1229,7 @@ class DaskExprDateTimeNamespace:
             else _input.dt.tz_localize(None),
             "tz_localize",
             time_zone=time_zone,
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def convert_time_zone(self, time_zone: str) -> DaskExpr:
@@ -1240,7 +1246,7 @@ class DaskExprDateTimeNamespace:
             func,
             "tz_convert",
             time_zone=time_zone,
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def timestamp(self, time_unit: Literal["ns", "us", "ms"] = "us") -> DaskExpr:
@@ -1274,42 +1280,42 @@ class DaskExprDateTimeNamespace:
             func,
             "datetime",
             time_unit=time_unit,
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def total_minutes(self) -> DaskExpr:
         return self._compliant_expr._from_call(
             lambda _input: _input.dt.total_seconds() // 60,
             "total_minutes",
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def total_seconds(self) -> DaskExpr:
         return self._compliant_expr._from_call(
             lambda _input: _input.dt.total_seconds() // 1,
             "total_seconds",
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def total_milliseconds(self) -> DaskExpr:
         return self._compliant_expr._from_call(
             lambda _input: _input.dt.total_seconds() * 1000 // 1,
             "total_milliseconds",
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def total_microseconds(self) -> DaskExpr:
         return self._compliant_expr._from_call(
             lambda _input: _input.dt.total_seconds() * 1_000_000 // 1,
             "total_microseconds",
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
     def total_nanoseconds(self) -> DaskExpr:
         return self._compliant_expr._from_call(
             lambda _input: _input.dt.total_seconds() * 1_000_000_000 // 1,
             "total_nanoseconds",
-            returns_scalar=False,
+            returns_scalar=self._compliant_expr._returns_scalar,
         )
 
 
diff --git a/narwhals/_dask/utils.py b/narwhals/_dask/utils.py
index 86a0e519..71e17b3f 100644
--- a/narwhals/_dask/utils.py
+++ b/narwhals/_dask/utils.py
@@ -15,6 +15,7 @@ if TYPE_CHECKING:
     import dask_expr
 
     from narwhals._dask.dataframe import DaskLazyFrame
+    from narwhals._dask.expr import DaskExpr
     from narwhals.dtypes import DType
     from narwhals.utils import Version
 
@@ -143,3 +144,10 @@ def name_preserving_sum(s1: dask_expr.Series, s2: dask_expr.Series) -> dask_expr
 
 def name_preserving_div(s1: dask_expr.Series, s2: dask_expr.Series) -> dask_expr.Series:
     return (s1 / s2).rename(s1.name)
+
+
+def binary_operation_returns_scalar(lhs: DaskExpr, rhs: DaskExpr | Any) -> bool:
+    # If `rhs` is a DaskExpr, we look at `_returns_scalar`. If it isn't,
+    # it means that it was a scalar (e.g. nw.col('a') + 1), and so we default
+    # to `True`.
+    return lhs._returns_scalar and getattr(rhs, "_returns_scalar", True)
diff --git a/narwhals/_pandas_like/group_by.py b/narwhals/_pandas_like/group_by.py
index ae897ee9..3741c713 100644
--- a/narwhals/_pandas_like/group_by.py
+++ b/narwhals/_pandas_like/group_by.py
@@ -18,7 +18,6 @@ from narwhals._pandas_like.utils import set_columns
 from narwhals.utils import Implementation
 from narwhals.utils import find_stacklevel
 from narwhals.utils import remove_prefix
-from narwhals.utils import tupleify
 
 if TYPE_CHECKING:
     from narwhals._pandas_like.dataframe import PandasLikeDataFrame
@@ -124,20 +123,14 @@ class PandasLikeGroupBy:
         )
 
     def __iter__(self) -> Iterator[tuple[Any, PandasLikeDataFrame]]:
-        indices = self._grouped.indices
-        if (
-            self._df._implementation is Implementation.PANDAS
-            and self._df._backend_version < (2, 2)
-        ) or (
-            self._df._implementation is Implementation.CUDF
-            and self._df._backend_version < (2024, 12)
-        ):  # pragma: no cover
-            for key in indices:
-                yield (key, self._from_native_frame(self._grouped.get_group(key)))
-        else:
-            for key in indices:
-                key = tupleify(key)  # noqa: PLW2901
-                yield (key, self._from_native_frame(self._grouped.get_group(key)))
+        with warnings.catch_warnings():
+            warnings.filterwarnings(
+                "ignore",
+                message=".*a length 1 tuple will be returned",
+                category=FutureWarning,
+            )
+            for key, group in self._grouped:
+                yield (key, self._from_native_frame(group))
 
 
 def agg_pandas(  # noqa: PLR0915
diff --git a/narwhals/_pandas_like/utils.py b/narwhals/_pandas_like/utils.py
index 0bb7edf8..8d67aa65 100644
--- a/narwhals/_pandas_like/utils.py
+++ b/narwhals/_pandas_like/utils.py
@@ -481,7 +481,7 @@ def native_to_narwhals_dtype(
 
 
 def get_dtype_backend(dtype: Any, implementation: Implementation) -> str:
-    if implementation is Implementation.PANDAS:
+    if implementation in [Implementation.PANDAS, Implementation.MODIN]:
         import pandas as pd
 
         if hasattr(pd, "ArrowDtype") and isinstance(dtype, pd.ArrowDtype):
diff --git a/narwhals/_spark_like/dataframe.py b/narwhals/_spark_like/dataframe.py
index ba57da5b..e57f9e1b 100644
--- a/narwhals/_spark_like/dataframe.py
+++ b/narwhals/_spark_like/dataframe.py
@@ -203,11 +203,8 @@ class SparkLikeLazyFrame:
         self: Self,
         subset: str | list[str] | None = None,
         *,
-        keep: Literal["any", "first", "last", "none"],
-        maintain_order: bool,
+        keep: Literal["any", "none"],
     ) -> Self:
-        # The param `maintain_order` is only here for compatibility with the Polars API
-        # and has no effect on the output.
         if keep != "any":
             msg = "`LazyFrame.unique` with PySpark backend only supports `keep='any'`."
             raise ValueError(msg)
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index 4bc706e4..b508ded1 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -146,21 +146,6 @@ class BaseFrame(Generic[FrameT]):
             self._compliant_frame.drop(columns, strict=strict)
         )
 
-    def unique(
-        self,
-        subset: str | list[str] | None = None,
-        *,
-        keep: Literal["any", "first", "last", "none"] = "any",
-        maintain_order: bool = False,
-    ) -> Self:
-        if isinstance(subset, str):
-            subset = [subset]
-        return self._from_compliant_dataframe(
-            self._compliant_frame.unique(
-                subset=subset, keep=keep, maintain_order=maintain_order
-            )
-        )
-
     def filter(
         self, *predicates: IntoExpr | Iterable[IntoExpr] | list[bool], **constraints: Any
     ) -> Self:
@@ -400,9 +385,8 @@ class DataFrame(BaseFrame[DataFrameT]):
     def implementation(self) -> Implementation:
         """Return implementation of native frame.
 
-        This can be useful when you need to some special-casing for
-        some libraries for features outside of Narwhals' scope - for
-        example, when dealing with pandas' Period Dtype.
+        This can be useful when you need to use special-casing for features outside of
+        Narwhals' scope - for example, when dealing with pandas' Period Dtype.
 
         Returns:
             Implementation.
@@ -423,8 +407,8 @@ class DataFrame(BaseFrame[DataFrameT]):
         """
         return self._compliant_frame._implementation  # type: ignore[no-any-return]
 
-    def __len__(self) -> Any:
-        return self._compliant_frame.__len__()
+    def __len__(self) -> int:
+        return self._compliant_frame.__len__()  # type: ignore[no-any-return]
 
     def __array__(self, dtype: Any = None, copy: bool | None = None) -> np.ndarray:
         return self._compliant_frame.__array__(dtype, copy=copy)
@@ -482,11 +466,10 @@ class DataFrame(BaseFrame[DataFrameT]):
             >>> import pyarrow as pa
             >>> import narwhals as nw
             >>> from narwhals.typing import IntoFrame
-            >>>
-            >>> df = {"foo": [1, 2, 3], "bar": [6.0, 7.0, 8.0], "ham": ["a", "b", "c"]}
-            >>> df_pd = pd.DataFrame(df)
-            >>> df_pl = pl.DataFrame(df)
-            >>> df_pa = pa.table(df)
+            >>> data = {"foo": [1, 2, 3], "bar": [6.0, 7.0, 8.0], "ham": ["a", "b", "c"]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             We define a library agnostic function:
 
@@ -494,7 +477,8 @@ class DataFrame(BaseFrame[DataFrameT]):
             ...     df = nw.from_native(df_native)
             ...     return df.lazy().to_native()
 
-            Note that then, pandas and pyarrow dataframe stay eager, but Polars DataFrame becomes a Polars LazyFrame:
+            Note that then, pandas and pyarrow dataframe stay eager, but Polars DataFrame
+            becomes a Polars LazyFrame:
 
             >>> agnostic_lazy(df_pd)
                foo  bar ham
@@ -575,11 +559,10 @@ class DataFrame(BaseFrame[DataFrameT]):
             >>> import pyarrow as pa
             >>> import narwhals as nw
             >>> from narwhals.typing import IntoDataFrame
-            >>>
-            >>> df = {"foo": [1, 2, 3], "bar": [6.0, 7.0, 8.0], "ham": ["a", "b", "c"]}
-            >>> df_pd = pd.DataFrame(df)
-            >>> df_pl = pl.DataFrame(df)
-            >>> df_pa = pa.table(df)
+            >>> data = {"foo": [1, 2, 3], "bar": [6.0, 7.0, 8.0], "ham": ["a", "b", "c"]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             We define a library agnostic function:
 
@@ -587,7 +570,8 @@ class DataFrame(BaseFrame[DataFrameT]):
             ...     df = nw.from_native(df_native)
             ...     return df.to_pandas()
 
-            We can then pass any supported library such as pandas, Polars (eager), or PyArrow to `agnostic_to_pandas`:
+            We can then pass any supported library such as pandas, Polars (eager), or
+            PyArrow to `agnostic_to_pandas`:
 
             >>> agnostic_to_pandas(df_pd)
                foo  bar ham
@@ -607,7 +591,13 @@ class DataFrame(BaseFrame[DataFrameT]):
         """
         return self._compliant_frame.to_pandas()
 
-    def write_csv(self, file: str | Path | BytesIO | None = None) -> Any:
+    @overload
+    def write_csv(self, file: None = None) -> str: ...
+
+    @overload
+    def write_csv(self, file: str | Path | BytesIO) -> None: ...
+
+    def write_csv(self, file: str | Path | BytesIO | None = None) -> str | None:
         r"""Write dataframe to comma-separated values (CSV) file.
 
         Arguments:
@@ -624,30 +614,31 @@ class DataFrame(BaseFrame[DataFrameT]):
             >>> import polars as pl
             >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> df = {"foo": [1, 2, 3], "bar": [6.0, 7.0, 8.0], "ham": ["a", "b", "c"]}
-            >>> df_pd = pd.DataFrame(df)
-            >>> df_pl = pl.DataFrame(df)
-            >>> df_pa = pa.table(df)
+            >>> from narwhals.typing import IntoDataFrame
+            >>> data = {"foo": [1, 2, 3], "bar": [6.0, 7.0, 8.0], "ham": ["a", "b", "c"]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             We define a library agnostic function:
 
-            >>> def func(df):
-            ...     df = nw.from_native(df)
+            >>> def agnostic_write_csv(df_native: IntoDataFrame) -> str:
+            ...     df = nw.from_native(df_native)
             ...     return df.write_csv()
 
-            We can pass any supported library such as pandas, Polars or PyArrow to `func`:
+            We can pass any supported library such as pandas, Polars or PyArrow to `agnostic_write_csv`:
 
-            >>> func(df_pd)
+            >>> agnostic_write_csv(df_pd)
             'foo,bar,ham\n1,6.0,a\n2,7.0,b\n3,8.0,c\n'
-            >>> func(df_pl)
+            >>> agnostic_write_csv(df_pl)
             'foo,bar,ham\n1,6.0,a\n2,7.0,b\n3,8.0,c\n'
-            >>> func(df_pa)
+            >>> agnostic_write_csv(df_pa)
             '"foo","bar","ham"\n1,6,"a"\n2,7,"b"\n3,8,"c"\n'
 
             If we had passed a file name to `write_csv`, it would have been
             written to that file.
         """
-        return self._compliant_frame.write_csv(file)
+        return self._compliant_frame.write_csv(file)  # type: ignore[no-any-return]
 
     def write_parquet(self, file: str | Path | BytesIO) -> None:
         """Write dataframe to parquet file.
@@ -666,22 +657,23 @@ class DataFrame(BaseFrame[DataFrameT]):
             >>> import polars as pl
             >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> df = {"foo": [1, 2, 3], "bar": [6.0, 7.0, 8.0], "ham": ["a", "b", "c"]}
-            >>> df_pd = pd.DataFrame(df)
-            >>> df_pl = pl.DataFrame(df)
-            >>> df_pa = pa.table(df)
+            >>> from narwhals.typing import IntoDataFrame
+            >>> data = {"foo": [1, 2, 3], "bar": [6.0, 7.0, 8.0], "ham": ["a", "b", "c"]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             We define a library agnostic function:
 
-            >>> def func(df):
-            ...     df = nw.from_native(df)
+            >>> def agnostic_write_parquet(df_native: IntoDataFrame):
+            ...     df = nw.from_native(df_native)
             ...     df.write_parquet("foo.parquet")
 
-            We can then pass either pandas, Polars or PyArrow to `func`:
+            We can then pass either pandas, Polars or PyArrow to `agnostic_write_parquet`:
 
-            >>> func(df_pd)  # doctest:+SKIP
-            >>> func(df_pl)  # doctest:+SKIP
-            >>> func(df_pa)  # doctest:+SKIP
+            >>> agnostic_write_parquet(df_pd)  # doctest:+SKIP
+            >>> agnostic_write_parquet(df_pl)  # doctest:+SKIP
+            >>> agnostic_write_parquet(df_pa)  # doctest:+SKIP
         """
         self._compliant_frame.write_parquet(file)
 
@@ -700,11 +692,10 @@ class DataFrame(BaseFrame[DataFrameT]):
             >>> import narwhals as nw
             >>> import numpy as np
             >>> from narwhals.typing import IntoDataFrame
-            >>>
-            >>> df = {"foo": [1, 2, 3], "bar": [6.5, 7.0, 8.5], "ham": ["a", "b", "c"]}
-            >>> df_pd = pd.DataFrame(df)
-            >>> df_pl = pl.DataFrame(df)
-            >>> df_pa = pa.table(df)
+            >>> data = {"foo": [1, 2, 3], "bar": [6.5, 7.0, 8.5], "ham": ["a", "b", "c"]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             We define a library agnostic function:
 
@@ -744,11 +735,10 @@ class DataFrame(BaseFrame[DataFrameT]):
             >>> import pyarrow as pa
             >>> import narwhals as nw
             >>> from narwhals.typing import IntoDataFrame
-            >>>
-            >>> df = {"foo": [1, 2, 3, 4, 5]}
-            >>> df_pd = pd.DataFrame(df)
-            >>> df_pl = pl.DataFrame(df)
-            >>> df_pa = pa.table(df)
+            >>> data = {"foo": [1, 2, 3, 4, 5]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             We define a library agnostic function:
 
@@ -786,13 +776,14 @@ class DataFrame(BaseFrame[DataFrameT]):
         Examples:
             >>> import pandas as pd
             >>> import polars as pl
+            >>> import pyarrow as pa
             >>> import narwhals as nw
             >>> from narwhals.typing import IntoDataFrame
             >>> from narwhals.typing import IntoSeries
-            >>>
             >>> data = {"a": [1, 2], "b": [3, 4]}
             >>> df_pd = pd.DataFrame(data)
             >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             We define a library agnostic function:
 
@@ -801,7 +792,7 @@ class DataFrame(BaseFrame[DataFrameT]):
             ...     name = df.columns[0]
             ...     return df.get_column(name).to_native()
 
-            We can then pass either pandas or Polars to `agnostic_get_column`:
+            We can then pass either pandas, Polars or PyArrow to `agnostic_get_column`:
 
             >>> agnostic_get_column(df_pd)
             0    1
@@ -814,6 +805,14 @@ class DataFrame(BaseFrame[DataFrameT]):
                 1
                 2
             ]
+            >>> agnostic_get_column(df_pa)  # doctest:+ELLIPSIS
+            <pyarrow.lib.ChunkedArray object at ...>
+            [
+              [
+                1,
+                2
+              ]
+            ]
         """
         return self._series(
             self._compliant_frame.get_column(name),
@@ -827,7 +826,7 @@ class DataFrame(BaseFrame[DataFrameT]):
 
         Arguments:
             unit: 'b', 'kb', 'mb', 'gb', 'tb', 'bytes', 'kilobytes', 'megabytes',
-                    'gigabytes', or 'terabytes'.
+                'gigabytes', or 'terabytes'.
 
         Returns:
             Integer or Float.
@@ -957,7 +956,6 @@ class DataFrame(BaseFrame[DataFrameT]):
             >>> import narwhals as nw
             >>> from narwhals.typing import IntoDataFrame
             >>> from narwhals.typing import IntoSeries
-            >>>
             >>> data = {"a": [1, 2], "b": [3, 4]}
             >>> df_pd = pd.DataFrame(data)
             >>> df_pl = pl.DataFrame(data)
@@ -1048,7 +1046,7 @@ class DataFrame(BaseFrame[DataFrameT]):
 
         Arguments:
             as_series: If set to true ``True``, then the values are Narwhals Series,
-                        otherwise the values are Any.
+                    otherwise the values are Any.
 
         Returns:
             A mapping from column name to values / Series.
@@ -1059,17 +1057,16 @@ class DataFrame(BaseFrame[DataFrameT]):
             >>> import pyarrow as pa
             >>> import narwhals as nw
             >>> from narwhals.typing import IntoDataFrame
-            >>>
-            >>> df = {
+            >>> data = {
             ...     "A": [1, 2, 3, 4, 5],
             ...     "fruits": ["banana", "banana", "apple", "apple", "banana"],
             ...     "B": [5, 4, 3, 2, 1],
             ...     "animals": ["beetle", "fly", "beetle", "beetle", "beetle"],
             ...     "optional": [28, 300, None, 2, -30],
             ... }
-            >>> df_pd = pd.DataFrame(df)
-            >>> df_pl = pl.DataFrame(df)
-            >>> df_pa = pa.table(df)
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             We define a library agnostic function:
 
@@ -1103,9 +1100,10 @@ class DataFrame(BaseFrame[DataFrameT]):
     def row(self, index: int) -> tuple[Any, ...]:
         """Get values at given row.
 
-        !!!note
+        !!! warning
             You should NEVER use this method to iterate over a DataFrame;
-            if you require row-iteration you should strongly prefer use of iter_rows() instead.
+            if you require row-iteration you should strongly prefer use of iter_rows()
+            instead.
 
         Arguments:
             index: Row number.
@@ -1120,25 +1118,27 @@ class DataFrame(BaseFrame[DataFrameT]):
             >>> import narwhals as nw
             >>> import pandas as pd
             >>> import polars as pl
+            >>> import pyarrow as pa
             >>> from narwhals.typing import IntoDataFrame
             >>> from typing import Any
-            >>>
             >>> data = {"a": [1, 2, 3], "b": [4, 5, 6]}
             >>> df_pd = pd.DataFrame(data)
             >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             Let's define a library-agnostic function to get the second row.
 
             >>> def agnostic_row(df_native: IntoDataFrame) -> tuple[Any, ...]:
-            ...     df = nw.from_native(df_native)
-            ...     return df.row(1)
+            ...     return nw.from_native(df_native).row(1)
 
-            We can then pass pandas / Polars / any other supported library:
+            We can then pass either pandas, Polars or PyArrow to `agnostic_row`:
 
             >>> agnostic_row(df_pd)
             (2, 5)
             >>> agnostic_row(df_pl)
             (2, 5)
+            >>> agnostic_row(df_pa)
+            (<pyarrow.Int64Scalar: 2>, <pyarrow.Int64Scalar: 5>)
         """
         return self._compliant_frame.row(index)  # type: ignore[no-any-return]
 
@@ -1155,14 +1155,15 @@ class DataFrame(BaseFrame[DataFrameT]):
             The original object with the function applied.
 
         Examples:
-            >>> import polars as pl
             >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
             >>> import narwhals as nw
             >>> from narwhals.typing import IntoFrameT
-            >>>
             >>> data = {"a": [1, 2, 3], "ba": [4, 5, 6]}
             >>> df_pd = pd.DataFrame(data)
             >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             Let's define a dataframe-agnostic function:
 
@@ -1174,7 +1175,7 @@ class DataFrame(BaseFrame[DataFrameT]):
             ...         ).to_native()
             ...     )
 
-            We can then pass either pandas or Polars:
+            We can then pass either pandas, Polars or PyArrow to `agnostic_pipe`:
 
             >>> agnostic_pipe(df_pd)
                a
@@ -1192,6 +1193,11 @@ class DataFrame(BaseFrame[DataFrameT]):
             │ 2   │
             │ 3   │
             └─────┘
+            >>> agnostic_pipe(df_pa)
+            pyarrow.Table
+            a: int64
+            ----
+            a: [[1,2,3]]
         """
         return super().pipe(function, *args, **kwargs)
 
@@ -1216,7 +1222,6 @@ class DataFrame(BaseFrame[DataFrameT]):
             >>> import pyarrow as pa
             >>> import narwhals as nw
             >>> from narwhals.typing import IntoFrameT
-            >>>
             >>> data = {"a": [1.0, 2.0, None], "ba": [1.0, None, 2.0]}
             >>> df_pd = pd.DataFrame(data)
             >>> df_pl = pl.DataFrame(data)
@@ -1228,7 +1233,8 @@ class DataFrame(BaseFrame[DataFrameT]):
             ...     df = nw.from_native(df_native)
             ...     return df.drop_nulls().to_native()
 
-            We can then pass any supported library such as Pandas, Polars, or PyArrow to `agnostic_drop_nulls`:
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_drop_nulls`:
 
             >>> agnostic_drop_nulls(df_pd)
                  a   ba
@@ -1264,14 +1270,15 @@ class DataFrame(BaseFrame[DataFrameT]):
         Examples:
             Construct pandas as polars DataFrames:
 
-            >>> import polars as pl
             >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
             >>> import narwhals as nw
             >>> from narwhals.typing import IntoFrameT
-            >>>
             >>> data = {"a": [1, 2, 3], "b": [4, 5, 6]}
             >>> df_pd = pd.DataFrame(data)
             >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             Let's define a dataframe-agnostic function:
 
@@ -1279,7 +1286,8 @@ class DataFrame(BaseFrame[DataFrameT]):
             ...     df = nw.from_native(df_native)
             ...     return df.with_row_index().to_native()
 
-            We can then pass either pandas or Polars:
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_with_row_index`:
 
             >>> agnostic_with_row_index(df_pd)
                index  a  b
@@ -1297,6 +1305,15 @@ class DataFrame(BaseFrame[DataFrameT]):
             │ 1     ┆ 2   ┆ 5   │
             │ 2     ┆ 3   ┆ 6   │
             └───────┴─────┴─────┘
+            >>> agnostic_with_row_index(df_pa)
+            pyarrow.Table
+            a: int64
+            b: int64
+            index: int64
+            ----
+            a: [[1,2,3]]
+            b: [[4,5,6]]
+            index: [[0,1,2]]
         """
         return super().with_row_index(name)
 
@@ -1308,12 +1325,12 @@ class DataFrame(BaseFrame[DataFrameT]):
             A Narwhals Schema object that displays the mapping of column names.
 
         Examples:
-            >>> import polars as pl
             >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
             >>> import narwhals as nw
             >>> from narwhals.schema import Schema
             >>> from narwhals.typing import IntoFrame
-            >>>
             >>> data = {
             ...     "foo": [1, 2, 3],
             ...     "bar": [6.0, 7.0, 8.0],
@@ -1321,6 +1338,7 @@ class DataFrame(BaseFrame[DataFrameT]):
             ... }
             >>> df_pd = pd.DataFrame(data)
             >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             We define a library agnostic function:
 
@@ -1328,14 +1346,14 @@ class DataFrame(BaseFrame[DataFrameT]):
             ...     df = nw.from_native(df_native)
             ...     return df.schema
 
-            You can pass either pandas or Polars to `agnostic_schema`:
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_schema`:
 
-            >>> df_pd_schema = agnostic_schema(df_pd)
-            >>> df_pd_schema
+            >>> agnostic_schema(df_pd)
             Schema({'foo': Int64, 'bar': Float64, 'ham': String})
-
-            >>> df_pl_schema = agnostic_schema(df_pl)
-            >>> df_pl_schema
+            >>> agnostic_schema(df_pl)
+            Schema({'foo': Int64, 'bar': Float64, 'ham': String})
+            >>> agnostic_schema(df_pa)
             Schema({'foo': Int64, 'bar': Float64, 'ham': String})
         """
         return super().schema
@@ -1347,12 +1365,12 @@ class DataFrame(BaseFrame[DataFrameT]):
             A Narwhals Schema object that displays the mapping of column names.
 
         Examples:
-            >>> import polars as pl
             >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
             >>> import narwhals as nw
             >>> from narwhals.schema import Schema
             >>> from narwhals.typing import IntoFrame
-            >>>
             >>> data = {
             ...     "foo": [1, 2, 3],
             ...     "bar": [6.0, 7.0, 8.0],
@@ -1360,6 +1378,7 @@ class DataFrame(BaseFrame[DataFrameT]):
             ... }
             >>> df_pd = pd.DataFrame(data)
             >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             We define a library agnostic function:
 
@@ -1367,14 +1386,14 @@ class DataFrame(BaseFrame[DataFrameT]):
             ...     df = nw.from_native(df_native)
             ...     return df.collect_schema()
 
-            You can pass either pandas or Polars to `agnostic_collect_schema`:
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_collect_schema`:
 
-            >>> df_pd_schema = agnostic_collect_schema(df_pd)
-            >>> df_pd_schema
+            >>> agnostic_collect_schema(df_pd)
             Schema({'foo': Int64, 'bar': Float64, 'ham': String})
-
-            >>> df_pl_schema = agnostic_collect_schema(df_pl)
-            >>> df_pl_schema
+            >>> agnostic_collect_schema(df_pl)
+            Schema({'foo': Int64, 'bar': Float64, 'ham': String})
+            >>> agnostic_collect_schema(df_pa)
             Schema({'foo': Int64, 'bar': Float64, 'ham': String})
         """
         return super().collect_schema()
@@ -1392,11 +1411,10 @@ class DataFrame(BaseFrame[DataFrameT]):
             >>> import pyarrow as pa
             >>> import narwhals as nw
             >>> from narwhals.typing import IntoFrame
-            >>>
-            >>> df = {"foo": [1, 2, 3], "bar": [6.0, 7.0, 8.0], "ham": ["a", "b", "c"]}
-            >>> df_pd = pd.DataFrame(df)
-            >>> df_pl = pl.DataFrame(df)
-            >>> df_pa = pa.table(df)
+            >>> data = {"foo": [1, 2, 3], "bar": [6.0, 7.0, 8.0], "ham": ["a", "b", "c"]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             We define a library agnostic function:
 
@@ -1404,7 +1422,8 @@ class DataFrame(BaseFrame[DataFrameT]):
             ...     df = nw.from_native(df_native)
             ...     return df.columns
 
-            We can pass any supported library such as pandas, Polars, or PyArrow to `func`:
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_columns`:
 
             >>> agnostic_columns(df_pd)
             ['foo', 'bar', 'ham']
@@ -1416,28 +1435,16 @@ class DataFrame(BaseFrame[DataFrameT]):
         return super().columns
 
     @overload
-    def rows(
-        self,
-        *,
-        named: Literal[False] = False,
-    ) -> list[tuple[Any, ...]]: ...
+    def rows(self, *, named: Literal[False] = False) -> list[tuple[Any, ...]]: ...
+
     @overload
-    def rows(
-        self,
-        *,
-        named: Literal[True],
-    ) -> list[dict[str, Any]]: ...
+    def rows(self, *, named: Literal[True]) -> list[dict[str, Any]]: ...
+
     @overload
-    def rows(
-        self,
-        *,
-        named: bool,
-    ) -> list[tuple[Any, ...]] | list[dict[str, Any]]: ...
+    def rows(self, *, named: bool) -> list[tuple[Any, ...]] | list[dict[str, Any]]: ...
 
     def rows(
-        self,
-        *,
-        named: bool = False,
+        self, *, named: bool = False
     ) -> list[tuple[Any, ...]] | list[dict[str, Any]]:
         """Returns all data in the DataFrame as a list of rows of python-native values.
 
@@ -1452,26 +1459,33 @@ class DataFrame(BaseFrame[DataFrameT]):
         Examples:
             >>> import pandas as pd
             >>> import polars as pl
+            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> df = {"foo": [1, 2, 3], "bar": [6.0, 7.0, 8.0], "ham": ["a", "b", "c"]}
-            >>> df_pd = pd.DataFrame(df)
-            >>> df_pl = pl.DataFrame(df)
+            >>> from narwhals.typing import IntoDataFrame
+            >>> data = {"foo": [1, 2, 3], "bar": [6.0, 7.0, 8.0], "ham": ["a", "b", "c"]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             We define a library agnostic function:
 
-            >>> @nw.narwhalify
-            ... def func(df, *, named):
-            ...     return df.rows(named=named)
+            >>> def agnostic_rows(df_native: IntoDataFrame, *, named: bool):
+            ...     return nw.from_native(df_native, eager_only=True).rows(named=named)
 
-            We can then pass either pandas or Polars to `func`:
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_rows`:
 
-            >>> func(df_pd, named=False)
+            >>> agnostic_rows(df_pd, named=False)
+            [(1, 6.0, 'a'), (2, 7.0, 'b'), (3, 8.0, 'c')]
+            >>> agnostic_rows(df_pd, named=True)
+            [{'foo': 1, 'bar': 6.0, 'ham': 'a'}, {'foo': 2, 'bar': 7.0, 'ham': 'b'}, {'foo': 3, 'bar': 8.0, 'ham': 'c'}]
+            >>> agnostic_rows(df_pl, named=False)
             [(1, 6.0, 'a'), (2, 7.0, 'b'), (3, 8.0, 'c')]
-            >>> func(df_pd, named=True)
+            >>> agnostic_rows(df_pl, named=True)
             [{'foo': 1, 'bar': 6.0, 'ham': 'a'}, {'foo': 2, 'bar': 7.0, 'ham': 'b'}, {'foo': 3, 'bar': 8.0, 'ham': 'c'}]
-            >>> func(df_pl, named=False)
+            >>> agnostic_rows(df_pa, named=False)
             [(1, 6.0, 'a'), (2, 7.0, 'b'), (3, 8.0, 'c')]
-            >>> func(df_pl, named=True)
+            >>> agnostic_rows(df_pa, named=True)
             [{'foo': 1, 'bar': 6.0, 'ham': 'a'}, {'foo': 2, 'bar': 7.0, 'ham': 'b'}, {'foo': 3, 'bar': 8.0, 'ham': 'c'}]
         """
         return self._compliant_frame.rows(named=named)  # type: ignore[no-any-return]
@@ -1513,26 +1527,33 @@ class DataFrame(BaseFrame[DataFrameT]):
         Examples:
             >>> import pandas as pd
             >>> import polars as pl
+            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> df = {"foo": [1, 2, 3], "bar": [6.0, 7.0, 8.0], "ham": ["a", "b", "c"]}
-            >>> df_pd = pd.DataFrame(df)
-            >>> df_pl = pl.DataFrame(df)
+            >>> from narwhals.typing import IntoDataFrame
+            >>> data = {"foo": [1, 2, 3], "bar": [6.0, 7.0, 8.0], "ham": ["a", "b", "c"]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             We define a library agnostic function:
 
-            >>> @nw.narwhalify
-            ... def func(df, *, named):
-            ...     return df.iter_rows(named=named)
+            >>> def agnostic_iter_rows(df_native: IntoDataFrame, *, named: bool):
+            ...     return nw.from_native(df_native, eager_only=True).iter_rows(named=named)
 
-            We can then pass either pandas or Polars to `func`:
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_iter_rows`:
 
-            >>> [row for row in func(df_pd, named=False)]
+            >>> [row for row in agnostic_iter_rows(df_pd, named=False)]
             [(1, 6.0, 'a'), (2, 7.0, 'b'), (3, 8.0, 'c')]
-            >>> [row for row in func(df_pd, named=True)]
+            >>> [row for row in agnostic_iter_rows(df_pd, named=True)]
             [{'foo': 1, 'bar': 6.0, 'ham': 'a'}, {'foo': 2, 'bar': 7.0, 'ham': 'b'}, {'foo': 3, 'bar': 8.0, 'ham': 'c'}]
-            >>> [row for row in func(df_pl, named=False)]
+            >>> [row for row in agnostic_iter_rows(df_pl, named=False)]
             [(1, 6.0, 'a'), (2, 7.0, 'b'), (3, 8.0, 'c')]
-            >>> [row for row in func(df_pl, named=True)]
+            >>> [row for row in agnostic_iter_rows(df_pl, named=True)]
+            [{'foo': 1, 'bar': 6.0, 'ham': 'a'}, {'foo': 2, 'bar': 7.0, 'ham': 'b'}, {'foo': 3, 'bar': 8.0, 'ham': 'c'}]
+            >>> [row for row in agnostic_iter_rows(df_pa, named=False)]
+            [(1, 6.0, 'a'), (2, 7.0, 'b'), (3, 8.0, 'c')]
+            >>> [row for row in agnostic_iter_rows(df_pa, named=True)]
             [{'foo': 1, 'bar': 6.0, 'ham': 'a'}, {'foo': 2, 'bar': 7.0, 'ham': 'b'}, {'foo': 3, 'bar': 8.0, 'ham': 'c'}]
         """
         return self._compliant_frame.iter_rows(named=named, buffer_size=buffer_size)  # type: ignore[no-any-return]
@@ -1562,31 +1583,38 @@ class DataFrame(BaseFrame[DataFrameT]):
         Examples:
             >>> import pandas as pd
             >>> import polars as pl
+            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> df = {
+            >>> from narwhals.typing import IntoFrameT
+            >>> data = {
             ...     "a": [1, 2, 3, 4],
             ...     "b": [0.5, 4, 10, 13],
             ...     "c": [True, True, False, True],
             ... }
-            >>> df_pd = pd.DataFrame(df)
-            >>> df_pl = pl.DataFrame(df)
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             Let's define a dataframe-agnostic function in which we pass an expression
             to add it as a new column:
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns((nw.col("a") * 2).alias("a*2"))
+            >>> def agnostic_with_columns(df_native: IntoFrameT) -> IntoFrameT:
+            ...     return (
+            ...         nw.from_native(df_native)
+            ...         .with_columns((nw.col("a") * 2).alias("a*2"))
+            ...         .to_native()
+            ...     )
 
-            We can then pass either pandas or Polars to `func`:
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_with_columns`:
 
-            >>> func(df_pd)
+            >>> agnostic_with_columns(df_pd)
                a     b      c  a*2
             0  1   0.5   True    2
             1  2   4.0   True    4
             2  3  10.0  False    6
             3  4  13.0   True    8
-            >>> func(df_pl)
+            >>> agnostic_with_columns(df_pl)
             shape: (4, 4)
             ┌─────┬──────┬───────┬─────┐
             │ a   ┆ b    ┆ c     ┆ a*2 │
@@ -1598,6 +1626,17 @@ class DataFrame(BaseFrame[DataFrameT]):
             │ 3   ┆ 10.0 ┆ false ┆ 6   │
             │ 4   ┆ 13.0 ┆ true  ┆ 8   │
             └─────┴──────┴───────┴─────┘
+            >>> agnostic_with_columns(df_pa)
+            pyarrow.Table
+            a: int64
+            b: double
+            c: bool
+            a*2: int64
+            ----
+            a: [[1,2,3,4]]
+            b: [[0.5,4,10,13]]
+            c: [[true,true,false,true]]
+            a*2: [[2,4,6,8]]
         """
         return super().with_columns(*exprs, **named_exprs)
 
@@ -1622,30 +1661,33 @@ class DataFrame(BaseFrame[DataFrameT]):
         Examples:
             >>> import pandas as pd
             >>> import polars as pl
+            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> df = {
+            >>> from narwhals.typing import IntoFrameT
+            >>> data = {
             ...     "foo": [1, 2, 3],
             ...     "bar": [6, 7, 8],
             ...     "ham": ["a", "b", "c"],
             ... }
-            >>> df_pd = pd.DataFrame(df)
-            >>> df_pl = pl.DataFrame(df)
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             Let's define a dataframe-agnostic function in which we pass the name of a
             column to select that column.
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select("foo")
+            >>> def agnostic_single_select(df_native: IntoFrameT) -> IntoFrameT:
+            ...     return nw.from_native(df_native).select("foo").to_native()
 
-            We can then pass either pandas or Polars to `func`:
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_single_select`:
 
-            >>> func(df_pd)
+            >>> agnostic_single_select(df_pd)
                foo
             0    1
             1    2
             2    3
-            >>> func(df_pl)
+            >>> agnostic_single_select(df_pl)
             shape: (3, 1)
             ┌─────┐
             │ foo │
@@ -1656,18 +1698,23 @@ class DataFrame(BaseFrame[DataFrameT]):
             │ 2   │
             │ 3   │
             └─────┘
+            >>> agnostic_single_select(df_pa)
+            pyarrow.Table
+            foo: int64
+            ----
+            foo: [[1,2,3]]
 
             Multiple columns can be selected by passing a list of column names.
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(["foo", "bar"])
-            >>> func(df_pd)
+            >>> def agnostic_multi_select(df_native: IntoFrameT) -> IntoFrameT:
+            ...     return nw.from_native(df_native).select(["foo", "bar"]).to_native()
+
+            >>> agnostic_multi_select(df_pd)
                foo  bar
             0    1    6
             1    2    7
             2    3    8
-            >>> func(df_pl)
+            >>> agnostic_multi_select(df_pl)
             shape: (3, 2)
             ┌─────┬─────┐
             │ foo ┆ bar │
@@ -1678,19 +1725,30 @@ class DataFrame(BaseFrame[DataFrameT]):
             │ 2   ┆ 7   │
             │ 3   ┆ 8   │
             └─────┴─────┘
+            >>> agnostic_multi_select(df_pa)
+            pyarrow.Table
+            foo: int64
+            bar: int64
+            ----
+            foo: [[1,2,3]]
+            bar: [[6,7,8]]
 
             Multiple columns can also be selected using positional arguments instead of a
             list. Expressions are also accepted.
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.col("foo"), nw.col("bar") + 1)
-            >>> func(df_pd)
+            >>> def agnostic_select(df_native: IntoFrameT) -> IntoFrameT:
+            ...     return (
+            ...         nw.from_native(df_native)
+            ...         .select(nw.col("foo"), nw.col("bar") + 1)
+            ...         .to_native()
+            ...     )
+
+            >>> agnostic_select(df_pd)
                foo  bar
             0    1    7
             1    2    8
             2    3    9
-            >>> func(df_pl)
+            >>> agnostic_select(df_pl)
             shape: (3, 2)
             ┌─────┬─────┐
             │ foo ┆ bar │
@@ -1701,18 +1759,29 @@ class DataFrame(BaseFrame[DataFrameT]):
             │ 2   ┆ 8   │
             │ 3   ┆ 9   │
             └─────┴─────┘
+            >>> agnostic_select(df_pa)
+            pyarrow.Table
+            foo: int64
+            bar: int64
+            ----
+            foo: [[1,2,3]]
+            bar: [[7,8,9]]
 
             Use keyword arguments to easily name your expression inputs.
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(threshold=nw.col("foo") * 2)
-            >>> func(df_pd)
+            >>> def agnostic_select_w_kwargs(df_native: IntoFrameT) -> IntoFrameT:
+            ...     return (
+            ...         nw.from_native(df_native)
+            ...         .select(threshold=nw.col("foo") * 2)
+            ...         .to_native()
+            ...     )
+
+            >>> agnostic_select_w_kwargs(df_pd)
                threshold
             0          2
             1          4
             2          6
-            >>> func(df_pl)
+            >>> agnostic_select_w_kwargs(df_pl)
             shape: (3, 1)
             ┌───────────┐
             │ threshold │
@@ -1723,6 +1792,11 @@ class DataFrame(BaseFrame[DataFrameT]):
             │ 4         │
             │ 6         │
             └───────────┘
+            >>> agnostic_select_w_kwargs(df_pa)
+            pyarrow.Table
+            threshold: int64
+            ----
+            threshold: [[2,4,6]]
         """
         return super().select(*exprs, **named_exprs)
 
@@ -1738,25 +1812,28 @@ class DataFrame(BaseFrame[DataFrameT]):
         Examples:
             >>> import pandas as pd
             >>> import polars as pl
+            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> df = {"foo": [1, 2, 3], "bar": [6, 7, 8], "ham": ["a", "b", "c"]}
-            >>> df_pd = pd.DataFrame(df)
-            >>> df_pl = pl.DataFrame(df)
+            >>> from narwhals.typing import IntoFrameT
+            >>> data = {"foo": [1, 2, 3], "bar": [6, 7, 8], "ham": ["a", "b", "c"]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             We define a library agnostic function:
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.rename({"foo": "apple"})
+            >>> def agnostic_rename(df_native: IntoFrameT) -> IntoFrameT:
+            ...     return nw.from_native(df_native).rename({"foo": "apple"}).to_native()
 
-            We can then pass either pandas or Polars to `func`:
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_rename`:
 
-            >>> func(df_pd)
+            >>> agnostic_rename(df_pd)
                apple  bar ham
             0      1    6   a
             1      2    7   b
             2      3    8   c
-            >>> func(df_pl)
+            >>> agnostic_rename(df_pl)
             shape: (3, 3)
             ┌───────┬─────┬─────┐
             │ apple ┆ bar ┆ ham │
@@ -1767,6 +1844,15 @@ class DataFrame(BaseFrame[DataFrameT]):
             │ 2     ┆ 7   ┆ b   │
             │ 3     ┆ 8   ┆ c   │
             └───────┴─────┴─────┘
+            >>> agnostic_rename(df_pa)
+            pyarrow.Table
+            apple: int64
+            bar: int64
+            ham: string
+            ----
+            apple: [[1,2,3]]
+            bar: [[6,7,8]]
+            ham: [["a","b","c"]]
         """
         return super().rename(mapping)
 
@@ -1783,29 +1869,32 @@ class DataFrame(BaseFrame[DataFrameT]):
         Examples:
             >>> import pandas as pd
             >>> import polars as pl
+            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> df = {
+            >>> from narwhals.typing import IntoFrameT
+            >>> data = {
             ...     "foo": [1, 2, 3, 4, 5],
             ...     "bar": [6, 7, 8, 9, 10],
             ...     "ham": ["a", "b", "c", "d", "e"],
             ... }
-            >>> df_pd = pd.DataFrame(df)
-            >>> df_pl = pl.DataFrame(df)
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             Let's define a dataframe-agnostic function that gets the first 3 rows.
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.head(3)
+            >>> def agnostic_head(df_native: IntoFrameT) -> IntoFrameT:
+            ...     return nw.from_native(df_native).head(3).to_native()
 
-            We can then pass either pandas or Polars to `func`:
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_head`:
 
-            >>> func(df_pd)
+            >>> agnostic_head(df_pd)
                foo  bar ham
             0    1    6   a
             1    2    7   b
             2    3    8   c
-            >>> func(df_pl)
+            >>> agnostic_head(df_pl)
             shape: (3, 3)
             ┌─────┬─────┬─────┐
             │ foo ┆ bar ┆ ham │
@@ -1816,6 +1905,15 @@ class DataFrame(BaseFrame[DataFrameT]):
             │ 2   ┆ 7   ┆ b   │
             │ 3   ┆ 8   ┆ c   │
             └─────┴─────┴─────┘
+            >>> agnostic_head(df_pa)
+            pyarrow.Table
+            foo: int64
+            bar: int64
+            ham: string
+            ----
+            foo: [[1,2,3]]
+            bar: [[6,7,8]]
+            ham: [["a","b","c"]]
         """
         return super().head(n)
 
@@ -1832,29 +1930,32 @@ class DataFrame(BaseFrame[DataFrameT]):
         Examples:
             >>> import pandas as pd
             >>> import polars as pl
+            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> df = {
+            >>> from narwhals.typing import IntoFrameT
+            >>> data = {
             ...     "foo": [1, 2, 3, 4, 5],
             ...     "bar": [6, 7, 8, 9, 10],
             ...     "ham": ["a", "b", "c", "d", "e"],
             ... }
-            >>> df_pd = pd.DataFrame(df)
-            >>> df_pl = pl.DataFrame(df)
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             Let's define a dataframe-agnostic function that gets the last 3 rows.
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.tail(3)
+            >>> def agnostic_tail(df_native: IntoFrameT) -> IntoFrameT:
+            ...     return nw.from_native(df_native).tail(3).to_native()
 
-            We can then pass either pandas or Polars to `func`:
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_tail`:
 
-            >>> func(df_pd)
+            >>> agnostic_tail(df_pd)
                foo  bar ham
             2    3    8   c
             3    4    9   d
             4    5   10   e
-            >>> func(df_pl)
+            >>> agnostic_tail(df_pl)
             shape: (3, 3)
             ┌─────┬─────┬─────┐
             │ foo ┆ bar ┆ ham │
@@ -1865,6 +1966,15 @@ class DataFrame(BaseFrame[DataFrameT]):
             │ 4   ┆ 9   ┆ d   │
             │ 5   ┆ 10  ┆ e   │
             └─────┴─────┴─────┘
+            >>> agnostic_tail(df_pa)
+            pyarrow.Table
+            foo: int64
+            bar: int64
+            ham: string
+            ----
+            foo: [[3,4,5]]
+            bar: [[8,9,10]]
+            ham: [["c","d","e"]]
         """
         return super().tail(n)
 
@@ -1882,25 +1992,28 @@ class DataFrame(BaseFrame[DataFrameT]):
         Examples:
             >>> import pandas as pd
             >>> import polars as pl
+            >>> import pyarrow as pa
             >>> import narwhals as nw
+            >>> from narwhals.typing import IntoFrameT
             >>> data = {"foo": [1, 2, 3], "bar": [6.0, 7.0, 8.0], "ham": ["a", "b", "c"]}
             >>> df_pd = pd.DataFrame(data)
             >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             We define a library agnostic function:
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.drop("ham")
+            >>> def agnostic_drop(df_native: IntoFrameT) -> IntoFrameT:
+            ...     return nw.from_native(df_native).drop("ham").to_native()
 
-            We can then pass either pandas or Polars to `func`:
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_drop`:
 
-            >>> func(df_pd)
+            >>> agnostic_drop(df_pd)
                foo  bar
             0    1  6.0
             1    2  7.0
             2    3  8.0
-            >>> func(df_pl)
+            >>> agnostic_drop(df_pl)
             shape: (3, 2)
             ┌─────┬─────┐
             │ foo ┆ bar │
@@ -1911,19 +2024,25 @@ class DataFrame(BaseFrame[DataFrameT]):
             │ 2   ┆ 7.0 │
             │ 3   ┆ 8.0 │
             └─────┴─────┘
+            >>> agnostic_drop(df_pa)
+            pyarrow.Table
+            foo: int64
+            bar: double
+            ----
+            foo: [[1,2,3]]
+            bar: [[6,7,8]]
 
             Use positional arguments to drop multiple columns.
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.drop("foo", "ham")
+            >>> def agnostic_drop_multi(df_native: IntoFrameT) -> IntoFrameT:
+            ...     return nw.from_native(df_native).drop("foo", "ham").to_native()
 
-            >>> func(df_pd)
+            >>> agnostic_drop_multi(df_pd)
                bar
             0  6.0
             1  7.0
             2  8.0
-            >>> func(df_pl)
+            >>> agnostic_drop_multi(df_pl)
             shape: (3, 1)
             ┌─────┐
             │ bar │
@@ -1934,6 +2053,12 @@ class DataFrame(BaseFrame[DataFrameT]):
             │ 7.0 │
             │ 8.0 │
             └─────┘
+            >>> agnostic_drop_multi(df_pa)
+            pyarrow.Table
+            bar: double
+            ----
+            bar: [[6,7,8]]
+
         """
         return super().drop(*flatten(columns), strict=strict)
 
@@ -1957,8 +2082,7 @@ class DataFrame(BaseFrame[DataFrameT]):
                 * 'first': Keep first unique row.
                 * 'last': Keep last unique row.
             maintain_order: Keep the same order as the original DataFrame. This may be more
-                expensive to compute. Settings this to `True` blocks the possibility
-                to run on the streaming engine for Polars.
+                expensive to compute.
 
         Returns:
             The dataframe with the duplicate rows removed.
@@ -1966,7 +2090,9 @@ class DataFrame(BaseFrame[DataFrameT]):
         Examples:
             >>> import pandas as pd
             >>> import polars as pl
+            >>> import pyarrow as pa
             >>> import narwhals as nw
+            >>> from narwhals.typing import IntoFrameT
             >>> data = {
             ...     "foo": [1, 2, 3, 1],
             ...     "bar": ["a", "a", "a", "a"],
@@ -1974,19 +2100,20 @@ class DataFrame(BaseFrame[DataFrameT]):
             ... }
             >>> df_pd = pd.DataFrame(data)
             >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             We define a library agnostic function:
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.unique(["bar", "ham"])
+            >>> def agnostic_unique(df_native: IntoFrameT) -> IntoFrameT:
+            ...     return nw.from_native(df_native).unique(["bar", "ham"]).to_native()
 
-            We can then pass either pandas or Polars to `func`:
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_unique`:
 
-            >>> func(df_pd)
+            >>> agnostic_unique(df_pd)
                foo bar ham
             0    1   a   b
-            >>> func(df_pl)
+            >>> agnostic_unique(df_pl)
             shape: (1, 3)
             ┌─────┬─────┬─────┐
             │ foo ┆ bar ┆ ham │
@@ -1995,8 +2122,26 @@ class DataFrame(BaseFrame[DataFrameT]):
             ╞═════╪═════╪═════╡
             │ 1   ┆ a   ┆ b   │
             └─────┴─────┴─────┘
+            >>> agnostic_unique(df_pa)
+            pyarrow.Table
+            foo: int64
+            bar: string
+            ham: string
+            ----
+            foo: [[1]]
+            bar: [["a"]]
+            ham: [["b"]]
         """
-        return super().unique(subset, keep=keep, maintain_order=maintain_order)
+        if keep not in {"any", "none", "first", "last"}:
+            msg = f"Expected {'any', 'none', 'first', 'last'}, got: {keep}"
+            raise ValueError(msg)
+        if isinstance(subset, str):
+            subset = [subset]
+        return self._from_compliant_dataframe(
+            self._compliant_frame.unique(
+                subset=subset, keep=keep, maintain_order=maintain_order
+            )
+        )
 
     def filter(
         self, *predicates: IntoExpr | Iterable[IntoExpr] | list[bool], **constraints: Any
@@ -2018,25 +2163,27 @@ class DataFrame(BaseFrame[DataFrameT]):
         Examples:
             >>> import pandas as pd
             >>> import polars as pl
+            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> from narwhals.typing import IntoFrame
-            >>>
-            >>> df = {
+            >>> from narwhals.typing import IntoFrameT
+            >>> data = {
             ...     "foo": [1, 2, 3],
             ...     "bar": [6, 7, 8],
             ...     "ham": ["a", "b", "c"],
             ... }
-            >>> df_pd = pd.DataFrame(df)
-            >>> df_pl = pl.DataFrame(df)
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             Let's define a dataframe-agnostic function in which we filter on
             one condition.
 
-            >>> def agnostic_filter(df_native: IntoFrame) -> IntoFrame:
+            >>> def agnostic_filter(df_native: IntoFrameT) -> IntoFrameT:
             ...     df = nw.from_native(df_native)
             ...     return df.filter(nw.col("foo") > 1).to_native()
 
-            We can then pass either pandas or Polars to `agnostic_filter`:
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_filter`:
 
             >>> agnostic_filter(df_pd)
                foo  bar ham
@@ -2052,10 +2199,19 @@ class DataFrame(BaseFrame[DataFrameT]):
             │ 2   ┆ 7   ┆ b   │
             │ 3   ┆ 8   ┆ c   │
             └─────┴─────┴─────┘
+            >>> agnostic_filter(df_pa)
+            pyarrow.Table
+            foo: int64
+            bar: int64
+            ham: string
+            ----
+            foo: [[2,3]]
+            bar: [[7,8]]
+            ham: [["b","c"]]
 
             Filter on multiple conditions, combined with and/or operators:
 
-            >>> def agnostic_filter(df_native: IntoFrame) -> IntoFrame:
+            >>> def agnostic_filter(df_native: IntoFrameT) -> IntoFrameT:
             ...     df = nw.from_native(df_native)
             ...     return df.filter((nw.col("foo") < 3) & (nw.col("ham") == "a")).to_native()
             >>> agnostic_filter(df_pd)
@@ -2070,8 +2226,17 @@ class DataFrame(BaseFrame[DataFrameT]):
             ╞═════╪═════╪═════╡
             │ 1   ┆ 6   ┆ a   │
             └─────┴─────┴─────┘
+            >>> agnostic_filter(df_pa)
+            pyarrow.Table
+            foo: int64
+            bar: int64
+            ham: string
+            ----
+            foo: [[1]]
+            bar: [[6]]
+            ham: [["a"]]
 
-            >>> def agnostic_filter(df_native: IntoFrame) -> IntoFrame:
+            >>> def agnostic_filter(df_native: IntoFrameT) -> IntoFrameT:
             ...     df = nw.from_native(df_native)
             ...     dframe = df.filter(
             ...         (nw.col("foo") == 1) | (nw.col("ham") == "c")
@@ -2091,10 +2256,19 @@ class DataFrame(BaseFrame[DataFrameT]):
             │ 1   ┆ 6   ┆ a   │
             │ 3   ┆ 8   ┆ c   │
             └─────┴─────┴─────┘
+            >>> agnostic_filter(df_pa)
+            pyarrow.Table
+            foo: int64
+            bar: int64
+            ham: string
+            ----
+            foo: [[1,3]]
+            bar: [[6,8]]
+            ham: [["a","c"]]
 
             Provide multiple filters using `*args` syntax:
 
-            >>> def agnostic_filter(df_native: IntoFrame) -> IntoFrame:
+            >>> def agnostic_filter(df_native: IntoFrameT) -> IntoFrameT:
             ...     df = nw.from_native(df_native)
             ...     dframe = df.filter(
             ...         nw.col("foo") <= 2,
@@ -2113,10 +2287,19 @@ class DataFrame(BaseFrame[DataFrameT]):
             ╞═════╪═════╪═════╡
             │ 1   ┆ 6   ┆ a   │
             └─────┴─────┴─────┘
+            >>> agnostic_filter(df_pa)
+            pyarrow.Table
+            foo: int64
+            bar: int64
+            ham: string
+            ----
+            foo: [[1]]
+            bar: [[6]]
+            ham: [["a"]]
 
             Provide multiple filters using `**kwargs` syntax:
 
-            >>> def agnostic_filter(df_native: IntoFrame) -> IntoFrame:
+            >>> def agnostic_filter(df_native: IntoFrameT) -> IntoFrameT:
             ...     df = nw.from_native(df_native)
             ...     return df.filter(foo=2, ham="b").to_native()
             >>> agnostic_filter(df_pd)
@@ -2131,6 +2314,15 @@ class DataFrame(BaseFrame[DataFrameT]):
             ╞═════╪═════╪═════╡
             │ 2   ┆ 7   ┆ b   │
             └─────┴─────┴─────┘
+            >>> agnostic_filter(df_pa)
+            pyarrow.Table
+            foo: int64
+            bar: int64
+            ham: string
+            ----
+            foo: [[2]]
+            bar: [[7]]
+            ham: [["b"]]
         """
         return super().filter(*predicates, **constraints)
 
@@ -2150,30 +2342,34 @@ class DataFrame(BaseFrame[DataFrameT]):
         Examples:
             >>> import pandas as pd
             >>> import polars as pl
+            >>> import pyarrow as pa
             >>> import narwhals as nw
-            >>> df = {
+            >>> from narwhals.typing import IntoDataFrameT
+            >>> data = {
             ...     "a": ["a", "b", "a", "b", "c"],
             ...     "b": [1, 2, 1, 3, 3],
             ...     "c": [5, 4, 3, 2, 1],
             ... }
-            >>> df_pd = pd.DataFrame(df)
-            >>> df_pl = pl.DataFrame(df)
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             Let's define a dataframe-agnostic function in which we group by one column
             and call `agg` to compute the grouped sum of another column.
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.group_by("a").agg(nw.col("b").sum()).sort("a")
+            >>> def agnostic_group_by_agg(df_native: IntoDataFrameT) -> IntoDataFrameT:
+            ...     df = nw.from_native(df_native, eager_only=True)
+            ...     return df.group_by("a").agg(nw.col("b").sum()).sort("a").to_native()
 
-            We can then pass either pandas or Polars to `func`:
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_group_by_agg`:
 
-            >>> func(df_pd)
+            >>> agnostic_group_by_agg(df_pd)
                a  b
             0  a  2
             1  b  5
             2  c  3
-            >>> func(df_pl)
+            >>> agnostic_group_by_agg(df_pl)
             shape: (3, 2)
             ┌─────┬─────┐
             │ a   ┆ b   │
@@ -2184,19 +2380,27 @@ class DataFrame(BaseFrame[DataFrameT]):
             │ b   ┆ 5   │
             │ c   ┆ 3   │
             └─────┴─────┘
+            >>> agnostic_group_by_agg(df_pa)
+            pyarrow.Table
+            a: string
+            b: int64
+            ----
+            a: [["a","b","c"]]
+            b: [[2,5,3]]
 
             Group by multiple columns by passing a list of column names.
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.group_by(["a", "b"]).agg(nw.max("c")).sort("a", "b")
-            >>> func(df_pd)
+            >>> def agnostic_group_by_agg(df_native: IntoDataFrameT) -> IntoDataFrameT:
+            ...     df = nw.from_native(df_native, eager_only=True)
+            ...     return df.group_by(["a", "b"]).agg(nw.max("c")).sort("a", "b").to_native()
+
+            >>> agnostic_group_by_agg(df_pd)
                a  b  c
             0  a  1  5
             1  b  2  4
             2  b  3  2
             3  c  3  1
-            >>> func(df_pl)
+            >>> agnostic_group_by_agg(df_pl)
             shape: (4, 3)
             ┌─────┬─────┬─────┐
             │ a   ┆ b   ┆ c   │
@@ -2208,6 +2412,15 @@ class DataFrame(BaseFrame[DataFrameT]):
             │ b   ┆ 3   ┆ 2   │
             │ c   ┆ 3   ┆ 1   │
             └─────┴─────┴─────┘
+            >>> agnostic_group_by_agg(df_pa)
+            pyarrow.Table
+            a: string
+            b: int64
+            c: int64
+            ----
+            a: [["a","b","b","c"]]
+            b: [[1,2,3,3]]
+            c: [[5,4,2,1]]
         """
         from narwhals.expr import Expr
         from narwhals.group_by import GroupBy
@@ -2247,9 +2460,11 @@ class DataFrame(BaseFrame[DataFrameT]):
             boolean is applied for all `by` columns.
 
         Examples:
-            >>> import narwhals as nw
             >>> import pandas as pd
             >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoFrameT
             >>> data = {
             ...     "a": [1, 2, None],
             ...     "b": [6.0, 5.0, 4.0],
@@ -2257,22 +2472,24 @@ class DataFrame(BaseFrame[DataFrameT]):
             ... }
             >>> df_pd = pd.DataFrame(data)
             >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             Let's define a dataframe-agnostic function in which we sort by multiple
             columns in different orders
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.sort("c", "a", descending=[False, True])
+            >>> def agnostic_sort(df_native: IntoFrameT) -> IntoFrameT:
+            ...     df = nw.from_native(df_native)
+            ...     return df.sort("c", "a", descending=[False, True]).to_native()
 
-            We can then pass either pandas or Polars to `func`:
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_sort`:
 
-            >>> func(df_pd)
+            >>> agnostic_sort(df_pd)
                  a    b  c
             0  1.0  6.0  a
             2  NaN  4.0  b
             1  2.0  5.0  c
-            >>> func(df_pl)
+            >>> agnostic_sort(df_pl)
             shape: (3, 3)
             ┌──────┬─────┬─────┐
             │ a    ┆ b   ┆ c   │
@@ -2283,6 +2500,15 @@ class DataFrame(BaseFrame[DataFrameT]):
             │ null ┆ 4.0 ┆ b   │
             │ 2    ┆ 5.0 ┆ c   │
             └──────┴─────┴─────┘
+            >>> agnostic_sort(df_pa)
+            pyarrow.Table
+            a: int64
+            b: double
+            c: string
+            ----
+            a: [[1,null,2]]
+            b: [[6,4,5]]
+            c: [["a","b","c"]]
         """
         return super().sort(by, *more_by, descending=descending, nulls_last=nulls_last)
 
@@ -2317,9 +2543,11 @@ class DataFrame(BaseFrame[DataFrameT]):
             A new joined DataFrame
 
         Examples:
-            >>> import narwhals as nw
             >>> import pandas as pd
             >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoFrameT
             >>> data = {
             ...     "foo": [1, 2, 3],
             ...     "bar": [6.0, 7.0, 8.0],
@@ -2336,20 +2564,27 @@ class DataFrame(BaseFrame[DataFrameT]):
             >>> df_pl = pl.DataFrame(data)
             >>> other_pl = pl.DataFrame(data_other)
 
+            >>> df_pa = pa.table(data)
+            >>> other_pa = pa.table(data_other)
+
             Let's define a dataframe-agnostic function in which we join over "ham" column:
 
-            >>> @nw.narwhalify
-            ... def join_on_ham(df, other_any):
-            ...     return df.join(other_any, left_on="ham", right_on="ham")
+            >>> def agnostic_join_on_ham(
+            ...     df_native: IntoFrameT, other_native: IntoFrameT
+            ... ) -> IntoFrameT:
+            ...     df = nw.from_native(df_native)
+            ...     other = nw.from_native(other_native)
+            ...     return df.join(other, left_on="ham", right_on="ham").to_native()
 
-            We can now pass either pandas or Polars to the function:
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_join_on_ham`:
 
-            >>> join_on_ham(df_pd, other_pd)
+            >>> agnostic_join_on_ham(df_pd, other_pd)
                foo  bar ham apple
             0    1  6.0   a     x
             1    2  7.0   b     y
 
-            >>> join_on_ham(df_pl, other_pl)
+            >>> agnostic_join_on_ham(df_pl, other_pl)
             shape: (2, 4)
             ┌─────┬─────┬─────┬───────┐
             │ foo ┆ bar ┆ ham ┆ apple │
@@ -2359,6 +2594,17 @@ class DataFrame(BaseFrame[DataFrameT]):
             │ 1   ┆ 6.0 ┆ a   ┆ x     │
             │ 2   ┆ 7.0 ┆ b   ┆ y     │
             └─────┴─────┴─────┴───────┘
+            >>> agnostic_join_on_ham(df_pa, other_pa)
+            pyarrow.Table
+            foo: int64
+            bar: double
+            ham: string
+            apple: string
+            ----
+            foo: [[1,2]]
+            bar: [[6,7]]
+            ham: [["a","b"]]
+            apple: [["x","y"]]
         """
         return super().join(
             other, how=how, left_on=left_on, right_on=right_on, on=on, suffix=suffix
@@ -2384,19 +2630,12 @@ class DataFrame(BaseFrame[DataFrameT]):
 
         Arguments:
             other: DataFrame to join with.
-
             left_on: Name(s) of the left join column(s).
-
             right_on: Name(s) of the right join column(s).
-
             on: Join column of both DataFrames. If set, left_on and right_on should be None.
-
-            by_left: join on these columns before doing asof join
-
-            by_right: join on these columns before doing asof join
-
-            by: join on these columns before doing asof join
-
+            by_left: join on these columns before doing asof join.
+            by_right: join on these columns before doing asof join.
+            by: join on these columns before doing asof join.
             strategy: Join strategy. The default is "backward".
 
                   * *backward*: selects the last row in the right DataFrame whose "on" key is less than or equal to the left's key.
@@ -2408,9 +2647,11 @@ class DataFrame(BaseFrame[DataFrameT]):
 
         Examples:
             >>> from datetime import datetime
-            >>> import narwhals as nw
+            >>> from typing import Literal
             >>> import pandas as pd
             >>> import polars as pl
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoFrameT
             >>> data_gdp = {
             ...     "datetime": [
             ...         datetime(2016, 1, 1),
@@ -2437,19 +2678,25 @@ class DataFrame(BaseFrame[DataFrameT]):
 
             Let's define a dataframe-agnostic function in which we join over "datetime" column:
 
-            >>> @nw.narwhalify
-            ... def join_asof_datetime(df, other_any, strategy):
-            ...     return df.join_asof(other_any, on="datetime", strategy=strategy)
+            >>> def agnostic_join_asof_datetime(
+            ...     df_native: IntoFrameT,
+            ...     other_native: IntoFrameT,
+            ...     strategy: Literal["backward", "forward", "nearest"],
+            ... ) -> IntoFrameT:
+            ...     df = nw.from_native(df_native)
+            ...     other = nw.from_native(other_native)
+            ...     return df.join_asof(other, on="datetime", strategy=strategy).to_native()
 
-            We can now pass either pandas or Polars to the function:
+            We can then pass any supported library such as Pandas or Polars
+            to `agnostic_join_asof_datetime`:
 
-            >>> join_asof_datetime(population_pd, gdp_pd, strategy="backward")
+            >>> agnostic_join_asof_datetime(population_pd, gdp_pd, strategy="backward")
                 datetime  population   gdp
             0 2016-03-01       82.19  4164
             1 2018-08-01       82.66  4566
             2 2019-01-01       83.12  4696
 
-            >>> join_asof_datetime(population_pl, gdp_pl, strategy="backward")
+            >>> agnostic_join_asof_datetime(population_pl, gdp_pl, strategy="backward")
             shape: (3, 3)
             ┌─────────────────────┬────────────┬──────┐
             │ datetime            ┆ population ┆ gdp  │
@@ -2510,13 +2757,16 @@ class DataFrame(BaseFrame[DataFrameT]):
 
             Let's define a dataframe-agnostic function in which we join over "datetime" and by "ticker" columns:
 
-            >>> @nw.narwhalify
-            ... def join_asof_datetime_by_ticker(df, other_any):
-            ...     return df.join_asof(other_any, on="datetime", by="ticker")
+            >>> def agnostic_join_asof_datetime_by_ticker(
+            ...     df_native: IntoFrameT, other_native: IntoFrameT
+            ... ) -> IntoFrameT:
+            ...     df = nw.from_native(df_native)
+            ...     other = nw.from_native(other_native)
+            ...     return df.join_asof(other, on="datetime", by="ticker").to_native()
 
             We can now pass either pandas or Polars to the function:
 
-            >>> join_asof_datetime_by_ticker(trades_pd, quotes_pd)
+            >>> agnostic_join_asof_datetime_by_ticker(trades_pd, quotes_pd)
                                 datetime ticker   price  quantity     bid     ask
             0 2016-05-25 13:30:00.000023   MSFT   51.95        75   51.95   51.96
             1 2016-05-25 13:30:00.000038   MSFT   51.95       155   51.97   51.98
@@ -2524,7 +2774,7 @@ class DataFrame(BaseFrame[DataFrameT]):
             3 2016-05-25 13:30:00.000048   GOOG  720.92       100  720.50  720.93
             4 2016-05-25 13:30:00.000048   AAPL   98.00       100     NaN     NaN
 
-            >>> join_asof_datetime_by_ticker(trades_pl, quotes_pl)
+            >>> agnostic_join_asof_datetime_by_ticker(trades_pl, quotes_pl)
             shape: (5, 6)
             ┌────────────────────────────┬────────┬────────┬──────────┬───────┬────────┐
             │ datetime                   ┆ ticker ┆ price  ┆ quantity ┆ bid   ┆ ask    │
@@ -2557,38 +2807,37 @@ class DataFrame(BaseFrame[DataFrameT]):
             A new Series.
 
         Examples:
-            >>> import narwhals as nw
             >>> import pandas as pd
             >>> import polars as pl
-            >>> df_pd = pd.DataFrame(
-            ...     {
-            ...         "a": [1, 2, 3, 1],
-            ...         "b": ["x", "y", "z", "x"],
-            ...     }
-            ... )
-            >>> df_pl = pl.DataFrame(
-            ...     {
-            ...         "a": [1, 2, 3, 1],
-            ...         "b": ["x", "y", "z", "x"],
-            ...     }
-            ... )
+            >>> import pyarrow as pa
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoDataFrame
+            >>> from narwhals.typing import IntoSeries
+            >>> data = {
+            ...     "a": [1, 2, 3, 1],
+            ...     "b": ["x", "y", "z", "x"],
+            ... }
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             Let's define a dataframe-agnostic function:
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.is_duplicated()
+            >>> def agnostic_is_duplicated(df_native: IntoDataFrame) -> IntoSeries:
+            ...     df = nw.from_native(df_native, eager_only=True)
+            ...     return df.is_duplicated().to_native()
 
-            We can then pass either pandas or Polars to `func`:
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_is_duplicated`:
 
-            >>> func(df_pd)  # doctest: +NORMALIZE_WHITESPACE
+            >>> agnostic_is_duplicated(df_pd)
             0     True
             1    False
             2    False
             3     True
             dtype: bool
 
-            >>> func(df_pl)  # doctest: +NORMALIZE_WHITESPACE
+            >>> agnostic_is_duplicated(df_pl)  # doctest: +NORMALIZE_WHITESPACE
             shape: (4,)
             Series: '' [bool]
             [
@@ -2597,6 +2846,16 @@ class DataFrame(BaseFrame[DataFrameT]):
                 false
                 true
             ]
+            >>> agnostic_is_duplicated(df_pa)  # doctest: +ELLIPSIS
+            <pyarrow.lib.ChunkedArray object at ...>
+            [
+              [
+                true,
+                false,
+                false,
+                true
+              ]
+            ]
         """
         return self._series(
             self._compliant_frame.is_duplicated(),
@@ -2610,28 +2869,35 @@ class DataFrame(BaseFrame[DataFrameT]):
             A boolean indicating whether the dataframe is empty (True) or not (False).
 
         Examples:
-            >>> import narwhals as nw
             >>> import pandas as pd
             >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoDataFrame
 
             Let's define a dataframe-agnostic function that filters rows in which "foo"
             values are greater than 10, and then checks if the result is empty or not:
 
-            >>> @nw.narwhalify
-            ... def func(df):
+            >>> def agnostic_is_empty(df_native: IntoDataFrame) -> bool:
+            ...     df = nw.from_native(df_native, eager_only=True)
             ...     return df.filter(nw.col("foo") > 10).is_empty()
 
-            We can then pass either pandas or Polars to `func`:
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_is_empty`:
 
-            >>> df_pd = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})
-            >>> df_pl = pl.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})
-            >>> func(df_pd), func(df_pl)
-            (True, True)
+            >>> data = {"foo": [1, 2, 3], "bar": [4, 5, 6]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
+            >>> agnostic_is_empty(df_pd), agnostic_is_empty(df_pl), agnostic_is_empty(df_pa)
+            (True, True, True)
 
-            >>> df_pd = pd.DataFrame({"foo": [100, 2, 3], "bar": [4, 5, 6]})
-            >>> df_pl = pl.DataFrame({"foo": [100, 2, 3], "bar": [4, 5, 6]})
-            >>> func(df_pd), func(df_pl)
-            (False, False)
+            >>> data = {"foo": [100, 2, 3], "bar": [4, 5, 6]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
+            >>> agnostic_is_empty(df_pd), agnostic_is_empty(df_pl), agnostic_is_empty(df_pa)
+            (False, False, False)
         """
         return self._compliant_frame.is_empty()  # type: ignore[no-any-return]
 
@@ -2642,38 +2908,37 @@ class DataFrame(BaseFrame[DataFrameT]):
             A new Series.
 
         Examples:
-            >>> import narwhals as nw
             >>> import pandas as pd
             >>> import polars as pl
-            >>> df_pd = pd.DataFrame(
-            ...     {
-            ...         "a": [1, 2, 3, 1],
-            ...         "b": ["x", "y", "z", "x"],
-            ...     }
-            ... )
-            >>> df_pl = pl.DataFrame(
-            ...     {
-            ...         "a": [1, 2, 3, 1],
-            ...         "b": ["x", "y", "z", "x"],
-            ...     }
-            ... )
+            >>> import pyarrow as pa
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoDataFrame
+            >>> from narwhals.typing import IntoSeries
+            >>> data = {
+            ...     "a": [1, 2, 3, 1],
+            ...     "b": ["x", "y", "z", "x"],
+            ... }
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             Let's define a dataframe-agnostic function:
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.is_unique()
+            >>> def agnostic_is_unique(df_native: IntoDataFrame) -> IntoSeries:
+            ...     df = nw.from_native(df_native, eager_only=True)
+            ...     return df.is_unique().to_native()
 
-            We can then pass either pandas or Polars to `func`:
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_is_unique`:
 
-            >>> func(df_pd)  # doctest: +NORMALIZE_WHITESPACE
+            >>> agnostic_is_unique(df_pd)
             0    False
             1     True
             2     True
             3    False
             dtype: bool
 
-            >>> func(df_pl)  # doctest: +NORMALIZE_WHITESPACE
+            >>> agnostic_is_unique(df_pl)  # doctest: +NORMALIZE_WHITESPACE
             shape: (4,)
             Series: '' [bool]
             [
@@ -2682,6 +2947,16 @@ class DataFrame(BaseFrame[DataFrameT]):
                  true
                 false
             ]
+            >>> agnostic_is_unique(df_pa)  # doctest: +ELLIPSIS
+            <pyarrow.lib.ChunkedArray object at ...>
+            [
+              [
+                false,
+                true,
+                true,
+                false
+              ]
+            ]
         """
         return self._series(
             self._compliant_frame.is_unique(),
@@ -2700,11 +2975,11 @@ class DataFrame(BaseFrame[DataFrameT]):
             for reference.
 
         Examples:
-            >>> import narwhals as nw
-            >>> from narwhals.typing import IntoFrameT
             >>> import pandas as pd
             >>> import polars as pl
             >>> import pyarrow as pa
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoFrameT
             >>> data = {
             ...     "foo": [1, None, 3],
             ...     "bar": [6, 7, None],
@@ -2721,7 +2996,8 @@ class DataFrame(BaseFrame[DataFrameT]):
             ...     df = nw.from_native(df_native)
             ...     return df.null_count().to_native()
 
-            We can then pass any supported library such as Pandas, Polars, or PyArrow to `agnostic_null_count`:
+            We can then pass any supported library such as Pandas, Polars, or PyArrow to
+            `agnostic_null_count`:
 
             >>> agnostic_null_count(df_pd)
                foo  bar  ham
@@ -2764,25 +3040,32 @@ class DataFrame(BaseFrame[DataFrameT]):
             With row/col, this is equivalent to df[row,col].
 
         Examples:
-            >>> import narwhals as nw
             >>> import pandas as pd
             >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoDataFrame
             >>> data = {"a": [1, 2, 3], "b": [4, 5, 6]}
             >>> df_pd = pd.DataFrame(data)
             >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             Let's define a dataframe-agnostic function that returns item at given row/column
 
-            >>> @nw.narwhalify
-            ... def func(df, row, column):
+            >>> def agnostic_item(
+            ...     df_native: IntoDataFrame, row: int | None, column: int | str | None
+            ... ):
+            ...     df = nw.from_native(df_native, eager_only=True)
             ...     return df.item(row, column)
 
-            We can then pass either pandas or Polars to `func`:
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_item`:
 
-            >>> func(df_pd, 1, 1), func(df_pd, 2, "b")
+            >>> agnostic_item(df_pd, 1, 1), agnostic_item(df_pd, 2, "b")
             (np.int64(5), np.int64(6))
-
-            >>> func(df_pl, 1, 1), func(df_pl, 2, "b")
+            >>> agnostic_item(df_pl, 1, 1), agnostic_item(df_pl, 2, "b")
+            (5, 6)
+            >>> agnostic_item(df_pa, 1, 1), agnostic_item(df_pa, 2, "b")
             (5, 6)
         """
         return self._compliant_frame.item(row=row, column=column)
@@ -2794,25 +3077,30 @@ class DataFrame(BaseFrame[DataFrameT]):
             An identical copy of the original dataframe.
 
         Examples:
-            >>> import narwhals as nw
             >>> import pandas as pd
             >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoFrameT
             >>> data = {"a": [1, 2], "b": [3, 4]}
             >>> df_pd = pd.DataFrame(data)
             >>> df_pl = pl.DataFrame(data)
 
             Let's define a dataframe-agnostic function in which we clone the DataFrame:
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.clone()
+            >>> def agnostic_clone(df_native: IntoFrameT) -> IntoFrameT:
+            ...     df = nw.from_native(df_native)
+            ...     return df.clone().to_native()
+
+            We can then pass any supported library such as Pandas or Polars
+            to `agnostic_clone`:
 
-            >>> func(df_pd)
+            >>> agnostic_clone(df_pd)
                a  b
             0  1  3
             1  2  4
 
-            >>> func(df_pl)
+            >>> agnostic_clone(df_pl)
             shape: (2, 2)
             ┌─────┬─────┐
             │ a   ┆ b   │
@@ -2836,26 +3124,32 @@ class DataFrame(BaseFrame[DataFrameT]):
             The dataframe containing only the selected rows.
 
         Examples:
-            >>> import narwhals as nw
             >>> import pandas as pd
             >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoFrameT
             >>> data = {"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}
             >>> df_pd = pd.DataFrame(data)
             >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             Let's define a dataframe-agnostic function in which gather every 2 rows,
             starting from a offset of 1:
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.gather_every(n=2, offset=1)
+            >>> def agnostic_gather_every(df_native: IntoFrameT) -> IntoFrameT:
+            ...     df = nw.from_native(df_native)
+            ...     return df.gather_every(n=2, offset=1).to_native()
+
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_gather_every`:
 
-            >>> func(df_pd)
+            >>> agnostic_gather_every(df_pd)
                a  b
             1  2  6
             3  4  8
 
-            >>> func(df_pl)
+            >>> agnostic_gather_every(df_pl)
             shape: (2, 2)
             ┌─────┬─────┐
             │ a   ┆ b   │
@@ -2865,6 +3159,13 @@ class DataFrame(BaseFrame[DataFrameT]):
             │ 2   ┆ 6   │
             │ 4   ┆ 8   │
             └─────┴─────┘
+            >>> agnostic_gather_every(df_pa)
+            pyarrow.Table
+            a: int64
+            b: int64
+            ----
+            a: [[2,4]]
+            b: [[6,8]]
         """
         return super().gather_every(n=n, offset=offset)
 
@@ -2909,9 +3210,11 @@ class DataFrame(BaseFrame[DataFrameT]):
             A new dataframe.
 
         Examples:
-            >>> import narwhals as nw
             >>> import pandas as pd
             >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoDataFrameT
             >>> data = {
             ...     "ix": [1, 1, 2, 2, 1, 2],
             ...     "col": ["a", "a", "a", "a", "b", "b"],
@@ -2920,20 +3223,22 @@ class DataFrame(BaseFrame[DataFrameT]):
             ... }
             >>> df_pd = pd.DataFrame(data)
             >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             Let's define a dataframe-agnostic function:
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.pivot("col", index="ix", aggregate_function="sum")
+            >>> def agnostic_pivot(df_native: IntoDataFrameT) -> IntoDataFrameT:
+            ...     df = nw.from_native(df_native, eager_only=True)
+            ...     return df.pivot("col", index="ix", aggregate_function="sum").to_native()
 
-            We can then pass either pandas or Polars to `func`:
+            We can then pass any supported library such as Pandas or Polars
+            to `agnostic_pivot`:
 
-            >>> func(df_pd)
+            >>> agnostic_pivot(df_pd)
                ix  foo_a  foo_b  bar_a  bar_b
             0   1      1      7      2      9
             1   2      4      1      0      4
-            >>> func(df_pl)
+            >>> agnostic_pivot(df_pl)
             shape: (2, 5)
             ┌─────┬───────┬───────┬───────┬───────┐
             │ ix  ┆ foo_a ┆ foo_b ┆ bar_a ┆ bar_b │
@@ -2972,20 +3277,26 @@ class DataFrame(BaseFrame[DataFrameT]):
             A new PyArrow table.
 
         Examples:
-            >>> import narwhals as nw
             >>> import pandas as pd
             >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoDataFrame
             >>> data = {"foo": [1, 2, 3], "bar": ["a", "b", "c"]}
             >>> df_pd = pd.DataFrame(data)
             >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             Let's define a dataframe-agnostic function that converts to arrow table:
 
-            >>> @nw.narwhalify
-            ... def func(df):
+            >>> def agnostic_to_arrow(df_native: IntoDataFrame) -> pa.Table:
+            ...     df = nw.from_native(df_native, eager_only=True)
             ...     return df.to_arrow()
 
-            >>> func(df_pd)
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_to_arrow`:
+
+            >>> agnostic_to_arrow(df_pd)
             pyarrow.Table
             foo: int64
             bar: string
@@ -2993,13 +3304,21 @@ class DataFrame(BaseFrame[DataFrameT]):
             foo: [[1,2,3]]
             bar: [["a","b","c"]]
 
-            >>> func(df_pl)  # doctest:+NORMALIZE_WHITESPACE
+            >>> agnostic_to_arrow(df_pl)
             pyarrow.Table
             foo: int64
             bar: large_string
             ----
             foo: [[1,2,3]]
             bar: [["a","b","c"]]
+
+            >>> agnostic_to_arrow(df_pa)
+            pyarrow.Table
+            foo: int64
+            bar: string
+            ----
+            foo: [[1,2,3]]
+            bar: [["a","b","c"]]
         """
         return self._compliant_frame.to_arrow()
 
@@ -3027,25 +3346,30 @@ class DataFrame(BaseFrame[DataFrameT]):
             The results may not be consistent across libraries.
 
         Examples:
-            >>> import narwhals as nw
             >>> import pandas as pd
             >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoDataFrameT
             >>> data = {"a": [1, 2, 3, 4], "b": ["x", "y", "x", "y"]}
             >>> df_pd = pd.DataFrame(data)
             >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             We define a library agnostic function:
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.sample(n=2, seed=123)
+            >>> def agnostic_sample(df_native: IntoDataFrameT) -> IntoDataFrameT:
+            ...     df = nw.from_native(df_native, eager_only=True)
+            ...     return df.sample(n=2, seed=123).to_native()
 
-            We can then pass either pandas or Polars to `func`:
-            >>> func(df_pd)
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_sample`:
+
+            >>> agnostic_sample(df_pd)
                a  b
             3  4  y
             0  1  x
-            >>> func(df_pl)
+            >>> agnostic_sample(df_pl)
             shape: (2, 2)
             ┌─────┬─────┐
             │ a   ┆ b   │
@@ -3055,6 +3379,13 @@ class DataFrame(BaseFrame[DataFrameT]):
             │ 2   ┆ y   │
             │ 3   ┆ x   │
             └─────┴─────┘
+            >>> agnostic_sample(df_pa)
+            pyarrow.Table
+            a: int64
+            b: string
+            ----
+            a: [[1,3]]
+            b: [["x","x"]]
 
             As you can see, by using the same seed, the result will be consistent within
             the same backend, but not necessarely across different backends.
@@ -3098,10 +3429,11 @@ class DataFrame(BaseFrame[DataFrameT]):
             In other frameworks, you might know this operation as `pivot_longer`.
 
         Examples:
-            >>> import narwhals as nw
             >>> import pandas as pd
             >>> import polars as pl
             >>> import pyarrow as pa
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoFrameT
             >>> data = {
             ...     "a": ["x", "y", "z"],
             ...     "b": [1, 3, 5],
@@ -3110,13 +3442,14 @@ class DataFrame(BaseFrame[DataFrameT]):
 
             We define a library agnostic function:
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.unpivot(on=["b", "c"], index="a")
+            >>> def agnostic_unpivot(df_native: IntoFrameT) -> IntoFrameT:
+            ...     df = nw.from_native(df_native)
+            ...     return df.unpivot(on=["b", "c"], index="a").to_native()
 
-            We can pass any supported library such as pandas, Polars or PyArrow to `func`:
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_unpivot`:
 
-            >>> func(pl.DataFrame(data))
+            >>> agnostic_unpivot(pl.DataFrame(data))
             shape: (6, 3)
             ┌─────┬──────────┬───────┐
             │ a   ┆ variable ┆ value │
@@ -3131,7 +3464,7 @@ class DataFrame(BaseFrame[DataFrameT]):
             │ z   ┆ c        ┆ 6     │
             └─────┴──────────┴───────┘
 
-            >>> func(pd.DataFrame(data))
+            >>> agnostic_unpivot(pd.DataFrame(data))
                a variable  value
             0  x        b      1
             1  y        b      3
@@ -3140,7 +3473,7 @@ class DataFrame(BaseFrame[DataFrameT]):
             4  y        c      4
             5  z        c      6
 
-            >>> func(pa.table(data))
+            >>> agnostic_unpivot(pa.table(data))
             pyarrow.Table
             a: string
             variable: string
@@ -3169,11 +3502,11 @@ class DataFrame(BaseFrame[DataFrameT]):
             New DataFrame
 
         Examples:
-            >>> import narwhals as nw
-            >>> from narwhals.typing import IntoDataFrameT
             >>> import pandas as pd
             >>> import polars as pl
             >>> import pyarrow as pa
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoFrameT
             >>> data = {
             ...     "a": ["x", "y", "z", "w"],
             ...     "lst1": [[1, 2], None, [None], []],
@@ -3182,7 +3515,7 @@ class DataFrame(BaseFrame[DataFrameT]):
 
             We define a library agnostic function:
 
-            >>> def agnostic_explode(df_native: IntoDataFrameT) -> IntoDataFrameT:
+            >>> def agnostic_explode(df_native: IntoFrameT) -> IntoFrameT:
             ...     return (
             ...         nw.from_native(df_native)
             ...         .with_columns(nw.col("lst1", "lst2").cast(nw.List(nw.Int32())))
@@ -4096,25 +4429,21 @@ class LazyFrame(BaseFrame[FrameT]):
         self,
         subset: str | list[str] | None = None,
         *,
-        keep: Literal["any", "first", "last", "none"] = "any",
-        maintain_order: bool = False,
+        keep: Literal["any", "none"] = "any",
+        maintain_order: bool | None = None,
     ) -> Self:
         """Drop duplicate rows from this LazyFrame.
 
         Arguments:
             subset: Column name(s) to consider when identifying duplicate rows.
                      If set to `None`, use all columns.
-            keep: {'first', 'last', 'any', 'none'}
+            keep: {'first', 'none'}
                 Which of the duplicate rows to keep.
 
                 * 'any': Does not give any guarantee of which row is kept.
                         This allows more optimizations.
                 * 'none': Don't keep duplicate rows.
-                * 'first': Keep first unique row.
-                * 'last': Keep last unique row.
-            maintain_order: Keep the same order as the original DataFrame. This may be more
-                expensive to compute. Settings this to `True` blocks the possibility
-                to run on the streaming engine for Polars.
+            maintain_order: Has no effect and is kept around only for backwards-compatibility.
 
         Returns:
             LazyFrame: LazyFrame with unique rows.
@@ -4154,7 +4483,26 @@ class LazyFrame(BaseFrame[FrameT]):
             │ 1   ┆ a   ┆ b   │
             └─────┴─────┴─────┘
         """
-        return super().unique(subset, keep=keep, maintain_order=maintain_order)
+        if keep not in {"any", "none"}:
+            msg = (
+                "narwhals.LazyFrame makes no assumptions about row order, so only "
+                f"'any' and 'none' are supported for `keep` in `unique`. Got: {keep}."
+            )
+            raise ValueError(msg)
+        if maintain_order:
+            msg = "`maintain_order=True` is not supported for LazyFrame.unique."
+            raise ValueError(msg)
+        if maintain_order is not None:
+            msg = (
+                "`maintain_order` has no effect and is only kept around for backwards-compatibility. "
+                "You can safely remove this argument."
+            )
+            warn(message=msg, category=UserWarning, stacklevel=find_stacklevel())
+        if isinstance(subset, str):
+            subset = [subset]
+        return self._from_compliant_dataframe(
+            self._compliant_frame.unique(subset=subset, keep=keep)
+        )
 
     def filter(
         self, *predicates: IntoExpr | Iterable[IntoExpr] | list[bool], **constraints: Any
diff --git a/narwhals/stable/v1/__init__.py b/narwhals/stable/v1/__init__.py
index 2b5be2ee..f7705713 100644
--- a/narwhals/stable/v1/__init__.py
+++ b/narwhals/stable/v1/__init__.py
@@ -180,11 +180,10 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
             >>> import pyarrow as pa
             >>> import narwhals as nw
             >>> from narwhals.typing import IntoFrame
-            >>>
-            >>> df = {"foo": [1, 2, 3], "bar": [6.0, 7.0, 8.0], "ham": ["a", "b", "c"]}
-            >>> df_pd = pd.DataFrame(df)
-            >>> df_pl = pl.DataFrame(df)
-            >>> df_pa = pa.table(df)
+            >>> data = {"foo": [1, 2, 3], "bar": [6.0, 7.0, 8.0], "ham": ["a", "b", "c"]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             We define a library agnostic function:
 
@@ -192,7 +191,8 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
             ...     df = nw.from_native(df_native)
             ...     return df.lazy().to_native()
 
-            Note that then, pandas and pyarrow dataframe stay eager, but Polars DataFrame becomes a Polars LazyFrame:
+            Note that then, pandas and pyarrow dataframe stay eager, but Polars DataFrame
+            becomes a Polars LazyFrame:
 
             >>> agnostic_lazy(df_pd)
                foo  bar ham
@@ -228,7 +228,7 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
 
         Arguments:
             as_series: If set to true ``True``, then the values are Narwhals Series,
-                        otherwise the values are Any.
+                    otherwise the values are Any.
 
         Returns:
             A mapping from column name to values / Series.
@@ -239,17 +239,16 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
             >>> import pyarrow as pa
             >>> import narwhals as nw
             >>> from narwhals.typing import IntoDataFrame
-            >>>
-            >>> df = {
+            >>> data = {
             ...     "A": [1, 2, 3, 4, 5],
             ...     "fruits": ["banana", "banana", "apple", "apple", "banana"],
             ...     "B": [5, 4, 3, 2, 1],
             ...     "animals": ["beetle", "fly", "beetle", "beetle", "beetle"],
             ...     "optional": [28, 300, None, 2, -30],
             ... }
-            >>> df_pd = pd.DataFrame(df)
-            >>> df_pl = pl.DataFrame(df)
-            >>> df_pa = pa.table(df)
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             We define a library agnostic function:
 
@@ -277,38 +276,37 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
             A new Series.
 
         Examples:
-            >>> import narwhals as nw
             >>> import pandas as pd
             >>> import polars as pl
-            >>> df_pd = pd.DataFrame(
-            ...     {
-            ...         "a": [1, 2, 3, 1],
-            ...         "b": ["x", "y", "z", "x"],
-            ...     }
-            ... )
-            >>> df_pl = pl.DataFrame(
-            ...     {
-            ...         "a": [1, 2, 3, 1],
-            ...         "b": ["x", "y", "z", "x"],
-            ...     }
-            ... )
+            >>> import pyarrow as pa
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoDataFrame
+            >>> from narwhals.typing import IntoSeries
+            >>> data = {
+            ...     "a": [1, 2, 3, 1],
+            ...     "b": ["x", "y", "z", "x"],
+            ... }
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             Let's define a dataframe-agnostic function:
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.is_duplicated()
+            >>> def agnostic_is_duplicated(df_native: IntoDataFrame) -> IntoSeries:
+            ...     df = nw.from_native(df_native, eager_only=True)
+            ...     return df.is_duplicated().to_native()
 
-            We can then pass either pandas or Polars to `func`:
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_is_duplicated`:
 
-            >>> func(df_pd)  # doctest: +NORMALIZE_WHITESPACE
+            >>> agnostic_is_duplicated(df_pd)
             0     True
             1    False
             2    False
             3     True
             dtype: bool
 
-            >>> func(df_pl)  # doctest: +NORMALIZE_WHITESPACE
+            >>> agnostic_is_duplicated(df_pl)  # doctest: +NORMALIZE_WHITESPACE
             shape: (4,)
             Series: '' [bool]
             [
@@ -317,6 +315,16 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
                 false
                 true
             ]
+            >>> agnostic_is_duplicated(df_pa)  # doctest: +ELLIPSIS
+            <pyarrow.lib.ChunkedArray object at ...>
+            [
+              [
+                true,
+                false,
+                false,
+                true
+              ]
+            ]
         """
         return super().is_duplicated()  # type: ignore[return-value]
 
@@ -327,38 +335,37 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
             A new Series.
 
         Examples:
-            >>> import narwhals as nw
             >>> import pandas as pd
             >>> import polars as pl
-            >>> df_pd = pd.DataFrame(
-            ...     {
-            ...         "a": [1, 2, 3, 1],
-            ...         "b": ["x", "y", "z", "x"],
-            ...     }
-            ... )
-            >>> df_pl = pl.DataFrame(
-            ...     {
-            ...         "a": [1, 2, 3, 1],
-            ...         "b": ["x", "y", "z", "x"],
-            ...     }
-            ... )
+            >>> import pyarrow as pa
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoDataFrame
+            >>> from narwhals.typing import IntoSeries
+            >>> data = {
+            ...     "a": [1, 2, 3, 1],
+            ...     "b": ["x", "y", "z", "x"],
+            ... }
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
 
             Let's define a dataframe-agnostic function:
 
-            >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.is_unique()
+            >>> def agnostic_is_unique(df_native: IntoDataFrame) -> IntoSeries:
+            ...     df = nw.from_native(df_native, eager_only=True)
+            ...     return df.is_unique().to_native()
 
-            We can then pass either pandas or Polars to `func`:
+            We can then pass any supported library such as Pandas, Polars, or PyArrow
+            to `agnostic_is_unique`:
 
-            >>> func(df_pd)  # doctest: +NORMALIZE_WHITESPACE
+            >>> agnostic_is_unique(df_pd)
             0    False
             1     True
             2     True
             3    False
             dtype: bool
 
-            >>> func(df_pl)  # doctest: +NORMALIZE_WHITESPACE
+            >>> agnostic_is_unique(df_pl)  # doctest: +NORMALIZE_WHITESPACE
             shape: (4,)
             Series: '' [bool]
             [
@@ -367,6 +374,16 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
                  true
                 false
             ]
+            >>> agnostic_is_unique(df_pa)  # doctest: +ELLIPSIS
+            <pyarrow.lib.ChunkedArray object at ...>
+            [
+              [
+                false,
+                true,
+                true,
+                false
+              ]
+            ]
         """
         return super().is_unique()  # type: ignore[return-value]
 
diff --git a/noxfile.py b/noxfile.py
index c17e41ae..49bb6053 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -14,10 +14,12 @@ PYTHON_VERSIONS = ["3.8", "3.9", "3.10", "3.11", "3.12"]
 
 
 def run_common(session: Session, coverage_threshold: float) -> None:
-    session.install("-e .[dev]")
-
-    if session.python != "3.8":
-        session.install("ibis-framework>=6.0.0", "rich", "packaging", "pyarrow_hotfix")
+    if session.python == "3.12":
+        session.install("-e .[dev,extra,dask,modin]")
+    elif session.python != "3.8":
+        session.install("-e .[dev,extra,dask,modin,pyspark,ibis]")
+    else:
+        session.install("-e .[dev]")
 
     session.run(
         "pytest",
@@ -34,11 +36,7 @@ def run_common(session: Session, coverage_threshold: float) -> None:
 
 @nox.session(python=PYTHON_VERSIONS)  # type: ignore[misc]
 def pytest_coverage(session: Session) -> None:
-    if session.python == "3.8":
-        coverage_threshold = 85
-    else:
-        coverage_threshold = 100
-        session.install("modin[dask]")
+    coverage_threshold = 85 if session.python == "3.8" else 100
 
     run_common(session, coverage_threshold)
 
diff --git a/pyproject.toml b/pyproject.toml
index 8193e028..45aa54ad 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,30 +25,30 @@ cudf = ["cudf>=24.10.0"]
 modin = ["modin"]
 pandas = ["pandas>=0.25.3"]
 polars = ["polars>=0.20.3"]
+ibis = ["ibis-framework>=6.0.0", "rich", "packaging", "pyarrow_hotfix"]
 pyarrow = ["pyarrow>=11.0.0"]
 dask = ["dask[dataframe]>=2024.7"]
+duckdb = ["duckdb>=1.0"]
 pyspark = ["pyspark>=3.3.0"]
 dev = [
-  "tqdm",
   "covdefaults",
-  "duckdb",
-  "pandas",
-  "polars",
   "pre-commit",
-  "pyarrow",
-  "pyarrow-stubs",
   "pytest",
   "pytest-cov",
   "pytest-randomly",
   "pytest-env",
-  "hypothesis[numpy]",
+  "hypothesis",
   "typing_extensions",
 ]
+core = [
+  "duckdb",
+  "pandas",
+  "polars",
+  "pyarrow",
+  "pyarrow-stubs",
+]
 extra = [  # heavier dependencies we don't necessarily need in every testing job
   "scikit-learn",
-  "pyspark; python_version >= '3.9' and python_version < '3.12'",
-  "dask[dataframe]; python_version >= '3.9'",
-  "modin",
 ]
 docs = [
   "black",  # required by mkdocstrings_handlers
@@ -110,9 +110,8 @@ lint.ignore = [
   "E501",
   "FIX",
   "ISC001",
-  "NPY002",
-  "PD901",  # This is a auxiliary library so dataframe variables have no concrete business meaning
   "PD010",
+  "PD901",  # This is a auxiliary library so dataframe variables have no concrete business meaning
   "PLR0911",
   "PLR0912",
   "PLR0913",
diff --git a/tpch/generate_data.py b/tpch/generate_data.py
index d0a370a2..31872a62 100644
--- a/tpch/generate_data.py
+++ b/tpch/generate_data.py
@@ -5,7 +5,6 @@ from pathlib import Path
 import duckdb
 import pyarrow as pa
 import pyarrow.parquet as pq
-import tqdm
 
 if not Path("data").exists():
     Path("data").mkdir()
@@ -23,7 +22,7 @@ tables = [
     "region",
     "supplier",
 ]
-for t in tqdm.tqdm(tables):
+for t in tables:
     res = con.query("SELECT * FROM " + t)  # noqa: S608
     res_arrow = res.to_arrow_table()
     new_schema = []


Test patch:
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
index 5c099bfa..b02b74c0 100644
--- a/.github/workflows/pytest.yml
+++ b/.github/workflows/pytest.yml
@@ -25,11 +25,11 @@ jobs:
           cache-dependency-glob: "pyproject.toml"
       - name: install-reqs
         # Python3.8 is technically at end-of-life, so we don't test everything
-        run: uv pip install -e ".[dev]" --system
+        run: uv pip install -e ".[dev, core]" --system
       - name: show-deps
         run: uv pip freeze
       - name: Run pytest
-        run: pytest tests --cov=narwhals --cov=tests --cov-fail-under=85
+        run: pytest tests --cov=narwhals --cov=tests --cov-fail-under=85 --constructors=pandas,pyarrow,polars[eager],polars[lazy]
 
   pytest-windows:
     strategy:
@@ -49,11 +49,12 @@ jobs:
           cache-suffix: ${{ matrix.python-version }}
           cache-dependency-glob: "pyproject.toml"
       - name: install-reqs
-        run: uv pip install -e ".[dev, extra]" --system
+        run: uv pip install -e ".[dev, core, extra, dask, modin]" --system
       - name: show-deps
         run: uv pip freeze
       - name: Run pytest
-        run: pytest tests --cov=narwhals --cov=tests --runslow --cov-fail-under=95
+        run: |
+          pytest tests --cov=narwhals --cov=tests --runslow --cov-fail-under=95 --all-cpu-constructors
 
   pytest-full-coverage:
     strategy:
@@ -73,16 +74,20 @@ jobs:
           cache-suffix: ${{ matrix.python-version }}
           cache-dependency-glob: "pyproject.toml"
       - name: install-reqs
-        run: uv pip install -e ".[dev, extra]" --system
+        run: uv pip install -e ".[dev, core, extra, modin, dask]" --system
+      - name: install pyspark
+        run: uv pip install -e ".[pyspark]" --system
+        # PySpark is not yet available on Python3.12+
+        if: matrix.python-version == '3.9' || matrix.python-version == '3.11'
       - name: install ibis
-        run: uv pip install ibis-framework>=6.0.0 rich packaging pyarrow_hotfix --system
+        run: uv pip install -e ".[ibis]" --system
         # Ibis puts upper bounds on dependencies, and requires Python3.10+,
         # which messes with other dependencies on lower Python versions
         if: matrix.python-version == '3.11'
       - name: show-deps
         run: uv pip freeze
       - name: Run pytest
-        run: pytest tests --cov=narwhals --cov=tests --cov-fail-under=100 --runslow
+        run: pytest tests --cov=narwhals --cov=tests --cov-fail-under=100 --runslow --all-cpu-constructors
       - name: Run doctests
         # reprs differ between versions, so we only run doctests on the latest Python
         if: matrix.python-version == '3.13'
diff --git a/.github/workflows/random_ci_pytest.yml b/.github/workflows/random_ci_pytest.yml
index 599dab3c..67f416c2 100644
--- a/.github/workflows/random_ci_pytest.yml
+++ b/.github/workflows/random_ci_pytest.yml
@@ -22,17 +22,15 @@ jobs:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
           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 -e ".[dev]" --system
-      - name: uninstall scipy/sklearn
-        run: uv pip uninstall scipy scikit-learn --system
       - name: install-random-verions
         run: uv pip install -r random-requirements.txt --system
+      - name: install-narwhals
+        run: uv pip install -e ".[dev]" --system
       - name: show versions
         run: uv pip freeze
       - name: Run pytest
-        run: pytest tests --cov=narwhals --cov=tests --cov-fail-under=80
+        run: |
+            pytest tests --cov=narwhals --cov=tests --cov-fail-under=80 \
+            --constructors=pandas,pyarrow,polars[eager],polars[lazy]
diff --git a/tests/conftest.py b/tests/conftest.py
index cb8a982a..28fbc761 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,54 +1,67 @@
 from __future__ import annotations
 
-import contextlib
+import os
 from typing import TYPE_CHECKING
 from typing import Any
+from typing import Callable
 from typing import Generator
+from typing import Sequence
 
 import pandas as pd
 import polars as pl
 import pyarrow as pa
 import pytest
 
-from narwhals.dependencies import get_dask_dataframe
-from narwhals.stable.v1.dependencies import get_cudf
-from narwhals.stable.v1.dependencies import get_modin
-from tests.utils import PANDAS_VERSION
-
 if TYPE_CHECKING:
     from narwhals.typing import IntoDataFrame
     from narwhals.typing import IntoFrame
-    from tests.utils import Constructor
-    from tests.utils import ConstructorEager
-
-with contextlib.suppress(ImportError):
-    import modin.pandas  # noqa: F401
-with contextlib.suppress(ImportError):
-    import dask.dataframe  # noqa: F401
-with contextlib.suppress(ImportError):
-    import cudf  # noqa: F401
-with contextlib.suppress(ImportError):
-    from pyspark.sql import SparkSession
 
 if TYPE_CHECKING:
     from pyspark.sql import SparkSession
 
     from narwhals.typing import IntoDataFrame
     from narwhals.typing import IntoFrame
-    from tests.utils import Constructor
+
+# When testing cudf.pandas in Kaggle, we get an error if we try to run
+# python -m cudf.pandas -m pytest --constructors=pandas. This gives us
+# a way to run `python -m cudf.pandas -m pytest` and control which constructors
+# get tested.
+if default_constructors := os.environ.get(
+    "NARWHALS_DEFAULT_CONSTRUCTORS", None
+):  # pragma: no cover
+    DEFAULT_CONSTRUCTORS = default_constructors
+else:
+    DEFAULT_CONSTRUCTORS = (
+        "pandas,pandas[nullable],pandas[pyarrow],polars[eager],polars[lazy],pyarrow"
+    )
 
 
 def pytest_addoption(parser: Any) -> None:
     parser.addoption(
         "--runslow", action="store_true", default=False, help="run slow tests"
     )
+    parser.addoption(
+        "--all-cpu-constructors",
+        action="store_true",
+        default=False,
+        help="run tests with all cpu constructors",
+    )
+    parser.addoption(
+        "--constructors",
+        action="store",
+        default=DEFAULT_CONSTRUCTORS,
+        type=str,
+        help="libraries to test",
+    )
 
 
-def pytest_configure(config: Any) -> None:
+def pytest_configure(config: pytest.Config) -> None:
     config.addinivalue_line("markers", "slow: mark test as slow to run")
 
 
-def pytest_collection_modifyitems(config: Any, items: Any) -> Any:  # pragma: no cover
+def pytest_collection_modifyitems(
+    config: pytest.Config, items: Sequence[pytest.Function]
+) -> None:  # pragma: no cover
     if config.getoption("--runslow"):
         # --runslow given in cli: do not skip slow tests
         return
@@ -71,12 +84,20 @@ def pandas_pyarrow_constructor(obj: Any) -> IntoDataFrame:
 
 
 def modin_constructor(obj: Any) -> IntoDataFrame:  # pragma: no cover
-    mpd = get_modin()
+    import modin.pandas as mpd
+
+    return mpd.DataFrame(pd.DataFrame(obj))  # type: ignore[no-any-return]
+
+
+def modin_pyarrow_constructor(obj: Any) -> IntoDataFrame:  # pragma: no cover
+    import modin.pandas as mpd
+
     return mpd.DataFrame(pd.DataFrame(obj)).convert_dtypes(dtype_backend="pyarrow")  # type: ignore[no-any-return]
 
 
 def cudf_constructor(obj: Any) -> IntoDataFrame:  # pragma: no cover
-    cudf = get_cudf()
+    import cudf
+
     return cudf.DataFrame(obj)  # type: ignore[no-any-return]
 
 
@@ -89,12 +110,14 @@ def polars_lazy_constructor(obj: Any) -> pl.LazyFrame:
 
 
 def dask_lazy_p1_constructor(obj: Any) -> IntoFrame:  # pragma: no cover
-    dd = get_dask_dataframe()
+    import dask.dataframe as dd
+
     return dd.from_dict(obj, npartitions=1)  # type: ignore[no-any-return]
 
 
 def dask_lazy_p2_constructor(obj: Any) -> IntoFrame:  # pragma: no cover
-    dd = get_dask_dataframe()
+    import dask.dataframe as dd
+
     return dd.from_dict(obj, npartitions=2)  # type: ignore[no-any-return]
 
 
@@ -110,7 +133,6 @@ def spark_session() -> Generator[SparkSession, None, None]:  # pragma: no cover
         pytest.skip("pyspark is not installed")
         return
 
-    import os
     import warnings
 
     os.environ["PYARROW_IGNORE_TIMEZONE"] = "1"
@@ -133,35 +155,56 @@ def spark_session() -> Generator[SparkSession, None, None]:  # pragma: no cover
     session.stop()
 
 
-if PANDAS_VERSION >= (2, 0, 0):
-    eager_constructors = [
-        pandas_constructor,
-        pandas_nullable_constructor,
-        pandas_pyarrow_constructor,
-    ]
-else:  # pragma: no cover
-    eager_constructors = [pandas_constructor]
-
-eager_constructors.extend([polars_eager_constructor, pyarrow_table_constructor])
-lazy_constructors = [polars_lazy_constructor]
-
-if get_modin() is not None:  # pragma: no cover
-    eager_constructors.append(modin_constructor)
-if get_cudf() is not None:
-    eager_constructors.append(cudf_constructor)  # pragma: no cover
-if get_dask_dataframe() is not None:  # pragma: no cover
-    # TODO(unassigned): reinstate both dask constructors once if/when we have a dask use-case
-    # lazy_constructors.extend([dask_lazy_p1_constructor, dask_lazy_p2_constructor])  # noqa: ERA001
-    lazy_constructors.append(dask_lazy_p2_constructor)  # type: ignore  # noqa: PGH003
-
-
-@pytest.fixture(params=eager_constructors)
-def constructor_eager(
-    request: pytest.FixtureRequest,
-) -> ConstructorEager:
-    return request.param  # type: ignore[no-any-return]
-
-
-@pytest.fixture(params=[*eager_constructors, *lazy_constructors])
-def constructor(request: pytest.FixtureRequest) -> Constructor:
-    return request.param  # type: ignore[no-any-return]
+EAGER_CONSTRUCTORS: dict[str, Callable[[Any], IntoDataFrame]] = {
+    "pandas": pandas_constructor,
+    "pandas[nullable]": pandas_nullable_constructor,
+    "pandas[pyarrow]": pandas_pyarrow_constructor,
+    "pyarrow": pyarrow_table_constructor,
+    "modin": modin_constructor,
+    "modin[pyarrow]": modin_pyarrow_constructor,
+    "cudf": cudf_constructor,
+    "polars[eager]": polars_eager_constructor,
+}
+LAZY_CONSTRUCTORS: dict[str, Callable[[Any], IntoFrame]] = {
+    "dask": dask_lazy_p2_constructor,
+    "polars[lazy]": polars_lazy_constructor,
+}
+GPU_CONSTRUCTORS: dict[str, Callable[[Any], IntoFrame]] = {"cudf": cudf_constructor}
+
+
+def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
+    if metafunc.config.getoption("all_cpu_constructors"):
+        selected_constructors: list[str] = [
+            *iter(EAGER_CONSTRUCTORS.keys()),
+            *iter(LAZY_CONSTRUCTORS.keys()),
+        ]
+        selected_constructors = [
+            x for x in selected_constructors if x not in GPU_CONSTRUCTORS
+        ]
+    else:  # pragma: no cover
+        selected_constructors = metafunc.config.getoption("constructors").split(",")
+
+    eager_constructors: list[Callable[[Any], IntoDataFrame]] = []
+    eager_constructors_ids: list[str] = []
+    constructors: list[Callable[[Any], IntoFrame]] = []
+    constructors_ids: list[str] = []
+
+    for constructor in selected_constructors:
+        if constructor in EAGER_CONSTRUCTORS:
+            eager_constructors.append(EAGER_CONSTRUCTORS[constructor])
+            eager_constructors_ids.append(constructor)
+            constructors.append(EAGER_CONSTRUCTORS[constructor])
+            constructors_ids.append(constructor)
+        elif constructor in LAZY_CONSTRUCTORS:
+            constructors.append(LAZY_CONSTRUCTORS[constructor])
+            constructors_ids.append(constructor)
+        else:  # pragma: no cover
+            msg = f"Expected one of {EAGER_CONSTRUCTORS.keys()} or {LAZY_CONSTRUCTORS.keys()}, got {constructor}"
+            raise ValueError(msg)
+
+    if "constructor_eager" in metafunc.fixturenames:
+        metafunc.parametrize(
+            "constructor_eager", eager_constructors, ids=eager_constructors_ids
+        )
+    elif "constructor" in metafunc.fixturenames:
+        metafunc.parametrize("constructor", constructors, ids=constructors_ids)
diff --git a/tests/dtypes_test.py b/tests/dtypes_test.py
index aa497785..97ca384c 100644
--- a/tests/dtypes_test.py
+++ b/tests/dtypes_test.py
@@ -5,7 +5,6 @@ from datetime import timedelta
 from datetime import timezone
 from typing import Literal
 
-import duckdb
 import numpy as np
 import pandas as pd
 import polars as pl
@@ -201,6 +200,7 @@ def test_pandas_fixed_offset_1302() -> None:
 
 
 def test_huge_int() -> None:
+    duckdb = pytest.importorskip("duckdb")
     df = pl.DataFrame({"a": [1, 2, 3]})
     if POLARS_VERSION >= (1, 18):  # pragma: no cover
         result = nw.from_native(df.select(pl.col("a").cast(pl.Int128))).schema
@@ -227,6 +227,7 @@ def test_huge_int() -> None:
 
 @pytest.mark.skipif(PANDAS_VERSION < (1, 5), reason="too old for pyarrow")
 def test_decimal() -> None:
+    duckdb = pytest.importorskip("duckdb")
     df = pl.DataFrame({"a": [1]}, schema={"a": pl.Decimal})
     result = nw.from_native(df).schema
     assert result["a"] == nw.Decimal
diff --git a/tests/expr_and_series/arithmetic_test.py b/tests/expr_and_series/arithmetic_test.py
index 12f931ba..eb38c6a1 100644
--- a/tests/expr_and_series/arithmetic_test.py
+++ b/tests/expr_and_series/arithmetic_test.py
@@ -38,7 +38,7 @@ def test_arithmetic_expr(
     request: pytest.FixtureRequest,
 ) -> None:
     if attr == "__mod__" and any(
-        x in str(constructor) for x in ["pandas_pyarrow", "modin"]
+        x in str(constructor) for x in ["pandas_pyarrow", "modin_pyarrow"]
     ):
         request.applymarker(pytest.mark.xfail)
 
@@ -68,7 +68,7 @@ def test_right_arithmetic_expr(
     request: pytest.FixtureRequest,
 ) -> None:
     if attr == "__rmod__" and any(
-        x in str(constructor) for x in ["pandas_pyarrow", "modin"]
+        x in str(constructor) for x in ["pandas_pyarrow", "modin_pyarrow"]
     ):
         request.applymarker(pytest.mark.xfail)
 
@@ -99,7 +99,7 @@ def test_arithmetic_series(
     request: pytest.FixtureRequest,
 ) -> None:
     if attr == "__mod__" and any(
-        x in str(constructor_eager) for x in ["pandas_pyarrow", "modin"]
+        x in str(constructor_eager) for x in ["pandas_pyarrow", "modin_pyarrow"]
     ):
         request.applymarker(pytest.mark.xfail)
 
@@ -129,7 +129,7 @@ def test_right_arithmetic_series(
     request: pytest.FixtureRequest,
 ) -> None:
     if attr == "__rmod__" and any(
-        x in str(constructor_eager) for x in ["pandas_pyarrow", "modin"]
+        x in str(constructor_eager) for x in ["pandas_pyarrow", "modin_pyarrow"]
     ):
         request.applymarker(pytest.mark.xfail)
 
@@ -242,7 +242,7 @@ def test_arithmetic_expr_left_literal(
     request: pytest.FixtureRequest,
 ) -> None:
     if attr == "__mod__" and any(
-        x in str(constructor) for x in ["pandas_pyarrow", "modin"]
+        x in str(constructor) for x in ["pandas_pyarrow", "modin_pyarrow"]
     ):
         request.applymarker(pytest.mark.xfail)
 
@@ -273,7 +273,7 @@ def test_arithmetic_series_left_literal(
     request: pytest.FixtureRequest,
 ) -> None:
     if attr == "__mod__" and any(
-        x in str(constructor_eager) for x in ["pandas_pyarrow", "modin"]
+        x in str(constructor_eager) for x in ["pandas_pyarrow", "modin_pyarrow"]
     ):
         request.applymarker(pytest.mark.xfail)
 
diff --git a/tests/expr_and_series/cast_test.py b/tests/expr_and_series/cast_test.py
index 992ea5f5..8cdceb9c 100644
--- a/tests/expr_and_series/cast_test.py
+++ b/tests/expr_and_series/cast_test.py
@@ -63,7 +63,7 @@ def test_cast(
         15,
     ):  # pragma: no cover
         request.applymarker(pytest.mark.xfail)
-    if "modin" in str(constructor):
+    if "modin_constructor" in str(constructor):
         # TODO(unassigned): in modin, we end up with `'<U0'` dtype
         request.applymarker(pytest.mark.xfail)
     df = nw.from_native(constructor(data)).select(
@@ -116,7 +116,7 @@ def test_cast_series(
         15,
     ):  # pragma: no cover
         request.applymarker(pytest.mark.xfail)
-    if "modin" in str(constructor):
+    if "modin_constructor" in str(constructor):
         # TODO(unassigned): in modin, we end up with `'<U0'` dtype
         request.applymarker(pytest.mark.xfail)
     df = (
diff --git a/tests/expr_and_series/convert_time_zone_test.py b/tests/expr_and_series/convert_time_zone_test.py
index fbe33f9a..aa423554 100644
--- a/tests/expr_and_series/convert_time_zone_test.py
+++ b/tests/expr_and_series/convert_time_zone_test.py
@@ -23,8 +23,10 @@ def test_convert_time_zone(
     request: pytest.FixtureRequest,
 ) -> None:
     if (
-        (any(x in str(constructor) for x in ("pyarrow", "modin")) and is_windows())
+        ("pyarrow" in str(constructor) and is_windows())
+        or ("pyarrow_table" in str(constructor) and is_windows())
         or ("pandas_pyarrow" in str(constructor) and PANDAS_VERSION < (2, 1))
+        or ("modin_pyarrow" in str(constructor) and PANDAS_VERSION < (2, 1))
         or ("cudf" in str(constructor))
     ):
         request.applymarker(pytest.mark.xfail)
@@ -49,8 +51,10 @@ def test_convert_time_zone_series(
     request: pytest.FixtureRequest,
 ) -> None:
     if (
-        (any(x in str(constructor_eager) for x in ("pyarrow", "modin")) and is_windows())
+        ("pyarrow" in str(constructor_eager) and is_windows())
+        or ("pyarrow_table" in str(constructor_eager) and is_windows())
         or ("pandas_pyarrow" in str(constructor_eager) and PANDAS_VERSION < (2, 1))
+        or ("modin_pyarrow" in str(constructor_eager) and PANDAS_VERSION < (2, 1))
         or ("cudf" in str(constructor_eager))
     ):
         request.applymarker(pytest.mark.xfail)
@@ -74,8 +78,10 @@ def test_convert_time_zone_from_none(
     constructor: Constructor, request: pytest.FixtureRequest
 ) -> None:
     if (
-        (any(x in str(constructor) for x in ("pyarrow", "modin")) and is_windows())
+        ("pyarrow" in str(constructor) and is_windows())
+        or ("pyarrow_table" in str(constructor) and is_windows())
         or ("pandas_pyarrow" in str(constructor) and PANDAS_VERSION < (2, 1))
+        or ("modin_pyarrow" in str(constructor) and PANDAS_VERSION < (2, 1))
         or ("pyarrow_table" in str(constructor) and PYARROW_VERSION < (12,))
         or ("cudf" in str(constructor))
     ):
diff --git a/tests/expr_and_series/dt/datetime_attributes_test.py b/tests/expr_and_series/dt/datetime_attributes_test.py
index 0e4c7c99..0235920e 100644
--- a/tests/expr_and_series/dt/datetime_attributes_test.py
+++ b/tests/expr_and_series/dt/datetime_attributes_test.py
@@ -95,6 +95,8 @@ def test_datetime_chained_attributes(
 ) -> None:
     if "pandas" in str(constructor_eager) and "pyarrow" not in str(constructor_eager):
         request.applymarker(pytest.mark.xfail)
+    if "modin" in str(constructor_eager) and "pyarrow" not in str(constructor_eager):
+        request.applymarker(pytest.mark.xfail)
     if "cudf" in str(constructor_eager):
         request.applymarker(pytest.mark.xfail)
 
@@ -109,7 +111,12 @@ def test_datetime_chained_attributes(
 def test_to_date(request: pytest.FixtureRequest, constructor: Constructor) -> None:
     if any(
         x in str(constructor)
-        for x in ("pandas_constructor", "pandas_nullable_constructor", "cudf")
+        for x in (
+            "pandas_constructor",
+            "pandas_nullable_constructor",
+            "cudf",
+            "modin_constructor",
+        )
     ):
         request.applymarker(pytest.mark.xfail)
     dates = {"a": [datetime(2001, 1, 1), None, datetime(2001, 1, 3)]}
diff --git a/tests/expr_and_series/dt/timestamp_test.py b/tests/expr_and_series/dt/timestamp_test.py
index 51782794..e205d817 100644
--- a/tests/expr_and_series/dt/timestamp_test.py
+++ b/tests/expr_and_series/dt/timestamp_test.py
@@ -138,7 +138,12 @@ def test_timestamp_dates(
 ) -> None:
     if any(
         x in str(constructor)
-        for x in ("pandas_constructor", "pandas_nullable_constructor", "cudf")
+        for x in (
+            "pandas_constructor",
+            "pandas_nullable_constructor",
+            "cudf",
+            "modin_constructor",
+        )
     ):
         request.applymarker(pytest.mark.xfail)
 
diff --git a/tests/expr_and_series/is_finite_test.py b/tests/expr_and_series/is_finite_test.py
index 90f4c3b6..270ba7d5 100644
--- a/tests/expr_and_series/is_finite_test.py
+++ b/tests/expr_and_series/is_finite_test.py
@@ -14,9 +14,13 @@ data = {"a": [float("nan"), float("inf"), 2.0, None]}
 def test_is_finite_expr(constructor: Constructor) -> None:
     if "polars" in str(constructor) or "pyarrow_table" in str(constructor):
         expected = {"a": [False, False, True, None]}
-    elif "pandas_constructor" in str(constructor) or "dask" in str(constructor):
+    elif (
+        "pandas_constructor" in str(constructor)
+        or "dask" in str(constructor)
+        or "modin_constructor" in str(constructor)
+    ):
         expected = {"a": [False, False, True, False]}
-    else:  # pandas_nullable_constructor, pandas_pyarrow_constructor, modin
+    else:  # pandas_nullable_constructor, pandas_pyarrow_constructor, modin_pyarrrow_constructor
         expected = {"a": [None, False, True, None]}
 
     df = nw.from_native(constructor(data))
@@ -28,11 +32,13 @@ def test_is_finite_expr(constructor: Constructor) -> None:
 def test_is_finite_series(constructor_eager: ConstructorEager) -> None:
     if "polars" in str(constructor_eager) or "pyarrow_table" in str(constructor_eager):
         expected = {"a": [False, False, True, None]}
-    elif "pandas_constructor" in str(constructor_eager) or "dask" in str(
-        constructor_eager
+    elif (
+        "pandas_constructor" in str(constructor_eager)
+        or "dask" in str(constructor_eager)
+        or "modin_constructor" in str(constructor_eager)
     ):
         expected = {"a": [False, False, True, False]}
-    else:  # pandas_nullable_constructor, pandas_pyarrow_constructor, modin
+    else:  # pandas_nullable_constructor, pandas_pyarrow_constructor, modin_pyarrrow_constructor
         expected = {"a": [None, False, True, None]}
 
     df = nw.from_native(constructor_eager(data), eager_only=True)
diff --git a/tests/expr_and_series/over_test.py b/tests/expr_and_series/over_test.py
index e824c6b7..a67c7973 100644
--- a/tests/expr_and_series/over_test.py
+++ b/tests/expr_and_series/over_test.py
@@ -138,7 +138,7 @@ def test_over_cummin(request: pytest.FixtureRequest, constructor: Constructor) -
 
 
 def test_over_cumprod(request: pytest.FixtureRequest, constructor: Constructor) -> None:
-    if any(x in str(constructor) for x in ("pyarrow_table", "dask_lazy_p2", "cudf")):
+    if any(x in str(constructor) for x in ("pyarrow_table", "dask_lazy_p2")):
         request.applymarker(pytest.mark.xfail)
 
     if "pandas_pyarrow" in str(constructor) and PANDAS_VERSION < (2, 1):
diff --git a/tests/expr_and_series/replace_time_zone_test.py b/tests/expr_and_series/replace_time_zone_test.py
index 76e78604..94367d1e 100644
--- a/tests/expr_and_series/replace_time_zone_test.py
+++ b/tests/expr_and_series/replace_time_zone_test.py
@@ -21,8 +21,9 @@ def test_replace_time_zone(
     constructor: Constructor, request: pytest.FixtureRequest
 ) -> None:
     if (
-        (any(x in str(constructor) for x in ("pyarrow", "modin")) and is_windows())
+        ("pyarrow" in str(constructor) and is_windows())
         or ("pandas_pyarrow" in str(constructor) and PANDAS_VERSION < (2,))
+        or ("modin_pyarrow" in str(constructor) and PANDAS_VERSION < (2,))
         or ("pyarrow_table" in str(constructor) and PYARROW_VERSION < (12,))
         or ("cudf" in str(constructor))
     ):
@@ -47,8 +48,9 @@ def test_replace_time_zone_none(
     constructor: Constructor, request: pytest.FixtureRequest
 ) -> None:
     if (
-        (any(x in str(constructor) for x in ("pyarrow", "modin")) and is_windows())
+        ("pyarrow" in str(constructor) and is_windows())
         or ("pandas_pyarrow" in str(constructor) and PANDAS_VERSION < (2,))
+        or ("modin_pyarrow" in str(constructor) and PANDAS_VERSION < (2,))
         or ("pyarrow_table" in str(constructor) and PYARROW_VERSION < (12,))
     ):
         request.applymarker(pytest.mark.xfail)
@@ -72,8 +74,9 @@ def test_replace_time_zone_series(
     constructor_eager: ConstructorEager, request: pytest.FixtureRequest
 ) -> None:
     if (
-        (any(x in str(constructor_eager) for x in ("pyarrow", "modin")) and is_windows())
+        ("pyarrow" in str(constructor_eager) and is_windows())
         or ("pandas_pyarrow" in str(constructor_eager) and PANDAS_VERSION < (2,))
+        or ("modin_pyarrow" in str(constructor_eager) and PANDAS_VERSION < (2,))
         or ("pyarrow_table" in str(constructor_eager) and PYARROW_VERSION < (12,))
         or ("cudf" in str(constructor_eager))
     ):
@@ -98,8 +101,9 @@ def test_replace_time_zone_none_series(
     constructor_eager: ConstructorEager, request: pytest.FixtureRequest
 ) -> None:
     if (
-        (any(x in str(constructor_eager) for x in ("pyarrow", "modin")) and is_windows())
+        ("pyarrow" in str(constructor_eager) and is_windows())
         or ("pandas_pyarrow" in str(constructor_eager) and PANDAS_VERSION < (2,))
+        or ("modin_pyarrow" in str(constructor_eager) and PANDAS_VERSION < (2,))
         or ("pyarrow_table" in str(constructor_eager) and PYARROW_VERSION < (12,))
     ):
         request.applymarker(pytest.mark.xfail)
diff --git a/tests/expr_and_series/str/to_uppercase_to_lowercase_test.py b/tests/expr_and_series/str/to_uppercase_to_lowercase_test.py
index 6ab26ac4..1d0eb883 100644
--- a/tests/expr_and_series/str/to_uppercase_to_lowercase_test.py
+++ b/tests/expr_and_series/str/to_uppercase_to_lowercase_test.py
@@ -38,7 +38,7 @@ def test_str_to_uppercase(
         in (
             "pandas_pyarrow_constructor",
             "pyarrow_table_constructor",
-            "modin_constructor",
+            "modin_pyarrow_constructor",
         )
         or ("dask" in str(constructor) and PYARROW_VERSION >= (12,))
     ):
@@ -80,6 +80,7 @@ def test_str_to_uppercase_series(
             "pandas_nullable_constructor",
             "polars_eager_constructor",
             "cudf_constructor",
+            "modin_constructor",
         )
     ):
         # We are marking it xfail for these conditions above
diff --git a/tests/frame/drop_test.py b/tests/frame/drop_test.py
index 2d6115c1..eb9bb266 100644
--- a/tests/frame/drop_test.py
+++ b/tests/frame/drop_test.py
@@ -48,7 +48,7 @@ def test_drop_strict(
     *,
     strict: bool,
 ) -> None:
-    if "polars_lazy" in str(request) and POLARS_VERSION < (1, 0, 0) and strict:
+    if "polars_lazy" in str(constructor) and POLARS_VERSION < (1, 0, 0) and strict:
         request.applymarker(pytest.mark.xfail)
 
     data = {"a": [1, 3, 2], "b": [4, 4, 6]}
diff --git a/tests/frame/interchange_native_namespace_test.py b/tests/frame/interchange_native_namespace_test.py
index 22d03646..865a675a 100644
--- a/tests/frame/interchange_native_namespace_test.py
+++ b/tests/frame/interchange_native_namespace_test.py
@@ -1,6 +1,5 @@
 from __future__ import annotations
 
-import duckdb
 import polars as pl
 import pytest
 
@@ -45,6 +44,7 @@ def test_ibis(
 
 
 def test_duckdb() -> None:
+    duckdb = pytest.importorskip("duckdb")
     df_pl = pl.DataFrame(data)  # noqa: F841
 
     rel = duckdb.sql("select * from df_pl")
diff --git a/tests/frame/interchange_schema_test.py b/tests/frame/interchange_schema_test.py
index e06a482d..a9482811 100644
--- a/tests/frame/interchange_schema_test.py
+++ b/tests/frame/interchange_schema_test.py
@@ -4,7 +4,6 @@ from datetime import date
 from datetime import datetime
 from datetime import timedelta
 
-import duckdb
 import polars as pl
 import pytest
 
@@ -160,6 +159,7 @@ def test_interchange_schema_ibis(
 
 
 def test_interchange_schema_duckdb() -> None:
+    duckdb = pytest.importorskip("duckdb")
     df_pl = pl.DataFrame(  # noqa: F841
         {
             "a": [1, 1, 2],
diff --git a/tests/frame/interchange_select_test.py b/tests/frame/interchange_select_test.py
index b553af75..30d87880 100644
--- a/tests/frame/interchange_select_test.py
+++ b/tests/frame/interchange_select_test.py
@@ -2,7 +2,6 @@ from __future__ import annotations
 
 from typing import Any
 
-import duckdb
 import polars as pl
 import pytest
 
@@ -61,6 +60,7 @@ def test_interchange_ibis(
 
 
 def test_interchange_duckdb() -> None:
+    duckdb = pytest.importorskip("duckdb")
     df_pl = pl.DataFrame(data)  # noqa: F841
     rel = duckdb.sql("select * from df_pl")
     df = nw.from_native(rel, eager_or_interchange_only=True)
diff --git a/tests/frame/interchange_to_arrow_test.py b/tests/frame/interchange_to_arrow_test.py
index d1ddd2a5..1f01b67c 100644
--- a/tests/frame/interchange_to_arrow_test.py
+++ b/tests/frame/interchange_to_arrow_test.py
@@ -1,6 +1,5 @@
 from __future__ import annotations
 
-import duckdb
 import polars as pl
 import pyarrow as pa
 import pytest
@@ -35,6 +34,7 @@ def test_interchange_ibis_to_arrow(
 
 
 def test_interchange_duckdb_to_arrow() -> None:
+    duckdb = pytest.importorskip("duckdb")
     df_pl = pl.DataFrame(data)  # noqa: F841
     rel = duckdb.sql("select * from df_pl")
     df = nw.from_native(rel, eager_or_interchange_only=True)
diff --git a/tests/frame/interchange_to_pandas_test.py b/tests/frame/interchange_to_pandas_test.py
index 938c23ea..7761a649 100644
--- a/tests/frame/interchange_to_pandas_test.py
+++ b/tests/frame/interchange_to_pandas_test.py
@@ -1,6 +1,5 @@
 from __future__ import annotations
 
-import duckdb
 import pandas as pd
 import pytest
 
@@ -39,6 +38,7 @@ def test_interchange_ibis_to_pandas(
 
 
 def test_interchange_duckdb_to_pandas(request: pytest.FixtureRequest) -> None:
+    duckdb = pytest.importorskip("duckdb")
     if PANDAS_VERSION < (1, 0, 0):
         request.applymarker(pytest.mark.xfail)
     df_raw = pd.DataFrame(data)
diff --git a/tests/frame/schema_test.py b/tests/frame/schema_test.py
index c2fdec31..9de39774 100644
--- a/tests/frame/schema_test.py
+++ b/tests/frame/schema_test.py
@@ -7,7 +7,6 @@ from datetime import timezone
 from typing import TYPE_CHECKING
 from typing import Any
 
-import duckdb
 import pandas as pd
 import polars as pl
 import pytest
@@ -215,6 +214,7 @@ def test_from_non_hashable_column_name() -> None:
     reason="too old for pyarrow types",
 )
 def test_nested_dtypes() -> None:
+    duckdb = pytest.importorskip("duckdb")
     df = pl.DataFrame(
         {"a": [[1, 2]], "b": [[1, 2]], "c": [{"a": 1}]},
         schema_overrides={"b": pl.Array(pl.Int64, 2)},
diff --git a/tests/frame/select_test.py b/tests/frame/select_test.py
index b06efe00..2cb3df91 100644
--- a/tests/frame/select_test.py
+++ b/tests/frame/select_test.py
@@ -9,6 +9,7 @@ import pytest
 import narwhals.stable.v1 as nw
 from narwhals.exceptions import ColumnNotFoundError
 from narwhals.exceptions import InvalidIntoExprError
+from tests.utils import DASK_VERSION
 from tests.utils import PANDAS_VERSION
 from tests.utils import POLARS_VERSION
 from tests.utils import Constructor
@@ -112,3 +113,20 @@ def test_missing_columns(constructor: Constructor) -> None:
             df.drop(selected_columns, strict=True)
         with pytest.raises(ColumnNotFoundError, match=msg):
             df.select(nw.col("fdfa"))
+
+
+def test_left_to_right_broadcasting(
+    constructor: Constructor, request: pytest.FixtureRequest
+) -> None:
+    if "dask" in str(constructor) and DASK_VERSION < (2024, 9):
+        request.applymarker(pytest.mark.xfail)
+    df = nw.from_native(constructor({"a": [1, 1, 2], "b": [4, 5, 6]}))
+    result = df.select(nw.col("a") + nw.col("b").sum())
+    expected = {"a": [16, 16, 17]}
+    assert_equal_data(result, expected)
+    result = df.select(nw.col("b").sum() + nw.col("a"))
+    expected = {"b": [16, 16, 17]}
+    assert_equal_data(result, expected)
+    result = df.select(nw.col("b").sum() + nw.col("a").sum())
+    expected = {"b": [19]}
+    assert_equal_data(result, expected)
diff --git a/tests/frame/to_numpy_test.py b/tests/frame/to_numpy_test.py
index 0b631a3d..e1179f3a 100644
--- a/tests/frame/to_numpy_test.py
+++ b/tests/frame/to_numpy_test.py
@@ -31,10 +31,7 @@ def test_to_numpy_tz_aware(
     if (
         ("pyarrow_table" in str(constructor_eager) and PYARROW_VERSION < (12,))
         or ("pandas_pyarrow" in str(constructor_eager) and PANDAS_VERSION < (2, 2))
-        or (
-            any(x in str(constructor_eager) for x in ("pyarrow", "modin"))
-            and is_windows()
-        )
+        or ("pyarrow" in str(constructor_eager) and is_windows())
     ):
         request.applymarker(pytest.mark.xfail)
     df = nw.from_native(
diff --git a/tests/frame/to_pandas_test.py b/tests/frame/to_pandas_test.py
index d9bce7a6..1bc588f3 100644
--- a/tests/frame/to_pandas_test.py
+++ b/tests/frame/to_pandas_test.py
@@ -19,16 +19,15 @@ if TYPE_CHECKING:
 )
 def test_convert_pandas(
     constructor_eager: ConstructorEager,
-    request: pytest.FixtureRequest,
 ) -> None:
-    if "modin" in str(constructor_eager):
-        request.applymarker(pytest.mark.xfail)
     data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
     df_raw = constructor_eager(data)
     result = nw.from_native(df_raw).to_pandas()  # type: ignore[union-attr]
 
     if constructor_eager.__name__.startswith("pandas"):
         expected = constructor_eager(data)
+    elif "modin_pyarrow" in str(constructor_eager):
+        expected = pd.DataFrame(data).convert_dtypes(dtype_backend="pyarrow")
     else:
         expected = pd.DataFrame(data)
 
diff --git a/tests/frame/unique_test.py b/tests/frame/unique_test.py
index c8079f59..96d5a8c2 100644
--- a/tests/frame/unique_test.py
+++ b/tests/frame/unique_test.py
@@ -1,5 +1,8 @@
 from __future__ import annotations
 
+from contextlib import nullcontext as does_not_raise
+from typing import Any
+
 import pytest
 
 import narwhals.stable.v1 as nw
@@ -17,6 +20,7 @@ data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
         ("last", {"a": [3, 2], "b": [4, 6], "z": [8.0, 9.0]}),
         ("any", {"a": [1, 2], "b": [4, 6], "z": [7.0, 9.0]}),
         ("none", {"a": [2], "b": [6], "z": [9]}),
+        ("foo", {"a": [2], "b": [6], "z": [9]}),
     ],
 )
 def test_unique(
@@ -27,14 +31,29 @@ def test_unique(
 ) -> None:
     df_raw = constructor(data)
     df = nw.from_native(df_raw)
+    if isinstance(df, nw.LazyFrame) and keep in {"first", "last"}:
+        context: Any = pytest.raises(ValueError, match="row order")
+    elif keep == "foo":
+        context = pytest.raises(ValueError, match=": foo")
+    else:
+        context = does_not_raise()
 
-    result = df.unique(subset, keep=keep, maintain_order=True)  # type: ignore[arg-type]
-    assert_equal_data(result, expected)
+    with context:
+        result = df.unique(subset, keep=keep).sort("z")  # type: ignore[arg-type]
+        assert_equal_data(result, expected)
 
 
+@pytest.mark.filterwarnings("ignore:.*backwards-compatibility:UserWarning")
 def test_unique_none(constructor: Constructor) -> None:
     df_raw = constructor(data)
     df = nw.from_native(df_raw)
 
-    result = df.unique(maintain_order=True)
+    result = df.unique(maintain_order=False).sort("z")
     assert_equal_data(result, data)
+
+    if isinstance(df, nw.LazyFrame):
+        with pytest.raises(ValueError, match="not supported"):
+            result = df.unique(maintain_order=True).sort("z")
+    else:
+        result = df.unique(maintain_order=True)
+        assert_equal_data(result, data)
diff --git a/tests/group_by_test.py b/tests/group_by_test.py
index f98508ef..188c17c7 100644
--- a/tests/group_by_test.py
+++ b/tests/group_by_test.py
@@ -75,12 +75,7 @@ def test_invalid_group_by() -> None:
         )
 
 
-def test_group_by_iter(
-    constructor_eager: ConstructorEager, request: pytest.FixtureRequest
-) -> None:
-    if "cudf" in str(constructor_eager):
-        # https://github.com/rapidsai/cudf/issues/17650
-        request.applymarker(pytest.mark.xfail)
+def test_group_by_iter(constructor_eager: ConstructorEager) -> None:
     df = nw.from_native(constructor_eager(data), eager_only=True)
     expected_keys = [(1,), (3,)]
     keys = []
@@ -321,8 +316,8 @@ def test_key_with_nulls_iter(
     constructor_eager: ConstructorEager,
     request: pytest.FixtureRequest,
 ) -> None:
-    if PANDAS_VERSION < (1, 3) and "pandas_constructor" in str(constructor_eager):
-        # bug in old pandas
+    if PANDAS_VERSION < (1, 0) and "pandas_constructor" in str(constructor_eager):
+        # Grouping by null values is not supported in pandas < 1.0.0
         request.applymarker(pytest.mark.xfail)
     data = {"b": ["4", "5", None, "7"], "a": [1, 2, 3, 4], "c": ["4", "3", None, None]}
     result = dict(
@@ -330,6 +325,7 @@ def test_key_with_nulls_iter(
         .group_by("b", "c", drop_null_keys=True)
         .__iter__()
     )
+
     assert len(result) == 2
     assert_equal_data(result[("4", "4")], {"b": ["4"], "a": [1], "c": ["4"]})
     assert_equal_data(result[("5", "3")], {"b": ["5"], "a": [2], "c": ["3"]})
@@ -415,7 +411,7 @@ def test_double_same_aggregation(
 def test_all_kind_of_aggs(
     constructor: Constructor, request: pytest.FixtureRequest
 ) -> None:
-    if any(x in str(constructor) for x in ("dask", "cudf", "modin_constructor")):
+    if any(x in str(constructor) for x in ("dask", "cudf", "modin")):
         # bugged in dask https://github.com/dask/dask/issues/11612
         # and modin lol https://github.com/modin-project/modin/issues/7414
         # and cudf https://github.com/rapidsai/cudf/issues/17649
diff --git a/tests/hypothesis/getitem_test.py b/tests/hypothesis/getitem_test.py
index f227bcd6..33c31f76 100644
--- a/tests/hypothesis/getitem_test.py
+++ b/tests/hypothesis/getitem_test.py
@@ -30,7 +30,9 @@ if TYPE_CHECKING:
     ],
     scope="module",
 )
-def constructor(request: pytest.FixtureRequest) -> Callable[[Any], IntoDataFrame]:
+def pandas_or_pyarrow_constructor(
+    request: pytest.FixtureRequest,
+) -> Callable[[Any], IntoDataFrame]:
     return request.param  # type: ignore[no-any-return]
 
 
@@ -140,7 +142,7 @@ def tuple_selector(draw: st.DrawFn) -> tuple[Any, Any]:
 )  # type: ignore[misc]
 @pytest.mark.slow
 def test_getitem(
-    constructor: Any,
+    pandas_or_pyarrow_constructor: Any,
     selector: Any,
 ) -> None:
     """Compare __getitem__ against polars."""
@@ -150,7 +152,7 @@ def test_getitem(
     # NotImplementedError: Slicing with step is not supported on PyArrow tables
     assume(
         not (
-            constructor is pyarrow_table_constructor
+            pandas_or_pyarrow_constructor is pyarrow_table_constructor
             and isinstance(selector, slice)
             and selector.step is not None
         )
@@ -159,7 +161,7 @@ def test_getitem(
     # IndexError: Offset must be non-negative (pyarrow does not support negative indexing)
     assume(
         not (
-            constructor is pyarrow_table_constructor
+            pandas_or_pyarrow_constructor is pyarrow_table_constructor
             and isinstance(selector, slice)
             and isinstance(selector.start, int)
             and selector.start < 0
@@ -167,7 +169,7 @@ def test_getitem(
     )
     assume(
         not (
-            constructor is pyarrow_table_constructor
+            pandas_or_pyarrow_constructor is pyarrow_table_constructor
             and isinstance(selector, slice)
             and isinstance(selector.stop, int)
             and selector.stop < 0
@@ -179,7 +181,7 @@ def test_getitem(
     # TypeError: Got unexpected argument type <class 'slice'> for compute function
     assume(
         not (
-            constructor is pyarrow_table_constructor
+            pandas_or_pyarrow_constructor is pyarrow_table_constructor
             and isinstance(selector, tuple)
             and isinstance(selector[0], slice)
             and isinstance(selector[1], slice)
@@ -194,7 +196,7 @@ def test_getitem(
     # ArrowNotImplementedError: Function 'array_take' has no kernel matching input types (int64, null)
     assume(
         not (
-            constructor is pyarrow_table_constructor
+            pandas_or_pyarrow_constructor is pyarrow_table_constructor
             and isinstance(selector, tuple)
             and isinstance(selector[0], list)
             and len(selector[0]) == 0
@@ -205,7 +207,7 @@ def test_getitem(
     # df[[], "a":], df[[], :] etc return different results between pandas/polars:
     assume(
         not (
-            constructor is pandas_constructor
+            pandas_or_pyarrow_constructor is pandas_constructor
             and isinstance(selector, tuple)
             and isinstance(selector[0], list)
             and len(selector[0]) == 0
@@ -236,7 +238,7 @@ def test_getitem(
         # rows/columns sides.
         return
 
-    df_other = nw.from_native(constructor(TEST_DATA))
+    df_other = nw.from_native(pandas_or_pyarrow_constructor(TEST_DATA))
     result_other = df_other[selector]
 
     if isinstance(result_polars, nw.Series):
diff --git a/tests/series_only/to_numpy_test.py b/tests/series_only/to_numpy_test.py
index 8e36ac12..166d1877 100644
--- a/tests/series_only/to_numpy_test.py
+++ b/tests/series_only/to_numpy_test.py
@@ -42,10 +42,8 @@ def test_to_numpy_tz_aware(
     if (
         ("pyarrow_table" in str(constructor_eager) and PYARROW_VERSION < (12,))
         or ("pandas_pyarrow" in str(constructor_eager) and PANDAS_VERSION < (2, 2))
-        or (
-            any(x in str(constructor_eager) for x in ("pyarrow", "modin"))
-            and is_windows()
-        )
+        or ("modin_pyarrow" in str(constructor_eager) and PANDAS_VERSION < (2, 2))
+        or ("pyarrow" in str(constructor_eager) and is_windows())
     ):
         request.applymarker(pytest.mark.xfail)
         request.applymarker(pytest.mark.xfail)
diff --git a/tests/series_only/to_pandas_test.py b/tests/series_only/to_pandas_test.py
index 387af270..a1c3985c 100644
--- a/tests/series_only/to_pandas_test.py
+++ b/tests/series_only/to_pandas_test.py
@@ -22,7 +22,7 @@ def test_convert(
 ) -> None:
     if any(
         cname in str(constructor_eager)
-        for cname in ("pandas_nullable", "pandas_pyarrow", "modin")
+        for cname in ("pandas_nullable", "pandas_pyarrow", "modin_pyarrow")
     ):
         request.applymarker(pytest.mark.xfail)
 
diff --git a/tests/spark_like_test.py b/tests/spark_like_test.py
index 3b9fa12f..99682b8f 100644
--- a/tests/spark_like_test.py
+++ b/tests/spark_like_test.py
@@ -548,35 +548,34 @@ def test_rename(pyspark_constructor: Constructor) -> None:
         ("none", {"a": [2], "b": [6], "z": [9]}),
     ],
 )
-@pytest.mark.filterwarnings("ignore:Argument `maintain_order=True` is unused")
 def test_unique(
     pyspark_constructor: Constructor,
     subset: str | list[str] | None,
     keep: str,
     expected: dict[str, list[float]],
 ) -> None:
-    context = (
-        does_not_raise()
-        if keep == "any"
-        else pytest.raises(
+    if keep == "any":
+        context: Any = does_not_raise()
+    elif keep == "none":
+        context = pytest.raises(
             ValueError,
             match=r"`LazyFrame.unique` with PySpark backend only supports `keep='any'`.",
         )
-    )
+    else:
+        context = pytest.raises(ValueError, match=f": {keep}")
 
     with context:
         data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
         df = nw.from_native(pyspark_constructor(data))
 
-        result = df.unique(subset, keep=keep, maintain_order=True)  # type: ignore[arg-type]
+        result = df.unique(subset, keep=keep).sort("z")  # type: ignore[arg-type]
         assert_equal_data(result, expected)
 
 
-@pytest.mark.filterwarnings("ignore:Argument `maintain_order=True` is unused")
 def test_unique_none(pyspark_constructor: Constructor) -> None:
     data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
     df = nw.from_native(pyspark_constructor(data))
-    result = df.unique(maintain_order=True)
+    result = df.unique().sort("z")
     assert_equal_data(result, data)
 
 
diff --git a/tests/utils.py b/tests/utils.py
index 907eafa6..60933046 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -32,6 +32,7 @@ IBIS_VERSION: tuple[int, ...] = get_module_version_as_tuple("ibis")
 NUMPY_VERSION: tuple[int, ...] = get_module_version_as_tuple("numpy")
 PANDAS_VERSION: tuple[int, ...] = get_module_version_as_tuple("pandas")
 POLARS_VERSION: tuple[int, ...] = get_module_version_as_tuple("polars")
+DASK_VERSION: tuple[int, ...] = get_module_version_as_tuple("dask")
 PYARROW_VERSION: tuple[int, ...] = get_module_version_as_tuple("pyarrow")
 PYSPARK_VERSION: tuple[int, ...] = get_module_version_as_tuple("pyspark")