Repository: narwhals-dev/narwhals
Pull request #821 (2024-08-20 11:39:37)

feat: dask expr `cast`
## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [x] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues 

- Related issue #637 
- Closes #

## Checklist

- [x] Code follows style guide (ruff)
- [ ] Tests added 
- [ ] Documented the changes

## If you have comments or can explain your changes, please do so below.

Implemented without preserving a dtype backend for now. Need to dig more into that :P

aidoskanapyanov: Some CI failures:
- Coverage is dropping for some reason for `pytest-38 (3.8, windows-latest)`
- Also, modin constructor is not failing, but marked as failing in pytest
FBruzzesi: > * Coverage is dropping for some reason for `pytest-38 (3.8, windows-latest)`

I will take a look and see if I can spot anything

> * Also, modin constructor is not failing, but marked as failing in pytest

That's typically good news even though... why is that happening now 😂?

aidoskanapyanov: > I wonder why `windows` and `linux` pytest coverage CI show different results. For some reason windows run is flagging lots of lines as uncovered, whereas linux is flagging them as green.

Yeah, nevermind that, I figure CI coverage reports are a bit more involved than I understand rn. Gotta dig into it first.
FBruzzesi: Hey there! 

I think the problem with coverage for python 3.8 is that dask is entirely skipped (min python version is 3.9), therefore as we increase total number of functions, the coverage will gradually decrease.

I think you just unluckily hit the threshold number to push the total below 90%.

As all other workflows are passing, I would not worry too much about it. I think we will need to be smarter about it or come with another threshold value.

Patch:
diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml
index 7b29b1cb..8f4c9881 100644
--- a/.github/release-drafter.yml
+++ b/.github/release-drafter.yml
@@ -9,31 +9,31 @@ autolabeler:
   - label: breaking
     title:
       # Example: feat!: ...
-      - '/^([B|b]uild|[C|c]hore|[CI|ci]|[D|d]epr|[D|d]oc|DOC|[F|f]eat|[F|f]ix|[P|p]erf|[R|r]efactor|[R|r]elease|[T|t]est)\! /'
+      - '/^([Bb]uild|[Cc]hore|CI|ci|[Dd]epr|[Dd]oc|DOC|[Ff]eat|[Ff]ix|[Pp]erf|[Rr]efactor|[Rr]elease|[Tt]est)\! /'
   - label: build
     title:
-      - '/^([B|b]uild)/'
+      - '/^([Bb]uild)/'
   - label: internal
     title:
-      - '/^(chore|ci|refactor|test|template|bench|Chore|CI|Refactor|Test|Template|Bench)'
+      - '/^(chore|ci|refactor|test|template|bench|Chore|CI|Refactor|Test|Template|Bench)/'
   - label: deprecation
     title:
-      - '/^([D|d]epr)/'
+      - '/^([Dd]epr)/'
   - label: documentation
     title:
-      - '/^([D|d]oc|DOC)/'
+      - '/^([Dd]oc|DOC)/'
   - label: enhancement
     title:
       - '/^(feat|enh|Feat|ENH|Enh)/'
   - label: fix
     title:
-      - '/^([F|f]ix)/'
+      - '/^([Ff]ix)/'
   - label: performance
     title:
-      - '/^([P|p]erf)/'
+      - '/^([Pp]erf)/'
   - label: release
     title:
-      - '/^([R|r]elease)/'
+      - '/^([Rr]elease)/'
       
 version-resolver:
   major:
diff --git a/.gitignore b/.gitignore
index a36db14e..d6edf57e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@ todo.md
 site/
 .coverage.*
 .nox
+*.lock
 
 docs/api-completeness/*.md
 !docs/api-completeness/index.md
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b1eb91b0..d36d21a5 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -2,7 +2,50 @@
 
 Thank you for your interest in contributing to Narwhals! Any kind of improvement is welcome!
 
-## Setting up your environment
+## Local development vs Codespaces
+You can contribute to Narwhals in your local development environment, using python3, git and your editor of choice.
+You can also contribute to Narwhals using [Github Codespaces](https://docs.github.com/en/codespaces/overview) - a development environment that's hosted in the cloud.
+This way you can easily start to work from your browser without installing git and cloning the repo.
+Scroll down for instructions on how to use [Codespaces](#working-with-codespaces).
+
+## Working with local development environment
+
+### 1. Make sure you have git on your machine and a GitHub account
+
+Open your terminal and run the following command:
+
+```bash
+git --version
+``` 
+
+If the output looks like `git version 2.34.1` and you have a personal account on GitHub - you're good to go to the next step.
+If the terminal output informs about `command not found` you need to [install git](https://docs.github.com/en/get-started/quickstart/set-up-git).
+
+If you're new to GitHub, you'll need to create an account on [GitHub.com](https://github.com/) and verify your email address.
+
+### 2. Fork the repository
+
+Go to the [main project page](https://github.com/narwhals-dev/narwhals).
+Fork the repository by clicking on the fork button. You can find it in the right corner on the top of the page.
+
+### 3. Clone the repository
+
+Go to the forked repository on your GitHub account - you'll find it on your account in the tab Repositories. 
+Click on the green `Code` button and then click the `Copy url to clipboard` icon.
+Open a terminal, choose the directory where you would like to have Narwhals repository and run the following git command:
+
+```bash
+git clone <url you just copied>
+``` 
+
+for example:
+
+```bash
+git clone git@github.com:YOUR-USERNAME/narwhals.git
+```
+
+
+### 4. Setting up your environment
 
 Here's how you can set up your local development environment to contribute:
 
@@ -20,13 +63,13 @@ pre-commit install
 ```
 This will automatically format and lint your code before each commit, and it will block the commit if any issues are found.
 
-## Working on your issue
+### 5. Working on your issue
 
 Create a new git branch from the `main` branch in your local repository.
 Note that your work cannot be merged if the test below fail.
 If you add code that should be tested, please add tests.
 
-## Running tests
+### 6. Running tests
 
 To run tests, run `pytest`. To check coverage: `pytest --cov=narwhals`.
 To run tests on the docset-module, use `pytest narwhals --doctest-modules`.
@@ -42,13 +85,13 @@ nox
 
 Notice that nox will also require to have all the python versions that are defined in the `noxfile.py` installed in your system.
 
-## Building docs
+### 7. Building docs
 
 To build the docs, run `mkdocs serve`, and then open the link provided in a browser.
 The docs should refresh when you make changes. If they don't, press `ctrl+C`, and then
 do `mkdocs build` and then `mkdocs serve`.
 
-## Pull requests
+### 8. Pull requests
 
 When you have resolved your issue, [open a pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork) in the Narwhals repository.
 
@@ -62,6 +105,32 @@ Please adhere to the following guidelines:
 2. This text will end up in the [changelog](https://github.com/narwhals-dev/narwhals/releases).
 3. Please follow the instructions in the pull request form and submit. 
 
+## Working with Codespaces
+Codespaces is a great way to work on Narwhals without the need of configuring your local development environment.
+Every GitHub.com user has a monthly quota of free use of GitHub Codespaces, and you can start working in a codespace without providing any payment details.
+You'll be informed per email if you'll be close to using 100% of included services.
+To learn more about it visit [GitHub Docs](https://docs.github.com/en/codespaces/overview)
+
+
+### 1. Make sure you have GitHub account
+
+If you're new to GitHub, you'll need to create an account on [GitHub.com](https://github.com/) and verify your email address.
+
+### 2. Fork the repository
+
+Go to the [main project page](https://github.com/narwhals-dev/narwhals).
+Fork the repository by clicking on the fork button. You can find it in the right corner on the top of the page.
+
+### 3. Create codespace 
+
+Go to the forked repository on your GitHub account - you'll find it on your account in the tab Repositories. 
+Click on the green `Code` button and navigate to the `Codespaces` tab.
+Click on the green button `Create codespace on main` - it will open a browser version of VSCode,
+with the complete repository and git installed. 
+You can now proceed with the steps [4. Setting up your environment](#4-setting-up-your-environment) up to [8. Pull request](#8-pull-requests)
+listed above in [Working with local development environment](#working-with-local-development-environment).
+
+
 ## How it works
 
 If Narwhals looks like underwater unicorn magic to you, then please read
diff --git a/docs/api-reference/dataframe.md b/docs/api-reference/dataframe.md
index 08ad0f7c..c144b4af 100644
--- a/docs/api-reference/dataframe.md
+++ b/docs/api-reference/dataframe.md
@@ -26,6 +26,7 @@
         - null_count
         - pipe
         - rename
+        - row
         - rows
         - schema
         - select
diff --git a/docs/api-reference/dependencies.md b/docs/api-reference/dependencies.md
index 7e44ae9c..6c1a93d9 100644
--- a/docs/api-reference/dependencies.md
+++ b/docs/api-reference/dependencies.md
@@ -16,6 +16,8 @@
         - is_modin_series
         - is_numpy_array
         - is_pandas_dataframe
+        - is_pandas_like_dataframe
+        - is_pandas_like_series
         - is_pandas_series
         - is_polars_dataframe
         - is_polars_lazyframe
diff --git a/docs/api-reference/narwhals.md b/docs/api-reference/narwhals.md
index 2774a093..9d7ac384 100644
--- a/docs/api-reference/narwhals.md
+++ b/docs/api-reference/narwhals.md
@@ -21,6 +21,7 @@ Here are the top-level functions available in Narwhals.
         - max
         - maybe_align_index
         - maybe_convert_dtypes
+        - maybe_get_index
         - maybe_set_index
         - mean
         - mean_horizontal
@@ -29,6 +30,7 @@ Here are the top-level functions available in Narwhals.
         - new_series
         - sum
         - sum_horizontal
+        - when
         - show_versions
         - to_native
       show_source: false
diff --git a/docs/installation.md b/docs/installation.md
index e1035e4f..04625c68 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -11,6 +11,6 @@ Then, if you start the Python REPL and see the following:
 ```python
 >>> import narwhals
 >>> narwhals.__version__
-'1.5.2'
+'1.5.5'
 ```
 then installation worked correctly!
diff --git a/docs/other/column_names.md b/docs/other/column_names.md
index 97767053..14346a30 100644
--- a/docs/other/column_names.md
+++ b/docs/other/column_names.md
@@ -16,7 +16,7 @@ create one with _two_ such columns!
 What does Narwhals do about this?
 
 - In general, non-string column names are supported. In some places where this might
-  create ambiguity (such as `DataFrame.__getitem__`) we may be strict and only
+  create ambiguity (such as `DataFrame.__getitem__` or `DataFrame.select`) we may be strict and only
   allow passing in column names if they're strings.
 - If you have a use-case that's
   failing for non-string column names, please report it to [https://github.com/narwhals-dev/narwhals/issues](https://github.com/narwhals-dev/narwhals/issues)
diff --git a/narwhals/__init__.py b/narwhals/__init__.py
index a23bd7b7..0977e716 100644
--- a/narwhals/__init__.py
+++ b/narwhals/__init__.py
@@ -1,3 +1,4 @@
+from narwhals import dependencies
 from narwhals import selectors
 from narwhals import stable
 from narwhals.dataframe import DataFrame
@@ -22,11 +23,11 @@ from narwhals.dtypes import UInt32
 from narwhals.dtypes import UInt64
 from narwhals.dtypes import Unknown
 from narwhals.expr import Expr
-from narwhals.expr import all
+from narwhals.expr import all_ as all
 from narwhals.expr import all_horizontal
 from narwhals.expr import any_horizontal
 from narwhals.expr import col
-from narwhals.expr import len
+from narwhals.expr import len_ as len
 from narwhals.expr import lit
 from narwhals.expr import max
 from narwhals.expr import mean
@@ -34,6 +35,7 @@ from narwhals.expr import mean_horizontal
 from narwhals.expr import min
 from narwhals.expr import sum
 from narwhals.expr import sum_horizontal
+from narwhals.expr import when
 from narwhals.functions import concat
 from narwhals.functions import from_dict
 from narwhals.functions import get_level
@@ -48,11 +50,13 @@ from narwhals.translate import to_native
 from narwhals.utils import is_ordered_categorical
 from narwhals.utils import maybe_align_index
 from narwhals.utils import maybe_convert_dtypes
+from narwhals.utils import maybe_get_index
 from narwhals.utils import maybe_set_index
 
-__version__ = "1.5.2"
+__version__ = "1.5.5"
 
 __all__ = [
+    "dependencies",
     "selectors",
     "concat",
     "from_dict",
@@ -63,6 +67,7 @@ __all__ = [
     "is_ordered_categorical",
     "maybe_align_index",
     "maybe_convert_dtypes",
+    "maybe_get_index",
     "maybe_set_index",
     "get_native_namespace",
     "all",
@@ -77,6 +82,7 @@ __all__ = [
     "mean_horizontal",
     "sum",
     "sum_horizontal",
+    "when",
     "DataFrame",
     "LazyFrame",
     "Series",
diff --git a/narwhals/_arrow/dataframe.py b/narwhals/_arrow/dataframe.py
index 65a155e0..20e50716 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -63,6 +63,9 @@ class ArrowDataFrame:
     def __len__(self) -> int:
         return len(self._native_frame)
 
+    def row(self, index: int) -> tuple[Any, ...]:
+        return tuple(col[index] for col in self._native_frame)
+
     def rows(
         self, *, named: bool = False
     ) -> list[tuple[Any, ...]] | list[dict[str, Any]]:
@@ -377,11 +380,18 @@ class ArrowDataFrame:
         self,
         *predicates: IntoArrowExpr,
     ) -> Self:
-        plx = self.__narwhals_namespace__()
-        expr = plx.all_horizontal(*predicates)
-        # Safety: all_horizontal's expression only returns a single column.
-        mask = expr._call(self)[0]
-        return self._from_native_frame(self._native_frame.filter(mask._native_series))
+        if (
+            len(predicates) == 1
+            and isinstance(predicates[0], list)
+            and all(isinstance(x, bool) for x in predicates[0])
+        ):
+            mask = predicates[0]
+        else:
+            plx = self.__narwhals_namespace__()
+            expr = plx.all_horizontal(*predicates)
+            # Safety: all_horizontal's expression only returns a single column.
+            mask = expr._call(self)[0]._native_series
+        return self._from_native_frame(self._native_frame.filter(mask))
 
     def null_count(self) -> Self:
         import pyarrow as pa  # ignore-banned-import()
diff --git a/narwhals/_arrow/namespace.py b/narwhals/_arrow/namespace.py
index a17a6a62..ed14dd33 100644
--- a/narwhals/_arrow/namespace.py
+++ b/narwhals/_arrow/namespace.py
@@ -4,6 +4,7 @@ from functools import reduce
 from typing import TYPE_CHECKING
 from typing import Any
 from typing import Iterable
+from typing import cast
 
 from narwhals import dtypes
 from narwhals._arrow.dataframe import ArrowDataFrame
@@ -173,7 +174,10 @@ class ArrowNamespace:
         return reduce(lambda x, y: x | y, parse_into_exprs(*exprs, namespace=self))
 
     def sum_horizontal(self, *exprs: IntoArrowExpr) -> ArrowExpr:
-        return reduce(lambda x, y: x + y, parse_into_exprs(*exprs, namespace=self))
+        return reduce(
+            lambda x, y: x + y,
+            [expr.fill_null(0) for expr in parse_into_exprs(*exprs, namespace=self)],
+        )
 
     def mean_horizontal(self, *exprs: IntoArrowExpr) -> IntoArrowExpr:
         arrow_exprs = parse_into_exprs(*exprs, namespace=self)
@@ -231,3 +235,121 @@ class ArrowNamespace:
     @property
     def selectors(self) -> ArrowSelectorNamespace:
         return ArrowSelectorNamespace(backend_version=self._backend_version)
+
+    def when(
+        self,
+        *predicates: IntoArrowExpr,
+    ) -> ArrowWhen:
+        plx = self.__class__(backend_version=self._backend_version)
+        if predicates:
+            condition = plx.all_horizontal(*predicates)
+        else:
+            msg = "at least one predicate needs to be provided"
+            raise TypeError(msg)
+
+        return ArrowWhen(condition, self._backend_version)
+
+
+class ArrowWhen:
+    def __init__(
+        self,
+        condition: ArrowExpr,
+        backend_version: tuple[int, ...],
+        then_value: Any = None,
+        otherwise_value: Any = None,
+    ) -> None:
+        self._backend_version = backend_version
+        self._condition = condition
+        self._then_value = then_value
+        self._otherwise_value = otherwise_value
+
+    def __call__(self, df: ArrowDataFrame) -> list[ArrowSeries]:
+        import pyarrow as pa  # ignore-banned-import
+        import pyarrow.compute as pc  # ignore-banned-import
+
+        from narwhals._arrow.namespace import ArrowNamespace
+        from narwhals._expression_parsing import parse_into_expr
+
+        plx = ArrowNamespace(backend_version=self._backend_version)
+
+        condition = parse_into_expr(self._condition, namespace=plx)._call(df)[0]  # type: ignore[arg-type]
+        try:
+            value_series = parse_into_expr(self._then_value, namespace=plx)._call(df)[0]  # type: ignore[arg-type]
+        except TypeError:
+            # `self._otherwise_value` is a scalar and can't be converted to an expression
+            value_series = condition.__class__._from_iterable(  # type: ignore[call-arg]
+                [self._then_value] * len(condition),
+                name="literal",
+                backend_version=self._backend_version,
+            )
+        value_series = cast(ArrowSeries, value_series)
+
+        value_series_native = value_series._native_series
+        condition_native = condition._native_series.combine_chunks()
+
+        if self._otherwise_value is None:
+            otherwise_native = pa.array(
+                [None] * len(condition_native), type=value_series_native.type
+            )
+            return [
+                value_series._from_native_series(
+                    pc.if_else(condition_native, value_series_native, otherwise_native)
+                )
+            ]
+        try:
+            otherwise_series = parse_into_expr(
+                self._otherwise_value, namespace=plx
+            )._call(df)[0]  # type: ignore[arg-type]
+        except TypeError:
+            # `self._otherwise_value` is a scalar and can't be converted to an expression
+            return [
+                value_series._from_native_series(
+                    pc.if_else(
+                        condition_native, value_series_native, self._otherwise_value
+                    )
+                )
+            ]
+        else:
+            otherwise_series = cast(ArrowSeries, otherwise_series)
+            condition = cast(ArrowSeries, condition)
+            return [value_series.zip_with(condition, otherwise_series)]
+
+    def then(self, value: ArrowExpr | ArrowSeries | Any) -> ArrowThen:
+        self._then_value = value
+
+        return ArrowThen(
+            self,
+            depth=0,
+            function_name="whenthen",
+            root_names=None,
+            output_names=None,
+            backend_version=self._backend_version,
+        )
+
+
+class ArrowThen(ArrowExpr):
+    def __init__(
+        self,
+        call: ArrowWhen,
+        *,
+        depth: int,
+        function_name: str,
+        root_names: list[str] | None,
+        output_names: list[str] | None,
+        backend_version: tuple[int, ...],
+    ) -> None:
+        self._backend_version = backend_version
+
+        self._call = call
+        self._depth = depth
+        self._function_name = function_name
+        self._root_names = root_names
+        self._output_names = output_names
+
+    def otherwise(self, value: ArrowExpr | ArrowSeries | Any) -> ArrowExpr:
+        # type ignore because we are setting the `_call` attribute to a
+        # callable object of type `PandasWhen`, base class has the attribute as
+        # only a `Callable`
+        self._call._otherwise_value = value  # type: ignore[attr-defined]
+        self._function_name = "whenotherwise"
+        return self
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index fb15f3aa..1e9e4a08 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -233,7 +233,8 @@ class ArrowSeries:
         return len(self._native_series)
 
     def filter(self, other: Any) -> Self:
-        other = validate_column_comparand(other)
+        if not (isinstance(other, list) and all(isinstance(x, bool) for x in other)):
+            other = validate_column_comparand(other)
         return self._from_native_series(self._native_series.filter(other))
 
     def mean(self) -> int:
@@ -489,11 +490,12 @@ class ArrowSeries:
     def zip_with(self: Self, mask: Self, other: Self) -> Self:
         import pyarrow.compute as pc  # ignore-banned-import()
 
+        mask = mask._native_series.combine_chunks()
         return self._from_native_series(
-            pc.replace_with_mask(
-                self._native_series.combine_chunks(),
-                pc.invert(mask._native_series.combine_chunks()),
-                other._native_series.combine_chunks(),
+            pc.if_else(
+                mask,
+                self._native_series,
+                other._native_series,
             )
         )
 
diff --git a/narwhals/_dask/dataframe.py b/narwhals/_dask/dataframe.py
index 35ef28bb..9774d6c8 100644
--- a/narwhals/_dask/dataframe.py
+++ b/narwhals/_dask/dataframe.py
@@ -72,12 +72,19 @@ class DaskLazyFrame:
         self,
         *predicates: DaskExpr,
     ) -> Self:
-        from narwhals._dask.namespace import DaskNamespace
+        if (
+            len(predicates) == 1
+            and isinstance(predicates[0], list)
+            and all(isinstance(x, bool) for x in predicates[0])
+        ):
+            mask = predicates[0]
+        else:
+            from narwhals._dask.namespace import DaskNamespace
 
-        plx = DaskNamespace(backend_version=self._backend_version)
-        expr = plx.all_horizontal(*predicates)
-        # Safety: all_horizontal's expression only returns a single column.
-        mask = expr._call(self)[0]
+            plx = DaskNamespace(backend_version=self._backend_version)
+            expr = plx.all_horizontal(*predicates)
+            # Safety: all_horizontal's expression only returns a single column.
+            mask = expr._call(self)[0]
         return self._from_native_frame(self._native_frame.loc[mask])
 
     def lazy(self) -> Self:
diff --git a/narwhals/_dask/expr.py b/narwhals/_dask/expr.py
index 31bc4852..62aaa85e 100644
--- a/narwhals/_dask/expr.py
+++ b/narwhals/_dask/expr.py
@@ -9,6 +9,7 @@ from typing import NoReturn
 
 from narwhals._dask.utils import add_row_index
 from narwhals._dask.utils import maybe_evaluate
+from narwhals._dask.utils import reverse_translate_dtype
 from narwhals.dependencies import get_dask
 from narwhals.utils import generate_unique_token
 
@@ -17,6 +18,7 @@ if TYPE_CHECKING:
 
     from narwhals._dask.dataframe import DaskLazyFrame
     from narwhals._dask.namespace import DaskNamespace
+    from narwhals.dtypes import DType
 
 
 class DaskExpr:
@@ -654,6 +656,21 @@ class DaskExpr:
     def name(self: Self) -> DaskExprNameNamespace:
         return DaskExprNameNamespace(self)
 
+    def cast(
+        self: Self,
+        dtype: DType | type[DType],
+    ) -> Self:
+        def func(_input: Any, dtype: DType | type[DType]) -> Any:
+            dtype = reverse_translate_dtype(dtype)
+            return _input.astype(dtype)
+
+        return self._from_call(
+            func,
+            "cast",
+            dtype,
+            returns_scalar=False,
+        )
+
 
 class DaskExprStringNamespace:
     def __init__(self, expr: DaskExpr) -> None:
diff --git a/narwhals/_dask/namespace.py b/narwhals/_dask/namespace.py
index 868cc20d..71fd4b8b 100644
--- a/narwhals/_dask/namespace.py
+++ b/narwhals/_dask/namespace.py
@@ -133,7 +133,10 @@ class DaskNamespace:
         return reduce(lambda x, y: x | y, parse_into_exprs(*exprs, namespace=self))
 
     def sum_horizontal(self, *exprs: IntoDaskExpr) -> DaskExpr:
-        return reduce(lambda x, y: x + y, parse_into_exprs(*exprs, namespace=self))
+        return reduce(
+            lambda x, y: x + y,
+            [expr.fill_null(0) for expr in parse_into_exprs(*exprs, namespace=self)],
+        )
 
     def mean_horizontal(self, *exprs: IntoDaskExpr) -> IntoDaskExpr:
         dask_exprs = parse_into_exprs(*exprs, namespace=self)
diff --git a/narwhals/_dask/utils.py b/narwhals/_dask/utils.py
index 27f0d764..d3f02a07 100644
--- a/narwhals/_dask/utils.py
+++ b/narwhals/_dask/utils.py
@@ -4,9 +4,14 @@ from typing import TYPE_CHECKING
 from typing import Any
 
 from narwhals.dependencies import get_dask_expr
+from narwhals.dependencies import get_pandas
+from narwhals.dependencies import get_pyarrow
+from narwhals.utils import isinstance_or_issubclass
+from narwhals.utils import parse_version
 
 if TYPE_CHECKING:
     from narwhals._dask.dataframe import DaskLazyFrame
+    from narwhals.dtypes import DType
 
 
 def maybe_evaluate(df: DaskLazyFrame, obj: Any) -> Any:
@@ -73,3 +78,47 @@ def parse_exprs_and_named_exprs(
 def add_row_index(frame: Any, name: str) -> Any:
     frame = frame.assign(**{name: 1})
     return frame.assign(**{name: frame[name].cumsum(method="blelloch") - 1})
+
+
+def reverse_translate_dtype(dtype: DType | type[DType]) -> Any:
+    from narwhals import dtypes
+
+    if isinstance_or_issubclass(dtype, dtypes.Float64):
+        return "float64"
+    if isinstance_or_issubclass(dtype, dtypes.Float32):
+        return "float32"
+    if isinstance_or_issubclass(dtype, dtypes.Int64):
+        return "int64"
+    if isinstance_or_issubclass(dtype, dtypes.Int32):
+        return "int32"
+    if isinstance_or_issubclass(dtype, dtypes.Int16):
+        return "int16"
+    if isinstance_or_issubclass(dtype, dtypes.Int8):
+        return "int8"
+    if isinstance_or_issubclass(dtype, dtypes.UInt64):
+        return "uint64"
+    if isinstance_or_issubclass(dtype, dtypes.UInt32):
+        return "uint32"
+    if isinstance_or_issubclass(dtype, dtypes.UInt16):
+        return "uint16"
+    if isinstance_or_issubclass(dtype, dtypes.UInt8):
+        return "uint8"
+    if isinstance_or_issubclass(dtype, dtypes.String):
+        if (pd := get_pandas()) is not None and parse_version(
+            pd.__version__
+        ) >= parse_version("2.0.0"):
+            if get_pyarrow() is not None:
+                return "string[pyarrow]"
+            return "string[python]"  # pragma: no cover
+        return "object"  # pragma: no cover
+    if isinstance_or_issubclass(dtype, dtypes.Boolean):
+        return "bool"
+    if isinstance_or_issubclass(dtype, dtypes.Categorical):
+        return "category"
+    if isinstance_or_issubclass(dtype, dtypes.Datetime):
+        return "datetime64[us]"
+    if isinstance_or_issubclass(dtype, dtypes.Duration):
+        return "timedelta64[ns]"
+
+    msg = f"Unknown dtype: {dtype}"  # pragma: no cover
+    raise AssertionError(msg)
diff --git a/narwhals/_expression_parsing.py b/narwhals/_expression_parsing.py
index 06383821..a74ca3c6 100644
--- a/narwhals/_expression_parsing.py
+++ b/narwhals/_expression_parsing.py
@@ -3,7 +3,6 @@
 # and pandas or PyArrow.
 from __future__ import annotations
 
-from collections.abc import Hashable
 from copy import copy
 from typing import TYPE_CHECKING
 from typing import Any
@@ -187,14 +186,18 @@ def parse_into_expr(
         return into_expr  # type: ignore[return-value]
     if hasattr(into_expr, "__narwhals_series__"):
         return namespace._create_expr_from_series(into_expr)  # type: ignore[arg-type]
-    if isinstance(into_expr, Hashable):
-        # pandas allows non-string column names, so we pass them through here.
-        return namespace.col(into_expr)  # type: ignore[arg-type]
+    if isinstance(into_expr, str):
+        return namespace.col(into_expr)
     if is_numpy_array(into_expr):
         series = namespace._create_compliant_series(into_expr)
-        return namespace._create_expr_from_series(series)
-    msg = f"Expected IntoExpr, got {type(into_expr)}"  # pragma: no cover
-    raise AssertionError(msg)
+        return namespace._create_expr_from_series(series)  # type: ignore[arg-type]
+    msg = (
+        f"Expected an object which can be converted into an expression, got {type(into_expr)}\n\n"  # pragma: no cover
+        "Hint: if you were trying to select a column which does not have a string column name, then "
+        "you should explicitly use `nw.col`.\nFor example, `df.select(nw.col(0))` if you have a column "
+        "named `0`."
+    )
+    raise TypeError(msg)
 
 
 def reuse_series_implementation(
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index c6a51be2..193955cb 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -283,15 +283,25 @@ class PandasLikeDataFrame:
             )
         )
 
+    def row(self, row: int) -> tuple[Any, ...]:
+        return tuple(x for x in self._native_frame.iloc[row])
+
     def filter(
         self,
         *predicates: IntoPandasLikeExpr,
     ) -> Self:
         plx = self.__narwhals_namespace__()
-        expr = plx.all_horizontal(*predicates)
-        # Safety: all_horizontal's expression only returns a single column.
-        mask = expr._call(self)[0]
-        _mask = validate_dataframe_comparand(self._native_frame.index, mask)
+        if (
+            len(predicates) == 1
+            and isinstance(predicates[0], list)
+            and all(isinstance(x, bool) for x in predicates[0])
+        ):
+            _mask = predicates[0]
+        else:
+            expr = plx.all_horizontal(*predicates)
+            # Safety: all_horizontal's expression only returns a single column.
+            mask = expr._call(self)[0]
+            _mask = validate_dataframe_comparand(self._native_frame.index, mask)
         return self._from_native_frame(self._native_frame.loc[_mask])
 
     def with_columns(
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index 193b1786..44154453 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -227,8 +227,8 @@ class PandasLikeExpr:
 
     def filter(self, *predicates: Any) -> Self:
         plx = self.__narwhals_namespace__()
-        expr = plx.all_horizontal(*predicates)
-        return reuse_series_implementation(self, "filter", other=expr)
+        other = plx.all_horizontal(*predicates)
+        return reuse_series_implementation(self, "filter", other=other)
 
     def drop_nulls(self) -> Self:
         return reuse_series_implementation(self, "drop_nulls")
diff --git a/narwhals/_pandas_like/namespace.py b/narwhals/_pandas_like/namespace.py
index 3c378e3c..37c88954 100644
--- a/narwhals/_pandas_like/namespace.py
+++ b/narwhals/_pandas_like/namespace.py
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING
 from typing import Any
 from typing import Callable
 from typing import Iterable
+from typing import cast
 
 from narwhals import dtypes
 from narwhals._expression_parsing import parse_into_exprs
@@ -202,7 +203,10 @@ class PandasLikeNamespace:
 
     # --- horizontal ---
     def sum_horizontal(self, *exprs: IntoPandasLikeExpr) -> PandasLikeExpr:
-        return reduce(lambda x, y: x + y, parse_into_exprs(*exprs, namespace=self))
+        return reduce(
+            lambda x, y: x + y,
+            [expr.fill_null(0) for expr in parse_into_exprs(*exprs, namespace=self)],
+        )
 
     def all_horizontal(self, *exprs: IntoPandasLikeExpr) -> PandasLikeExpr:
         return reduce(lambda x, y: x & y, parse_into_exprs(*exprs, namespace=self))
@@ -246,3 +250,121 @@ class PandasLikeNamespace:
                 backend_version=self._backend_version,
             )
         raise NotImplementedError
+
+    def when(
+        self,
+        *predicates: IntoPandasLikeExpr,
+    ) -> PandasWhen:
+        plx = self.__class__(self._implementation, self._backend_version)
+        if predicates:
+            condition = plx.all_horizontal(*predicates)
+        else:
+            msg = "at least one predicate needs to be provided"
+            raise TypeError(msg)
+
+        return PandasWhen(condition, self._implementation, self._backend_version)
+
+
+class PandasWhen:
+    def __init__(
+        self,
+        condition: PandasLikeExpr,
+        implementation: Implementation,
+        backend_version: tuple[int, ...],
+        then_value: Any = None,
+        otherwise_value: Any = None,
+    ) -> None:
+        self._implementation = implementation
+        self._backend_version = backend_version
+        self._condition = condition
+        self._then_value = then_value
+        self._otherwise_value = otherwise_value
+
+    def __call__(self, df: PandasLikeDataFrame) -> list[PandasLikeSeries]:
+        from narwhals._expression_parsing import parse_into_expr
+        from narwhals._pandas_like.namespace import PandasLikeNamespace
+        from narwhals._pandas_like.utils import validate_column_comparand
+
+        plx = PandasLikeNamespace(
+            implementation=self._implementation, backend_version=self._backend_version
+        )
+
+        condition = parse_into_expr(self._condition, namespace=plx)._call(df)[0]  # type: ignore[arg-type]
+        try:
+            value_series = parse_into_expr(self._then_value, namespace=plx)._call(df)[0]  # type: ignore[arg-type]
+        except TypeError:
+            # `self._otherwise_value` is a scalar and can't be converted to an expression
+            value_series = condition.__class__._from_iterable(  # type: ignore[call-arg]
+                [self._then_value] * len(condition),
+                name="literal",
+                index=condition._native_series.index,
+                implementation=self._implementation,
+                backend_version=self._backend_version,
+            )
+        value_series = cast(PandasLikeSeries, value_series)
+
+        value_series_native = value_series._native_series
+        condition_native = validate_column_comparand(value_series_native.index, condition)
+
+        if self._otherwise_value is None:
+            return [
+                value_series._from_native_series(
+                    value_series_native.where(condition_native)
+                )
+            ]
+        try:
+            otherwise_series = parse_into_expr(
+                self._otherwise_value, namespace=plx
+            )._call(df)[0]  # type: ignore[arg-type]
+        except TypeError:
+            # `self._otherwise_value` is a scalar and can't be converted to an expression
+            return [
+                value_series._from_native_series(
+                    value_series_native.where(condition_native, self._otherwise_value)
+                )
+            ]
+        else:
+            return [value_series.zip_with(condition, otherwise_series)]
+
+    def then(self, value: PandasLikeExpr | PandasLikeSeries | Any) -> PandasThen:
+        self._then_value = value
+
+        return PandasThen(
+            self,
+            depth=0,
+            function_name="whenthen",
+            root_names=None,
+            output_names=None,
+            implementation=self._implementation,
+            backend_version=self._backend_version,
+        )
+
+
+class PandasThen(PandasLikeExpr):
+    def __init__(
+        self,
+        call: PandasWhen,
+        *,
+        depth: int,
+        function_name: str,
+        root_names: list[str] | None,
+        output_names: list[str] | None,
+        implementation: Implementation,
+        backend_version: tuple[int, ...],
+    ) -> None:
+        self._implementation = implementation
+        self._backend_version = backend_version
+
+        self._call = call
+        self._depth = depth
+        self._function_name = function_name
+        self._root_names = root_names
+        self._output_names = output_names
+
+    def otherwise(self, value: PandasLikeExpr | PandasLikeSeries | Any) -> PandasLikeExpr:
+        # type ignore because we are setting the `_call` attribute to a
+        # callable object of type `PandasWhen`, base class has the attribute as
+        # only a `Callable`
+        self._call._otherwise_value = value  # type: ignore[attr-defined]
+        self._function_name = "whenotherwise"
+        return self
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index 90434ebd..14900f14 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -229,7 +229,8 @@ class PandasLikeSeries:
 
     def filter(self, other: Any) -> PandasLikeSeries:
         ser = self._native_series
-        other = validate_column_comparand(self._native_series.index, other)
+        if not (isinstance(other, list) and all(isinstance(x, bool) for x in other)):
+            other = validate_column_comparand(self._native_series.index, other)
         return self._from_native_series(self._rename(ser.loc[other], ser.name))
 
     def __eq__(self, other: object) -> PandasLikeSeries:  # type: ignore[override]
@@ -581,7 +582,9 @@ class PandasLikeSeries:
 
     def zip_with(self: Self, mask: Any, other: Any) -> PandasLikeSeries:
         ser = self._native_series
-        res = ser.where(mask._native_series, other._native_series)
+        mask = validate_column_comparand(ser.index, mask)
+        other = validate_column_comparand(ser.index, other)
+        res = ser.where(mask, other)
         return self._from_native_series(res)
 
     def head(self: Self, n: int) -> Self:
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index b09f12cc..64df5913 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -158,8 +158,13 @@ class BaseFrame(Generic[FrameT]):
             )
         )
 
-    def filter(self, *predicates: IntoExpr | Iterable[IntoExpr]) -> Self:
-        predicates, _ = self._flatten_and_extract(*predicates)
+    def filter(self, *predicates: IntoExpr | Iterable[IntoExpr] | list[bool]) -> Self:
+        if not (
+            len(predicates) == 1
+            and isinstance(predicates[0], list)
+            and all(isinstance(x, bool) for x in predicates[0])
+        ):
+            predicates, _ = self._flatten_and_extract(*predicates)
         return self._from_compliant_dataframe(
             self._compliant_frame.filter(*predicates),
         )
@@ -599,6 +604,8 @@ class DataFrame(BaseFrame[FrameT]):
                 2
             ]
         """
+        if isinstance(item, int):
+            item = [item]
         if (
             isinstance(item, tuple)
             and len(item) == 2
@@ -693,6 +700,40 @@ class DataFrame(BaseFrame[FrameT]):
             }
         return self._compliant_frame.to_dict(as_series=as_series)  # type: ignore[no-any-return]
 
+    def row(self, index: int) -> tuple[Any, ...]:
+        """
+        Get values at given row.
+
+        !!!note
+            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.
+
+        Arguments:
+            index: Row number.
+
+        Examples:
+            >>> import narwhals as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> data = {"a": [1, 2, 3], "b": [4, 5, 6]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+
+            Let's define a library-agnostic function to get the second row.
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.row(1)
+
+            We can then pass pandas / Polars / any other supported library:
+
+            >>> func(df_pd)
+            (2, 5)
+            >>> func(df_pl)
+            (2, 5)
+        """
+        return self._compliant_frame.row(index)  # type: ignore[no-any-return]
+
     # inherited
     def pipe(self, function: Callable[[Any], Self], *args: Any, **kwargs: Any) -> Self:
         """
@@ -1483,14 +1524,15 @@ class DataFrame(BaseFrame[FrameT]):
         """
         return super().unique(subset, keep=keep, maintain_order=maintain_order)
 
-    def filter(self, *predicates: IntoExpr | Iterable[IntoExpr]) -> Self:
+    def filter(self, *predicates: IntoExpr | Iterable[IntoExpr] | list[bool]) -> Self:
         r"""
         Filter the rows in the DataFrame based on one or more predicate expressions.
 
         The original order of the remaining rows is preserved.
 
         Arguments:
-            predicates: Expression(s) that evaluates to a boolean Series.
+            *predicates: Expression(s) that evaluates to a boolean Series. Can
+                also be a (single!) boolean list.
 
         Examples:
             >>> import pandas as pd
@@ -2493,11 +2535,15 @@ class LazyFrame(BaseFrame[FrameT]):
 
         Arguments:
             *exprs: Column(s) to select, specified as positional arguments.
-                     Accepts expression input. Strings are parsed as column names,
-                     other non-expression inputs are parsed as literals.
-
+                Accepts expression input. Strings are parsed as column names.
             **named_exprs: Additional columns to select, specified as keyword arguments.
-                            The columns will be renamed to the keyword used.
+                The columns will be renamed to the keyword used.
+
+        Notes:
+            If you'd like to select a column whose name isn't a string (for example,
+            if you're working with pandas) then you should explicitly use `nw.col` instead
+            of just passing the column name. For example, to select a column named
+            `0` use `df.select(nw.col(0))`, not `df.select(0)`.
 
         Examples:
             >>> import pandas as pd
@@ -2949,14 +2995,15 @@ class LazyFrame(BaseFrame[FrameT]):
         """
         return super().unique(subset, keep=keep, maintain_order=maintain_order)
 
-    def filter(self, *predicates: IntoExpr | Iterable[IntoExpr]) -> Self:
+    def filter(self, *predicates: IntoExpr | Iterable[IntoExpr] | list[bool]) -> Self:
         r"""
         Filter the rows in the LazyFrame based on a predicate expression.
 
         The original order of the remaining rows is preserved.
 
         Arguments:
-            *predicates: Expression that evaluates to a boolean Series.
+            *predicates: Expression that evaluates to a boolean Series. Can
+                also be a (single!) boolean list.
 
         Examples:
             >>> import pandas as pd
diff --git a/narwhals/dependencies.py b/narwhals/dependencies.py
index 7ed2f021..66516eac 100644
--- a/narwhals/dependencies.py
+++ b/narwhals/dependencies.py
@@ -134,6 +134,24 @@ def is_numpy_array(arr: Any) -> TypeGuard[np.ndarray]:
     return bool((np := get_numpy()) is not None and isinstance(arr, np.ndarray))
 
 
+def is_pandas_like_dataframe(df: Any) -> bool:
+    """
+    Check whether `df` is a pandas-like DataFrame without doing any imports
+
+    By "pandas-like", we mean: pandas, Modin, cuDF.
+    """
+    return is_pandas_dataframe(df) or is_modin_dataframe(df) or is_cudf_dataframe(df)
+
+
+def is_pandas_like_series(arr: Any) -> bool:
+    """
+    Check whether `arr` is a pandas-like Series without doing any imports
+
+    By "pandas-like", we mean: pandas, Modin, cuDF.
+    """
+    return is_pandas_series(arr) or is_modin_series(arr) or is_cudf_series(arr)
+
+
 __all__ = [
     "get_polars",
     "get_pandas",
@@ -154,4 +172,6 @@ __all__ = [
     "is_pyarrow_chunked_array",
     "is_numpy_array",
     "is_dask_dataframe",
+    "is_pandas_like_dataframe",
+    "is_pandas_like_series",
 ]
diff --git a/narwhals/expr.py b/narwhals/expr.py
index 74ead79e..cc8b8695 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -3657,7 +3657,8 @@ def col(*names: str | Iterable[str]) -> Expr:
     return Expr(func)
 
 
-def all() -> Expr:
+# Add underscore so it doesn't conflict with builtin `all`
+def all_() -> Expr:
     """
     Instantiate an expression representing all columns.
 
@@ -3696,7 +3697,8 @@ def all() -> Expr:
     return Expr(lambda plx: plx.all())
 
 
-def len() -> Expr:
+# Add underscore so it doesn't conflict with builtin `len`
+def len_() -> Expr:
     """
     Return the number of rows.
 
@@ -3903,17 +3905,22 @@ def max(*columns: str) -> Expr:
 
 def sum_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
     """
-    Sum all values horizontally across columns
+    Sum all values horizontally across columns.
+
+    Warning:
+        Unlike Polars, we support horizontal sum over numeric columns only.
 
     Arguments:
-        exprs: Name(s) of the columns to use in the aggregation function. Accepts expression input.
+        exprs: Name(s) of the columns to use in the aggregation function. Accepts
+            expression input.
 
     Examples:
         >>> import pandas as pd
         >>> import polars as pl
         >>> import narwhals as nw
-        >>> df_pl = pl.DataFrame({"a": [1, 2, 3], "b": [5, 10, 15]})
-        >>> df_pd = pd.DataFrame({"a": [1, 2, 3], "b": [5, 10, 15]})
+        >>> data = {"a": [1, 2, 3], "b": [5, 10, None]}
+        >>> df_pl = pl.DataFrame(data)
+        >>> df_pd = pd.DataFrame(data)
 
         We define a dataframe-agnostic function:
 
@@ -3924,10 +3931,10 @@ def sum_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
         We can then pass either pandas or polars to `func`:
 
         >>> func(df_pd)
-            a
-        0   6
-        1  12
-        2  18
+              a
+        0   6.0
+        1  12.0
+        2   3.0
         >>> func(df_pl)
         shape: (3, 1)
         ┌─────┐
@@ -3937,7 +3944,7 @@ def sum_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
         ╞═════╡
         │ 6   │
         │ 12  │
-        │ 18  │
+        │ 3   │
         └─────┘
     """
     return Expr(
@@ -3947,6 +3954,77 @@ def sum_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
     )
 
 
+class When:
+    def __init__(self, *predicates: IntoExpr | Iterable[IntoExpr]) -> None:
+        self._predicates = flatten([predicates])
+
+    def _extract_predicates(self, plx: Any) -> Any:
+        return [extract_compliant(plx, v) for v in self._predicates]
+
+    def then(self, value: Any) -> Then:
+        return Then(
+            lambda plx: plx.when(*self._extract_predicates(plx)).then(
+                extract_compliant(plx, value)
+            )
+        )
+
+
+class Then(Expr):
+    def __init__(self, call: Callable[[Any], Any]) -> None:
+        self._call = call
+
+    def otherwise(self, value: Any) -> Expr:
+        return Expr(lambda plx: self._call(plx).otherwise(extract_compliant(plx, value)))
+
+
+def when(*predicates: IntoExpr | Iterable[IntoExpr]) -> When:
+    """
+    Start a `when-then-otherwise` expression.
+
+    Expression similar to an `if-else` statement in Python. Always initiated by a `pl.when(<condition>).then(<value if condition>)`., and optionally followed by chaining one or more `.when(<condition>).then(<value>)` statements.
+    Chained when-then operations should be read as Python `if, elif, ... elif` blocks, not as `if, if, ... if`, i.e. the first condition that evaluates to `True` will be picked.
+    If none of the conditions are `True`, an optional `.otherwise(<value if all statements are false>)` can be appended at the end. If not appended, and none of the conditions are `True`, `None` will be returned.
+
+    Arguments:
+        predicates: Condition(s) that must be met in order to apply the subsequent statement. Accepts one or more boolean expressions, which are implicitly combined with `&`. String input is parsed as a column name.
+
+    Examples:
+        >>> import pandas as pd
+        >>> import polars as pl
+        >>> import narwhals as nw
+        >>> df_pl = pl.DataFrame({"a": [1, 2, 3], "b": [5, 10, 15]})
+        >>> df_pd = pd.DataFrame({"a": [1, 2, 3], "b": [5, 10, 15]})
+
+        We define a dataframe-agnostic function:
+
+        >>> @nw.narwhalify
+        ... def func(df_any):
+        ...     return df_any.with_columns(
+        ...         nw.when(nw.col("a") < 3).then(5).otherwise(6).alias("a_when")
+        ...     )
+
+        We can then pass either pandas or polars to `func`:
+
+        >>> func(df_pd)
+           a   b  a_when
+        0  1   5       5
+        1  2  10       5
+        2  3  15       6
+        >>> func(df_pl)
+        shape: (3, 3)
+        ┌─────┬─────┬────────┐
+        │ a   ┆ b   ┆ a_when │
+        │ --- ┆ --- ┆ ---    │
+        │ i64 ┆ i64 ┆ i32    │
+        ╞═════╪═════╪════════╡
+        │ 1   ┆ 5   ┆ 5      │
+        │ 2   ┆ 10  ┆ 5      │
+        │ 3   ┆ 15  ┆ 6      │
+        └─────┴─────┴────────┘
+    """
+    return When(*predicates)
+
+
 def all_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
     r"""
     Compute the bitwise AND horizontally across columns.
diff --git a/narwhals/stable/v1.py b/narwhals/stable/v1.py
index 961fff16..d166a814 100644
--- a/narwhals/stable/v1.py
+++ b/narwhals/stable/v1.py
@@ -10,6 +10,7 @@ from typing import TypeVar
 from typing import overload
 
 import narwhals as nw
+from narwhals import dependencies
 from narwhals import selectors
 from narwhals.dataframe import DataFrame as NwDataFrame
 from narwhals.dataframe import LazyFrame as NwLazyFrame
@@ -33,6 +34,9 @@ from narwhals.dtypes import UInt32
 from narwhals.dtypes import UInt64
 from narwhals.dtypes import Unknown
 from narwhals.expr import Expr as NwExpr
+from narwhals.expr import Then as NwThen
+from narwhals.expr import When as NwWhen
+from narwhals.expr import when as nw_when
 from narwhals.functions import concat
 from narwhals.functions import show_versions
 from narwhals.schema import Schema as NwSchema
@@ -45,6 +49,7 @@ from narwhals.typing import IntoFrameT
 from narwhals.utils import is_ordered_categorical as nw_is_ordered_categorical
 from narwhals.utils import maybe_align_index as nw_maybe_align_index
 from narwhals.utils import maybe_convert_dtypes as nw_maybe_convert_dtypes
+from narwhals.utils import maybe_get_index as nw_maybe_get_index
 from narwhals.utils import maybe_set_index as nw_maybe_set_index
 
 if TYPE_CHECKING:
@@ -599,8 +604,8 @@ def from_native(
     allow_series: None = ...,
 ) -> DataFrame[IntoDataFrameT]:
     """
-    from_native(df, strict=True, eager_or_interchange_only=True, allow_series=True)
-    from_native(df, eager_or_interchange_only=True, allow_series=True)
+    from_native(df, strict=True, eager_or_interchange_only=True)
+    from_native(df, eager_or_interchange_only=True)
     """
 
 
@@ -615,8 +620,8 @@ def from_native(
     allow_series: None = ...,
 ) -> DataFrame[IntoDataFrameT]:
     """
-    from_native(df, strict=True, eager_only=True, allow_series=True)
-    from_native(df, eager_only=True, allow_series=True)
+    from_native(df, strict=True, eager_only=True)
+    from_native(df, eager_only=True)
     """
 
 
@@ -631,8 +636,8 @@ def from_native(
     allow_series: Literal[True],
 ) -> DataFrame[Any] | LazyFrame[Any] | Series:
     """
-    from_native(df, strict=True, eager_only=True)
-    from_native(df, eager_only=True)
+    from_native(df, strict=True, allow_series=True)
+    from_native(df, allow_series=True)
     """
 
 
@@ -1127,17 +1132,22 @@ def sum(*columns: str) -> Expr:
 
 def sum_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
     """
-    Sum all values horizontally across columns
+    Sum all values horizontally across columns.
+
+    Warning:
+        Unlike Polars, we support horizontal sum over numeric columns only.
 
     Arguments:
-        exprs: Name(s) of the columns to use in the aggregation function. Accepts expression input.
+        exprs: Name(s) of the columns to use in the aggregation function. Accepts
+            expression input.
 
     Examples:
         >>> import pandas as pd
         >>> import polars as pl
         >>> import narwhals.stable.v1 as nw
-        >>> df_pl = pl.DataFrame({"a": [1, 2, 3], "b": [5, 10, 15]})
-        >>> df_pd = pd.DataFrame({"a": [1, 2, 3], "b": [5, 10, 15]})
+        >>> data = {"a": [1, 2, 3], "b": [5, 10, None]}
+        >>> df_pl = pl.DataFrame(data)
+        >>> df_pd = pd.DataFrame(data)
 
         We define a dataframe-agnostic function:
 
@@ -1148,10 +1158,10 @@ def sum_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
         We can then pass either pandas or polars to `func`:
 
         >>> func(df_pd)
-            a
-        0   6
-        1  12
-        2  18
+              a
+        0   6.0
+        1  12.0
+        2   3.0
         >>> func(df_pl)
         shape: (3, 1)
         ┌─────┐
@@ -1161,7 +1171,7 @@ def sum_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
         ╞═════╡
         │ 6   │
         │ 12  │
-        │ 18  │
+        │ 3   │
         └─────┘
     """
     return _stableify(nw.sum_horizontal(*exprs))
@@ -1398,7 +1408,12 @@ def maybe_align_index(lhs: T, rhs: Series | DataFrame[Any] | LazyFrame[Any]) ->
 
 def maybe_convert_dtypes(df: T, *args: bool, **kwargs: bool | str) -> T:
     """
-    Convert columns to the best possible dtypes using dtypes supporting ``pd.NA``, if df is pandas-like.
+    Convert columns or series to the best possible dtypes using dtypes supporting ``pd.NA``, if df is pandas-like.
+
+    Arguments:
+        obj: DataFrame or Series.
+        *args: Additional arguments which gets passed through.
+        **kwargs: Additional arguments which gets passed through.
 
     Notes:
         For non-pandas-like inputs, this is a no-op.
@@ -1424,6 +1439,33 @@ def maybe_convert_dtypes(df: T, *args: bool, **kwargs: bool | str) -> T:
     return nw_maybe_convert_dtypes(df, *args, **kwargs)
 
 
+def maybe_get_index(obj: T) -> Any | None:
+    """
+    Get the index of a DataFrame or a Series, if it's pandas-like.
+
+    Notes:
+        This is only really intended for backwards-compatibility purposes,
+        for example if your library already aligns indices for users.
+        If you're designing a new library, we highly encourage you to not
+        rely on the Index.
+        For non-pandas-like inputs, this returns `None`.
+
+    Examples:
+        >>> import pandas as pd
+        >>> import polars as pl
+        >>> import narwhals.stable.v1 as nw
+        >>> df_pd = pd.DataFrame({"a": [1, 2], "b": [4, 5]})
+        >>> df = nw.from_native(df_pd)
+        >>> nw.maybe_get_index(df)
+        RangeIndex(start=0, stop=2, step=1)
+        >>> series_pd = pd.Series([1, 2])
+        >>> series = nw.from_native(series_pd, series_only=True)
+        >>> nw.maybe_get_index(series)
+        RangeIndex(start=0, stop=2, step=1)
+    """
+    return nw_maybe_get_index(obj)
+
+
 def maybe_set_index(df: T, column_names: str | list[str]) -> T:
     """
     Set columns `columns` to be the index of `df`, if `df` is pandas-like.
@@ -1482,6 +1524,72 @@ def get_level(
     return nw.get_level(obj)
 
 
+class When(NwWhen):
+    @classmethod
+    def from_when(cls, when: NwWhen) -> Self:
+        return cls(*when._predicates)
+
+    def then(self, value: Any) -> Then:
+        return Then.from_then(super().then(value))
+
+
+class Then(NwThen, Expr):
+    @classmethod
+    def from_then(cls, then: NwThen) -> Self:
+        return cls(then._call)
+
+    def otherwise(self, value: Any) -> Expr:
+        return _stableify(super().otherwise(value))
+
+
+def when(*predicates: IntoExpr | Iterable[IntoExpr]) -> When:
+    """
+    Start a `when-then-otherwise` expression.
+
+    Expression similar to an `if-else` statement in Python. Always initiated by a `pl.when(<condition>).then(<value if condition>)`., and optionally followed by chaining one or more `.when(<condition>).then(<value>)` statements.
+    Chained when-then operations should be read as Python `if, elif, ... elif` blocks, not as `if, if, ... if`, i.e. the first condition that evaluates to `True` will be picked.
+    If none of the conditions are `True`, an optional `.otherwise(<value if all statements are false>)` can be appended at the end. If not appended, and none of the conditions are `True`, `None` will be returned.
+
+    Arguments:
+        predicates: Condition(s) that must be met in order to apply the subsequent statement. Accepts one or more boolean expressions, which are implicitly combined with `&`. String input is parsed as a column name.
+
+    Examples:
+        >>> import pandas as pd
+        >>> import polars as pl
+        >>> import narwhals.stable.v1 as nw
+        >>> df_pl = pl.DataFrame({"a": [1, 2, 3], "b": [5, 10, 15]})
+        >>> df_pd = pd.DataFrame({"a": [1, 2, 3], "b": [5, 10, 15]})
+
+        We define a dataframe-agnostic function:
+
+        >>> @nw.narwhalify
+        ... def func(df_any):
+        ...     return df_any.with_columns(
+        ...         nw.when(nw.col("a") < 3).then(5).otherwise(6).alias("a_when")
+        ...     )
+
+        We can then pass either pandas or polars to `func`:
+
+        >>> func(df_pd)
+           a   b  a_when
+        0  1   5       5
+        1  2  10       5
+        2  3  15       6
+        >>> func(df_pl)
+        shape: (3, 3)
+        ┌─────┬─────┬────────┐
+        │ a   ┆ b   ┆ a_when │
+        │ --- ┆ --- ┆ ---    │
+        │ i64 ┆ i64 ┆ i32    │
+        ╞═════╪═════╪════════╡
+        │ 1   ┆ 5   ┆ 5      │
+        │ 2   ┆ 10  ┆ 5      │
+        │ 3   ┆ 15  ┆ 6      │
+        └─────┴─────┴────────┘
+    """
+    return When.from_when(nw_when(*predicates))
+
+
 def new_series(
     name: str,
     values: Any,
@@ -1592,11 +1700,13 @@ def from_dict(
 __all__ = [
     "selectors",
     "concat",
+    "dependencies",
     "to_native",
     "from_native",
     "is_ordered_categorical",
     "maybe_align_index",
     "maybe_convert_dtypes",
+    "maybe_get_index",
     "maybe_set_index",
     "get_native_namespace",
     "get_level",
@@ -1612,6 +1722,7 @@ __all__ = [
     "mean_horizontal",
     "sum",
     "sum_horizontal",
+    "when",
     "DataFrame",
     "LazyFrame",
     "Series",
diff --git a/narwhals/utils.py b/narwhals/utils.py
index cc2a482c..6c1b5c1b 100644
--- a/narwhals/utils.py
+++ b/narwhals/utils.py
@@ -22,6 +22,8 @@ from narwhals.dependencies import get_pyarrow
 from narwhals.dependencies import is_cudf_series
 from narwhals.dependencies import is_modin_series
 from narwhals.dependencies import is_pandas_dataframe
+from narwhals.dependencies import is_pandas_like_dataframe
+from narwhals.dependencies import is_pandas_like_series
 from narwhals.dependencies import is_pandas_series
 from narwhals.dependencies import is_polars_series
 from narwhals.dependencies import is_pyarrow_chunked_array
@@ -238,6 +240,37 @@ def maybe_align_index(lhs: T, rhs: Series | BaseFrame[Any]) -> T:
     return lhs
 
 
+def maybe_get_index(obj: T) -> Any | None:
+    """
+    Get the index of a DataFrame or a Series, if it's pandas-like.
+
+    Notes:
+        This is only really intended for backwards-compatibility purposes,
+        for example if your library already aligns indices for users.
+        If you're designing a new library, we highly encourage you to not
+        rely on the Index.
+        For non-pandas-like inputs, this returns `None`.
+
+    Examples:
+        >>> import pandas as pd
+        >>> import polars as pl
+        >>> import narwhals as nw
+        >>> df_pd = pd.DataFrame({"a": [1, 2], "b": [4, 5]})
+        >>> df = nw.from_native(df_pd)
+        >>> nw.maybe_get_index(df)
+        RangeIndex(start=0, stop=2, step=1)
+        >>> series_pd = pd.Series([1, 2])
+        >>> series = nw.from_native(series_pd, series_only=True)
+        >>> nw.maybe_get_index(series)
+        RangeIndex(start=0, stop=2, step=1)
+    """
+    obj_any = cast(Any, obj)
+    native_obj = to_native(obj_any)
+    if is_pandas_like_dataframe(native_obj) or is_pandas_like_series(native_obj):
+        return native_obj.index
+    return None
+
+
 def maybe_set_index(df: T, column_names: str | list[str]) -> T:
     """
     Set columns `columns` to be the index of `df`, if `df` is pandas-like.
@@ -261,21 +294,25 @@ def maybe_set_index(df: T, column_names: str | list[str]) -> T:
         4  1
         5  2
     """
-    from narwhals._pandas_like.dataframe import PandasLikeDataFrame
-
     df_any = cast(Any, df)
-    if isinstance(getattr(df_any, "_compliant_frame", None), PandasLikeDataFrame):
+    native_frame = to_native(df_any)
+    if is_pandas_like_dataframe(native_frame):
         return df_any._from_compliant_dataframe(  # type: ignore[no-any-return]
             df_any._compliant_frame._from_native_frame(
-                df_any._compliant_frame._native_frame.set_index(column_names)
+                native_frame.set_index(column_names)
             )
         )
-    return df
+    return df_any  # type: ignore[no-any-return]
 
 
-def maybe_convert_dtypes(df: T, *args: bool, **kwargs: bool | str) -> T:
+def maybe_convert_dtypes(obj: T, *args: bool, **kwargs: bool | str) -> T:
     """
-    Convert columns to the best possible dtypes using dtypes supporting ``pd.NA``, if df is pandas-like.
+    Convert columns or series to the best possible dtypes using dtypes supporting ``pd.NA``, if df is pandas-like.
+
+    Arguments:
+        obj: DataFrame or Series.
+        *args: Additional arguments which gets passed through.
+        **kwargs: Additional arguments which gets passed through.
 
     Notes:
         For non-pandas-like inputs, this is a no-op.
@@ -298,16 +335,21 @@ def maybe_convert_dtypes(df: T, *args: bool, **kwargs: bool | str) -> T:
         b           boolean
         dtype: object
     """
-    from narwhals._pandas_like.dataframe import PandasLikeDataFrame
-
-    df_any = cast(Any, df)
-    if isinstance(getattr(df_any, "_compliant_frame", None), PandasLikeDataFrame):
-        return df_any._from_compliant_dataframe(  # type: ignore[no-any-return]
-            df_any._compliant_frame._from_native_frame(
-                df_any._compliant_frame._native_frame.convert_dtypes(*args, **kwargs)
+    obj_any = cast(Any, obj)
+    native_obj = to_native(obj_any)
+    if is_pandas_like_dataframe(native_obj):
+        return obj_any._from_compliant_dataframe(  # type: ignore[no-any-return]
+            obj_any._compliant_frame._from_native_frame(
+                native_obj.convert_dtypes(*args, **kwargs)
+            )
+        )
+    if is_pandas_like_series(native_obj):
+        return obj_any._from_compliant_series(  # type: ignore[no-any-return]
+            obj_any._compliant_series._from_native_series(
+                native_obj.convert_dtypes(*args, **kwargs)
             )
         )
-    return df
+    return obj_any  # type: ignore[no-any-return]
 
 
 def is_ordered_categorical(series: Series) -> bool:
diff --git a/noxfile.py b/noxfile.py
index 9e0508b7..06cdc028 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -23,17 +23,20 @@ def run_common(session: Session, coverage_threshold: float) -> None:
 
 @nox.session(python=PYTHON_VERSIONS)  # type: ignore[misc]
 def pytest_coverage(session: Session) -> None:
-    coverage_threshold = 90 if session.python == "3.8" else 100
-
-    session.install("modin[dask]")
+    if session.python == "3.8":
+        coverage_threshold = 85
+    else:
+        coverage_threshold = 100
+        session.install("modin[dask]")
 
     run_common(session, coverage_threshold)
 
 
 @nox.session(python=PYTHON_VERSIONS[0])  # type: ignore[misc]
-def minimum_versions(session: Session) -> None:
+@nox.parametrize("pandas_version", ["0.25.3", "1.1.5"])  # type: ignore[misc]
+def min_and_old_versions(session: Session, pandas_version: str) -> None:
     session.install(
-        "pandas==0.25.3",
+        f"pandas=={pandas_version}",
         "polars==0.20.3",
         "numpy==1.17.5",
         "pyarrow==11.0.0",
@@ -46,12 +49,29 @@ def minimum_versions(session: Session) -> None:
 
 @nox.session(python=PYTHON_VERSIONS[-1])  # type: ignore[misc]
 def nightly_versions(session: Session) -> None:
-    session.install("modin[dask]", "polars")
-    session.install(
+    session.install("polars")
+
+    session.install(  # pandas nightly
         "--pre",
         "--extra-index-url",
         "https://pypi.anaconda.org/scientific-python-nightly-wheels/simple",
         "pandas",
     )
 
+    session.install(  # numpy nightly
+        "--pre",
+        "--extra-index-url",
+        "https://pypi.anaconda.org/scientific-python-nightly-wheels/simple",
+        "numpy",
+    )
+
+    session.run("uv", "pip", "install", "pip")
+    session.run(  # dask nightly
+        "pip",
+        "install",
+        "git+https://github.com/dask/distributed",
+        "git+https://github.com/dask/dask",
+        "git+https://github.com/dask/dask-expr",
+    )
+
     run_common(session, coverage_threshold=50)
diff --git a/pyproject.toml b/pyproject.toml
index f49f65c9..2aa6c800 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "narwhals"
-version = "1.5.2"
+version = "1.5.5"
 authors = [
   { name="Marco Gorelli", email="33491632+MarcoGorelli@users.noreply.github.com" },
 ]
@@ -30,8 +30,8 @@ cudf = ["cudf>=23.08.00"]
 modin = ["modin"]
 pandas = ["pandas>=0.25.3"]
 polars = ["polars>=0.20.3"]
-pyarrow = ['pyarrow>=11.0.0']
-dask = ['dask[dataframe]>=2024.7']
+pyarrow = ["pyarrow>=11.0.0"]
+dask = ["dask[dataframe]>=2024.7"]
 
 [project.urls]
 "Homepage" = "https://github.com/narwhals-dev/narwhals"


Test patch:
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
index 7f165742..0458a472 100644
--- a/.github/workflows/pytest.yml
+++ b/.github/workflows/pytest.yml
@@ -29,7 +29,7 @@ jobs:
       - name: show-deps
         run: uv pip freeze
       - name: Run pytest
-        run: pytest tests --cov=narwhals --cov=tests --cov-fail-under=90
+        run: pytest tests --cov=narwhals --cov=tests --cov-fail-under=85
       - name: Run doctests
         run: pytest narwhals --doctest-modules
 
diff --git a/tests/expr_and_series/binary_test.py b/tests/expr_and_series/binary_test.py
index 9d4e6cf6..2d55af22 100644
--- a/tests/expr_and_series/binary_test.py
+++ b/tests/expr_and_series/binary_test.py
@@ -1,15 +1,11 @@
 from typing import Any
 
-import pytest
-
 import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 
-def test_expr_binary(constructor: Any, request: Any) -> None:
+def test_expr_binary(constructor: Any) -> None:
     data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
-    if "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
     df_raw = constructor(data)
     result = nw.from_native(df_raw).with_columns(
         a=(1 + 3 * nw.col("a")) * (1 / nw.col("a")),
diff --git a/tests/expr_and_series/cast_test.py b/tests/expr_and_series/cast_test.py
index 3999b110..0b496d7a 100644
--- a/tests/expr_and_series/cast_test.py
+++ b/tests/expr_and_series/cast_test.py
@@ -47,8 +47,6 @@ schema = {
 
 @pytest.mark.filterwarnings("ignore:casting period[M] values to int64:FutureWarning")
 def test_cast(constructor: Any, request: Any) -> None:
-    if "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
     if "pyarrow_table_constructor" in str(constructor) and parse_version(
         pa.__version__
     ) <= (15,):  # pragma: no cover
@@ -98,17 +96,21 @@ def test_cast(constructor: Any, request: Any) -> None:
     assert dict(result.collect_schema()) == expected
 
 
-def test_cast_series(constructor_eager: Any, request: Any) -> None:
-    if "pyarrow_table_constructor" in str(constructor_eager) and parse_version(
+def test_cast_series(constructor: Any, request: Any) -> None:
+    if "pyarrow_table_constructor" in str(constructor) and parse_version(
         pa.__version__
     ) <= (15,):  # pragma: no cover
         request.applymarker(pytest.mark.xfail)
-    if "modin" in str(constructor_eager):
+    if "modin" in str(constructor):
         # TODO(unassigned): in modin, we end up with `'<U0'` dtype
         request.applymarker(pytest.mark.xfail)
-    df = nw.from_native(constructor_eager(data), eager_only=True).select(
-        nw.col(key).cast(value) for key, value in schema.items()
+    df = (
+        nw.from_native(constructor(data))
+        .select(nw.col(key).cast(value) for key, value in schema.items())
+        .lazy()
+        .collect()
     )
+
     expected = {
         "a": nw.Int32,
         "b": nw.Int16,
@@ -158,3 +160,22 @@ def test_cast_string() -> None:
     s = s.cast(nw.String)
     result = nw.to_native(s)
     assert str(result.dtype) in ("string", "object", "dtype('O')")
+
+
+def test_cast_raises_for_unknown_dtype(constructor: Any, request: Any) -> None:
+    if "pyarrow_table_constructor" in str(constructor) and parse_version(
+        pa.__version__
+    ) <= (15,):  # pragma: no cover
+        request.applymarker(pytest.mark.xfail)
+    if "polars" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+
+    df = nw.from_native(constructor(data)).select(
+        nw.col(key).cast(value) for key, value in schema.items()
+    )
+
+    class Banana:
+        pass
+
+    with pytest.raises(AssertionError, match=r"Unknown dtype"):
+        df.select(nw.col("a").cast(Banana))
diff --git a/tests/expr_and_series/filter_test.py b/tests/expr_and_series/filter_test.py
index ede0c49a..b55a0368 100644
--- a/tests/expr_and_series/filter_test.py
+++ b/tests/expr_and_series/filter_test.py
@@ -27,3 +27,6 @@ def test_filter_series(constructor_eager: Any) -> None:
     result = df.select(df["a"].filter((df["i"] < 2) & (df["c"] == 5)))
     expected = {"a": [0]}
     compare_dicts(result, expected)
+    result_s = df["a"].filter([True, False, False, False, False])
+    expected = {"a": [0]}
+    compare_dicts({"a": result_s}, expected)
diff --git a/tests/expr_and_series/sum_horizontal_test.py b/tests/expr_and_series/sum_horizontal_test.py
index 9411903c..4c4ab924 100644
--- a/tests/expr_and_series/sum_horizontal_test.py
+++ b/tests/expr_and_series/sum_horizontal_test.py
@@ -18,3 +18,12 @@ def test_sumh(constructor: Any, col_expr: Any) -> None:
         "horizontal_sum": [5, 7, 8],
     }
     compare_dicts(result, expected)
+
+
+def test_sumh_nullable(constructor: Any) -> None:
+    data = {"a": [1, 8, 3], "b": [4, 5, None]}
+    expected = {"hsum": [5, 13, 3]}
+
+    df = nw.from_native(constructor(data))
+    result = df.select(hsum=nw.sum_horizontal("a", "b"))
+    compare_dicts(result, expected)
diff --git a/tests/expr_and_series/when_test.py b/tests/expr_and_series/when_test.py
new file mode 100644
index 00000000..32bf604f
--- /dev/null
+++ b/tests/expr_and_series/when_test.py
@@ -0,0 +1,156 @@
+from __future__ import annotations
+
+from typing import Any
+
+import numpy as np
+import pytest
+
+import narwhals.stable.v1 as nw
+from tests.utils import compare_dicts
+
+data = {
+    "a": [1, 2, 3],
+    "b": ["a", "b", "c"],
+    "c": [4.1, 5.0, 6.0],
+    "d": [True, False, True],
+    "e": [7.0, 2.0, 1.1],
+}
+
+
+def test_when(request: Any, constructor: Any) -> None:
+    if "dask" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+
+    df = nw.from_native(constructor(data))
+    result = df.select(nw.when(nw.col("a") == 1).then(value=3).alias("a_when"))
+    expected = {
+        "a_when": [3, np.nan, np.nan],
+    }
+    compare_dicts(result, expected)
+
+
+def test_when_otherwise(request: Any, constructor: Any) -> None:
+    if "dask" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+
+    df = nw.from_native(constructor(data))
+    result = df.select(nw.when(nw.col("a") == 1).then(3).otherwise(6).alias("a_when"))
+    expected = {
+        "a_when": [3, 6, 6],
+    }
+    compare_dicts(result, expected)
+
+
+def test_multiple_conditions(request: Any, constructor: Any) -> None:
+    if "dask" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+
+    df = nw.from_native(constructor(data))
+    result = df.select(
+        nw.when(nw.col("a") < 3, nw.col("c") < 5.0).then(3).alias("a_when")
+    )
+    expected = {
+        "a_when": [3, np.nan, np.nan],
+    }
+    compare_dicts(result, expected)
+
+
+def test_no_arg_when_fail(request: Any, constructor: Any) -> None:
+    if "dask" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+
+    df = nw.from_native(constructor(data))
+    with pytest.raises((TypeError, ValueError)):
+        df.select(nw.when().then(value=3).alias("a_when"))
+
+
+def test_value_numpy_array(request: Any, constructor: Any) -> None:
+    if "dask" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+
+    df = nw.from_native(constructor(data))
+    import numpy as np
+
+    result = df.select(
+        nw.when(nw.col("a") == 1).then(np.asanyarray([3, 4, 5])).alias("a_when")
+    )
+    expected = {
+        "a_when": [3, np.nan, np.nan],
+    }
+    compare_dicts(result, expected)
+
+
+def test_value_series(constructor_eager: Any) -> None:
+    df = nw.from_native(constructor_eager(data))
+    s_data = {"s": [3, 4, 5]}
+    s = nw.from_native(constructor_eager(s_data))["s"]
+    assert isinstance(s, nw.Series)
+    result = df.select(nw.when(nw.col("a") == 1).then(s).alias("a_when"))
+    expected = {
+        "a_when": [3, np.nan, np.nan],
+    }
+    compare_dicts(result, expected)
+
+
+def test_value_expression(request: Any, constructor: Any) -> None:
+    if "dask" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+
+    df = nw.from_native(constructor(data))
+    result = df.select(nw.when(nw.col("a") == 1).then(nw.col("a") + 9).alias("a_when"))
+    expected = {
+        "a_when": [10, np.nan, np.nan],
+    }
+    compare_dicts(result, expected)
+
+
+def test_otherwise_numpy_array(request: Any, constructor: Any) -> None:
+    if "dask" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+
+    df = nw.from_native(constructor(data))
+    import numpy as np
+
+    result = df.select(
+        nw.when(nw.col("a") == 1).then(-1).otherwise(np.array([0, 9, 10])).alias("a_when")
+    )
+    expected = {
+        "a_when": [-1, 9, 10],
+    }
+    compare_dicts(result, expected)
+
+
+def test_otherwise_series(constructor_eager: Any) -> None:
+    df = nw.from_native(constructor_eager(data))
+    s_data = {"s": [0, 9, 10]}
+    s = nw.from_native(constructor_eager(s_data))["s"]
+    assert isinstance(s, nw.Series)
+    result = df.select(nw.when(nw.col("a") == 1).then(-1).otherwise(s).alias("a_when"))
+    expected = {
+        "a_when": [-1, 9, 10],
+    }
+    compare_dicts(result, expected)
+
+
+def test_otherwise_expression(request: Any, constructor: Any) -> None:
+    if "dask" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+
+    df = nw.from_native(constructor(data))
+    result = df.select(
+        nw.when(nw.col("a") == 1).then(-1).otherwise(nw.col("a") + 7).alias("a_when")
+    )
+    expected = {
+        "a_when": [-1, 9, 10],
+    }
+    compare_dicts(result, expected)
+
+
+def test_when_then_otherwise_into_expr(request: Any, constructor: Any) -> None:
+    if "dask" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+
+    df = nw.from_native(constructor(data))
+    result = df.select(nw.when(nw.col("a") > 1).then("c").otherwise("e"))
+    expected = {"c": [7, 5, 6]}
+    compare_dicts(result, expected)
diff --git a/tests/frame/filter_test.py b/tests/frame/filter_test.py
index 9b7ed45d..a8d3144a 100644
--- a/tests/frame/filter_test.py
+++ b/tests/frame/filter_test.py
@@ -12,11 +12,9 @@ def test_filter(constructor: Any) -> None:
     compare_dicts(result, expected)
 
 
-def test_filter_series(constructor_eager: Any) -> None:
+def test_filter_with_boolean_list(constructor: Any) -> None:
     data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
-    df = nw.from_native(constructor_eager(data), eager_only=True).with_columns(
-        mask=nw.col("a") > 1
-    )
-    result = df.filter(df["mask"]).drop("mask")
+    df = nw.from_native(constructor(data))
+    result = df.filter([False, True, True])
     expected = {"a": [3, 2], "b": [4, 6], "z": [8.0, 9.0]}
     compare_dicts(result, expected)
diff --git a/tests/frame/get_column_test.py b/tests/frame/get_column_test.py
index 70f65562..58766ac3 100644
--- a/tests/frame/get_column_test.py
+++ b/tests/frame/get_column_test.py
@@ -24,6 +24,9 @@ def test_non_string_name() -> None:
     result = nw.from_native(df, eager_only=True).get_column(0)  # type: ignore[arg-type]
     compare_dicts({"a": result}, {"a": [1, 2]})
     assert result.name == 0  # type: ignore[comparison-overlap]
-    with pytest.raises(TypeError, match="Expected str or slice"):
-        # Check that getitem would have raised
-        nw.from_native(df, eager_only=True)[0]  # type: ignore[call-overload]
+
+
+def test_get_single_row() -> None:
+    df = pd.DataFrame({"a": [1, 2], "b": [3, 4]})
+    result = nw.from_native(df, eager_only=True)[0]  # type: ignore[call-overload]
+    compare_dicts(result, {"a": [1], "b": [3]})
diff --git a/tests/frame/row_test.py b/tests/frame/row_test.py
new file mode 100644
index 00000000..602c50f5
--- /dev/null
+++ b/tests/frame/row_test.py
@@ -0,0 +1,14 @@
+from typing import Any
+
+import narwhals.stable.v1 as nw
+
+
+def test_row_column(constructor_eager: Any) -> None:
+    data = {
+        "a": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
+        "b": [11, 12, 13, 14, 15, 16],
+    }
+    result = nw.from_native(constructor_eager(data), eager_only=True).row(2)
+    if "pyarrow_table" in str(constructor_eager):
+        result = tuple(x.as_py() for x in result)
+    assert result == (3.0, 13)
diff --git a/tests/frame/select_test.py b/tests/frame/select_test.py
index 27a45ff0..450e9106 100644
--- a/tests/frame/select_test.py
+++ b/tests/frame/select_test.py
@@ -1,6 +1,7 @@
 from typing import Any
 
 import pandas as pd
+import pytest
 
 import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
@@ -21,5 +22,12 @@ def test_empty_select(constructor: Any) -> None:
 
 def test_non_string_select() -> None:
     df = nw.from_native(pd.DataFrame({0: [1, 2], "b": [3, 4]}))
-    result = nw.to_native(df.select(0))  # type: ignore[arg-type]
-    pd.testing.assert_frame_equal(result, pd.Series([1, 2], name=0).to_frame())
+    result = nw.to_native(df.select(nw.col(0)))  # type: ignore[arg-type]
+    expected = pd.Series([1, 2], name=0).to_frame()
+    pd.testing.assert_frame_equal(result, expected)
+
+
+def test_non_string_select_invalid() -> None:
+    df = nw.from_native(pd.DataFrame({0: [1, 2], "b": [3, 4]}))
+    with pytest.raises(TypeError, match="\n\nHint: if you were trying to select"):
+        nw.to_native(df.select(0))  # type: ignore[arg-type]
diff --git a/tests/frame/slice_test.py b/tests/frame/slice_test.py
index 222717d1..eea94d44 100644
--- a/tests/frame/slice_test.py
+++ b/tests/frame/slice_test.py
@@ -49,9 +49,16 @@ def test_slice_lazy_fails() -> None:
         _ = nw.from_native(pl.LazyFrame(data))[1:]
 
 
-def test_slice_int_fails(constructor_eager: Any) -> None:
-    with pytest.raises(TypeError, match="Expected str or slice, got: <class 'int'>"):
-        _ = nw.from_native(constructor_eager(data))[1]  # type: ignore[call-overload,index]
+def test_slice_int(constructor_eager: Any) -> None:
+    result = nw.from_native(constructor_eager(data), eager_only=True)[1]  # type: ignore[call-overload]
+    compare_dicts(result, {"a": [2], "b": [12]})
+
+
+def test_slice_fails(constructor_eager: Any) -> None:
+    class Foo: ...
+
+    with pytest.raises(TypeError, match="Expected str or slice, got:"):
+        nw.from_native(constructor_eager(data), eager_only=True)[Foo()]  # type: ignore[call-overload]
 
 
 def test_gather(constructor_eager: Any) -> None:
diff --git a/tests/selectors_test.py b/tests/selectors_test.py
index 128cb73c..ababee4a 100644
--- a/tests/selectors_test.py
+++ b/tests/selectors_test.py
@@ -7,7 +7,6 @@ import pyarrow as pa
 import pytest
 
 import narwhals.stable.v1 as nw
-from narwhals.dependencies import get_dask_dataframe
 from narwhals.selectors import all
 from narwhals.selectors import boolean
 from narwhals.selectors import by_dtype
@@ -57,8 +56,6 @@ def test_string(constructor: Any, request: Any) -> None:
 
 
 def test_categorical(request: Any, constructor: Any) -> None:
-    if "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
     if "pyarrow_table_constructor" in str(constructor) and parse_version(
         pa.__version__
     ) <= (15,):  # pragma: no cover
@@ -70,17 +67,6 @@ def test_categorical(request: Any, constructor: Any) -> None:
     compare_dicts(result, expected)
 
 
-@pytest.mark.skipif((get_dask_dataframe() is None), reason="too old for dask")
-def test_dask_categorical() -> None:
-    import dask.dataframe as dd
-
-    expected = {"b": ["a", "b", "c"]}
-    df_raw = dd.from_dict(expected, npartitions=1).astype({"b": "category"})
-    df = nw.from_native(df_raw)
-    result = df.select(categorical())
-    compare_dicts(result, expected)
-
-
 @pytest.mark.parametrize(
     ("selector", "expected"),
     [
diff --git a/tests/test_group_by.py b/tests/test_group_by.py
index 40b598de..2bb8d435 100644
--- a/tests/test_group_by.py
+++ b/tests/test_group_by.py
@@ -173,9 +173,6 @@ def test_group_by_multiple_keys(constructor: Any) -> None:
 
 
 def test_key_with_nulls(constructor: Any, request: Any) -> None:
-    if "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
-
     if "modin" in str(constructor):
         # TODO(unassigned): Modin flaky here?
         request.applymarker(pytest.mark.skip)
diff --git a/tests/test_utils.py b/tests/test_utils.py
index f8b9b98e..68dc90ed 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -2,6 +2,7 @@ import pandas as pd
 import polars as pl
 import pytest
 from pandas.testing import assert_frame_equal
+from pandas.testing import assert_index_equal
 from pandas.testing import assert_series_equal
 
 import narwhals.stable.v1 as nw
@@ -65,6 +66,24 @@ def test_maybe_set_index_polars() -> None:
     assert result is df
 
 
+def test_maybe_get_index_pandas() -> None:
+    pandas_df = pd.DataFrame({"a": [1, 2, 3]}, index=[1, 2, 0])
+    result = nw.maybe_get_index(nw.from_native(pandas_df))
+    assert_index_equal(result, pandas_df.index)
+    pandas_series = pd.Series([1, 2, 3], index=[1, 2, 0])
+    result_s = nw.maybe_get_index(nw.from_native(pandas_series, series_only=True))
+    assert_index_equal(result_s, pandas_series.index)
+
+
+def test_maybe_get_index_polars() -> None:
+    df = nw.from_native(pl.DataFrame({"a": [1, 2, 3]}))
+    result = nw.maybe_get_index(df)
+    assert result is None
+    series = nw.from_native(pl.Series([1, 2, 3]), series_only=True)
+    result = nw.maybe_get_index(series)
+    assert result is None
+
+
 @pytest.mark.skipif(
     parse_version(pd.__version__) < parse_version("1.0.0"),
     reason="too old for convert_dtypes",
@@ -72,10 +91,15 @@ def test_maybe_set_index_polars() -> None:
 def test_maybe_convert_dtypes_pandas() -> None:
     import numpy as np
 
-    df = nw.from_native(pd.DataFrame({"a": [1, np.nan]}, dtype=np.dtype("float64")))
+    df = nw.from_native(
+        pd.DataFrame({"a": [1, np.nan]}, dtype=np.dtype("float64")), eager_only=True
+    )
     result = nw.to_native(nw.maybe_convert_dtypes(df))
     expected = pd.DataFrame({"a": [1, pd.NA]}, dtype="Int64")
     pd.testing.assert_frame_equal(result, expected)
+    result_s = nw.to_native(nw.maybe_convert_dtypes(df["a"]))
+    expected_s = pd.Series([1, pd.NA], name="a", dtype="Int64")
+    pd.testing.assert_series_equal(result_s, expected_s)
 
 
 def test_maybe_convert_dtypes_polars() -> None: