Repository: narwhals-dev/narwhals
Pull request #588 (2024-07-23 09:22:00)

feat: add when-then-otherwise expression
<!--
# 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
- [x] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues 

- Related issue #47 

## Checklist

- [x] Code follows style guide (ruff)
- [x] Tests added 
- [x] Documented the changes

## If you have comments or can explain your changes, please do so below.

This PR so far adds a simple `when-then-otherwise` expression with no chaining

~The PR does not aim to be complete as of now, the current aim is to get an initial review as to if this approach is consistent with the project's quality and style of code~

TODO:
- [x] Allow `Iterable[IntoPandasExpr]` to the pandas API for `predicates`
- [x] ~Allow `when-then-otherwise` chaining~ obsolete: see comments below and #668
- [x] Address noqa ignores

~Note: Temporary converted back to draft due to a git rebase issue~ fixed

Note: due to an unfortunate rebase gone wrong, the commit history is a mess, we could open a new pr from a new branch or when merging do a squash merge. Further advice on this would be much appreciated.

aivanoved: @MarcoGorelli can you take another look at this?
aivanoved: @MarcoGorelli 

Note that I have added support for users to be able to provide `Series` or `Expr` as `then` and `otherwise` values, not just scalars, which I think is a crucial behaviour

Currently not all code paths are tested as according to the coverage test, am looking into this
MarcoGorelli: legend, thanks!

cool, once code coverage is complete we can ship it 🚀 
aivanoved: @MarcoGorelli

can you take another look at this?
MarcoGorelli: thanks @aivanoved ! 

looks like there's a warning in the docs build
```
WARNING -  griffe: narwhals/expr.py:3676: Failed to get 'name: description' pair from 'predicates'
```
and test failures on python 3.8
```
FAILED tests/expr_and_series/when_test.py::test_no_arg_when_fail[polars_eager_constructor] - ValueError: No predicates or constraints provided to `when`.
FAILED tests/expr_and_series/when_test.py::test_no_arg_when_fail[polars_lazy_constructor] - ValueError: No predicates or constraints provided to `when`.
```
aivanoved: The doc test I will address this

However, for the python 3.8 issue - the issue comes from that starting from `polars` version `0.20.4` onwards the error thrown when there are no `predicates` or `constraints` is a `TypeError`, for which we test
For versions below this, meaning `polars<=0.20.3`, the error thrown is a `ValueError`, in the ci the installed version of `polars` is `0.20.3`

I can change the error type depending on the installed `polars` version, however this seems unsatisfactory, please advise @MarcoGorelli

For reference [here](https://github.com/pola-rs/polars/blob/58b0da8bc31d71d21be8782c5013747516652f81/py-polars/polars/utils/_parse_expr_input.py#L148) is the error thrown in version `0.20.3`
And [here](https://github.com/pola-rs/polars/blob/cdef09b98f5164271ac1a489b395accf4a363549/py-polars/polars/utils/_parse_expr_input.py#L184) is the same error in version `0.20.4`

> thanks @aivanoved !
> 
> looks like there's a warning in the docs build
> 
> ```
> WARNING -  griffe: narwhals/expr.py:3676: Failed to get 'name: description' pair from 'predicates'
> ```
> 
> and test failures on python 3.8
> 
> ```
> FAILED tests/expr_and_series/when_test.py::test_no_arg_when_fail[polars_eager_constructor] - ValueError: No predicates or constraints provided to `when`.
> FAILED tests/expr_and_series/when_test.py::test_no_arg_when_fail[polars_lazy_constructor] - ValueError: No predicates or constraints provided to `when`.
> ```


MarcoGorelli: I think you can do `with pytest.raises((ValueError, TypeError)):`

any reason why you closed the PR?
aivanoved: No, I guess I misclicked and canceled it by accident
aivanoved: > I think you can do `with pytest.raises((ValueError, TypeError)):`
> 
> 
> 
> any reason why you closed the PR?

As long as we are fine with having `narwhals` and `polars` throwing different types of error for `polars<=0.20.3` this works, just want to make sure we are happy with  the this decision
aivanoved: @MarcoGorelli

Everything should be working now I think
aivanoved: gentle ping @MarcoGorelli
MarcoGorelli: thanks for updating - hoping to merge this soon
aivanoved: I'm currently on vacation will take a closer look into this again, but will be slower to respond, apologies for the delay
MarcoGorelli: cool, no hurry, enjoy your holiday, thanks @aivanoved !
aivanoved: Current ci fails with the new `zip_with`, due to a know, issue in `modin`, [here](https://github.com/modin-project/modin/issues/7364)

Patch:
diff --git a/docs/api-reference/narwhals.md b/docs/api-reference/narwhals.md
index 2774a093..d808263a 100644
--- a/docs/api-reference/narwhals.md
+++ b/docs/api-reference/narwhals.md
@@ -29,6 +29,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/narwhals/__init__.py b/narwhals/__init__.py
index 2c43ccde..6150e5d0 100644
--- a/narwhals/__init__.py
+++ b/narwhals/__init__.py
@@ -35,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
@@ -79,6 +80,7 @@ __all__ = [
     "mean_horizontal",
     "sum",
     "sum_horizontal",
+    "when",
     "DataFrame",
     "LazyFrame",
     "Series",
diff --git a/narwhals/_pandas_like/namespace.py b/narwhals/_pandas_like/namespace.py
index fcaf849e..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
@@ -249,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/expr.py b/narwhals/expr.py
index 9d8e5386..cc8b8695 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -3954,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 a11df24c..9c7c2d15 100644
--- a/narwhals/stable/v1.py
+++ b/narwhals/stable/v1.py
@@ -34,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
@@ -1404,7 +1407,7 @@ 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.
@@ -1493,6 +1496,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,
@@ -1624,6 +1693,7 @@ __all__ = [
     "mean_horizontal",
     "sum",
     "sum_horizontal",
+    "when",
     "DataFrame",
     "LazyFrame",
     "Series",
diff --git a/narwhals/utils.py b/narwhals/utils.py
index 4142675e..f0e5c1cf 100644
--- a/narwhals/utils.py
+++ b/narwhals/utils.py
@@ -276,7 +276,7 @@ def maybe_set_index(df: T, column_names: str | list[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.
diff --git a/pyproject.toml b/pyproject.toml
index a9a1c539..b4318455 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -26,12 +26,12 @@ exclude = [
 ]
 
 [project.optional-dependencies]
-cudf = ["cudf>=23.08.00; python_version >= '3.9'"]
+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; python_version >= '3.9'"]
+dask = ["dask[dataframe]>=2024.7"]
 
 [project.urls]
 "Homepage" = "https://github.com/narwhals-dev/narwhals"


Test patch:
diff --git a/tests/expr_and_series/when_test.py b/tests/expr_and_series/when_test.py
new file mode 100644
index 00000000..741c676a
--- /dev/null
+++ b/tests/expr_and_series/when_test.py
@@ -0,0 +1,165 @@
+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 "pyarrow_table" in str(constructor) or "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 "pyarrow_table" in str(constructor) or "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 "pyarrow_table" in str(constructor) or "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 "pyarrow_table" in str(constructor) or "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 "pyarrow_table" in str(constructor) or "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(request: Any, constructor_eager: Any) -> None:
+    if "pyarrow_table" in str(constructor_eager):
+        request.applymarker(pytest.mark.xfail)
+
+    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 "pyarrow_table" in str(constructor) or "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 "pyarrow_table" in str(constructor) or "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.asanyarray([0, 9, 10]))
+        .alias("a_when")
+    )
+    expected = {
+        "a_when": [-1, 9, 10],
+    }
+    compare_dicts(result, expected)
+
+
+def test_otherwise_series(request: Any, constructor_eager: Any) -> None:
+    if "pyarrow_table" in str(constructor_eager):
+        request.applymarker(pytest.mark.xfail)
+
+    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 "pyarrow_table" in str(constructor) or "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 "pyarrow_table" in str(constructor) or "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)