Repository: narwhals-dev/narwhals
Pull request #1327 (2024-11-06 12:03:14)

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


MarcoGorelli: thanks @EdAbati for your review!

i've removed `replace` for now, we can address it later, `replace_strict` is more needed for the Plotly PR

I've also rewritten the signature to align it with the Polars args `old` and `new` instead of `mapping` - it's also a bit more concise to do 
```python
nw.col(col).replace_strict(order, range(len(order)))
```
than
```python
nw.col(col).replace_strict({key: i for i, key in enumerate(order)})
```
MarcoGorelli: Excellent comments, thanks both for reviews! 🙏 

i've amended the error message so now it shows something like:
```
ValueError: replace_strict did not replace all non-null values.

The following did not get replaced: ['cd']
```

Patch:
diff --git a/docs/api-reference/expr.md b/docs/api-reference/expr.md
index 7188b2c3..88dd86a6 100644
--- a/docs/api-reference/expr.md
+++ b/docs/api-reference/expr.md
@@ -36,6 +36,7 @@
         - over
         - pipe
         - quantile
+        - replace_strict
         - round
         - sample
         - shift
diff --git a/docs/api-reference/series.md b/docs/api-reference/series.md
index e8572dda..23b2c878 100644
--- a/docs/api-reference/series.md
+++ b/docs/api-reference/series.md
@@ -44,6 +44,7 @@
         - pipe
         - quantile
         - rename
+        - replace_strict
         - round
         - sample
         - scatter
diff --git a/narwhals/_arrow/expr.py b/narwhals/_arrow/expr.py
index 7b2af578..592fe615 100644
--- a/narwhals/_arrow/expr.py
+++ b/narwhals/_arrow/expr.py
@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING
 from typing import Any
 from typing import Callable
 from typing import Literal
+from typing import Sequence
 
 from narwhals._expression_parsing import reuse_series_implementation
 from narwhals._expression_parsing import reuse_series_namespace_implementation
@@ -320,6 +321,13 @@ class ArrowExpr:
     def unique(self: Self, *, maintain_order: bool = False) -> Self:
         return reuse_series_implementation(self, "unique", maintain_order=maintain_order)
 
+    def replace_strict(
+        self: Self, old: Sequence[Any], new: Sequence[Any], *, return_dtype: DType
+    ) -> Self:
+        return reuse_series_implementation(
+            self, "replace_strict", old, new, return_dtype=return_dtype
+        )
+
     def sort(self: Self, *, descending: bool = False, nulls_last: bool = False) -> Self:
         return reuse_series_implementation(
             self, "sort", descending=descending, nulls_last=nulls_last
diff --git a/narwhals/_arrow/namespace.py b/narwhals/_arrow/namespace.py
index 5eae258f..dd9289de 100644
--- a/narwhals/_arrow/namespace.py
+++ b/narwhals/_arrow/namespace.py
@@ -65,7 +65,7 @@ class ArrowNamespace:
     def _create_series_from_scalar(self, value: Any, series: ArrowSeries) -> ArrowSeries:
         from narwhals._arrow.series import ArrowSeries
 
-        if self._backend_version < (13,) and hasattr(value, "as_py"):  # pragma: no cover
+        if self._backend_version < (13,) and hasattr(value, "as_py"):
             value = value.as_py()
         return ArrowSeries._from_iterable(
             [value],
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index d63a92aa..15f64110 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -655,6 +655,26 @@ class ArrowSeries:
 
         return self._from_native_series(pc.unique(self._native_series))
 
+    def replace_strict(
+        self, old: Sequence[Any], new: Sequence[Any], *, return_dtype: DType
+    ) -> ArrowSeries:
+        import pyarrow as pa  # ignore-banned-import
+        import pyarrow.compute as pc  # ignore-banned-import
+
+        # https://stackoverflow.com/a/79111029/4451315
+        idxs = pc.index_in(self._native_series, pa.array(old))
+        result_native = pc.take(pa.array(new), idxs).cast(
+            narwhals_to_native_dtype(return_dtype, self._dtypes)
+        )
+        result = self._from_native_series(result_native)
+        if result.is_null().sum() != self.is_null().sum():
+            msg = (
+                "replace_strict did not replace all non-null values.\n\n"
+                f"The following did not get replaced: {self.filter(~self.is_null() & result.is_null()).unique().to_list()}"
+            )
+            raise ValueError(msg)
+        return result
+
     def sort(
         self: Self, *, descending: bool = False, nulls_last: bool = False
     ) -> ArrowSeries:
diff --git a/narwhals/_dask/expr.py b/narwhals/_dask/expr.py
index db29f6c4..3a5e69af 100644
--- a/narwhals/_dask/expr.py
+++ b/narwhals/_dask/expr.py
@@ -6,6 +6,7 @@ from typing import Any
 from typing import Callable
 from typing import Literal
 from typing import NoReturn
+from typing import Sequence
 
 from narwhals._dask.utils import add_row_index
 from narwhals._dask.utils import maybe_evaluate
@@ -477,6 +478,12 @@ class DaskExpr:
         msg = "`Expr.head` is not supported for the Dask backend. Please use `LazyFrame.head` instead."
         raise NotImplementedError(msg)
 
+    def replace_strict(
+        self, old: Sequence[Any], new: Sequence[Any], *, return_dtype: DType
+    ) -> Self:
+        msg = "`replace_strict` is not yet supported for Dask expressions"
+        raise NotImplementedError(msg)
+
     def sort(self, *, descending: bool = False, nulls_last: bool = False) -> NoReturn:
         # We can't (yet?) allow methods which modify the index
         msg = "`Expr.sort` is not supported for the Dask backend. Please use `LazyFrame.sort` instead."
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index 1ab35e28..8c30187b 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -429,7 +429,7 @@ class PandasLikeDataFrame:
             )
         else:
             # This is the logic in pandas' DataFrame.assign
-            if self._backend_version < (2,):  # pragma: no cover
+            if self._backend_version < (2,):
                 df = self._native_frame.copy(deep=True)
             else:
                 df = self._native_frame.copy(deep=False)
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index fb9e577b..a3c3aae0 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING
 from typing import Any
 from typing import Callable
 from typing import Literal
+from typing import Sequence
 
 from narwhals._expression_parsing import reuse_series_implementation
 from narwhals._expression_parsing import reuse_series_namespace_implementation
@@ -14,6 +15,7 @@ if TYPE_CHECKING:
 
     from narwhals._pandas_like.dataframe import PandasLikeDataFrame
     from narwhals._pandas_like.namespace import PandasLikeNamespace
+    from narwhals.dtypes import DType
     from narwhals.typing import DTypes
     from narwhals.utils import Implementation
 
@@ -271,6 +273,13 @@ class PandasLikeExpr:
     def drop_nulls(self) -> Self:
         return reuse_series_implementation(self, "drop_nulls")
 
+    def replace_strict(
+        self, old: Sequence[Any], new: Sequence[Any], *, return_dtype: DType
+    ) -> Self:
+        return reuse_series_implementation(
+            self, "replace_strict", old, new, return_dtype=return_dtype
+        )
+
     def sort(self, *, descending: bool = False, nulls_last: bool = False) -> Self:
         return reuse_series_implementation(
             self, "sort", descending=descending, nulls_last=nulls_last
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index 1fae75fb..c7e7d67b 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -491,6 +491,36 @@ class PandasLikeSeries:
     def shift(self, n: int) -> PandasLikeSeries:
         return self._from_native_series(self._native_series.shift(n))
 
+    def replace_strict(
+        self, old: Sequence[Any], new: Sequence[Any], *, return_dtype: DType
+    ) -> PandasLikeSeries:
+        tmp_name = f"{self.name}_tmp"
+        dtype = narwhals_to_native_dtype(
+            return_dtype,
+            self._native_series.dtype,
+            self._implementation,
+            self._backend_version,
+            self._dtypes,
+        )
+        other = self.__native_namespace__().DataFrame(
+            {
+                self.name: old,
+                tmp_name: self.__native_namespace__().Series(new, dtype=dtype),
+            }
+        )
+        result = self._from_native_series(
+            self._native_series.to_frame()
+            .merge(other, on=self.name, how="left")[tmp_name]
+            .rename(self.name)
+        )
+        if result.is_null().sum() != self.is_null().sum():
+            msg = (
+                "replace_strict did not replace all non-null values.\n\n"
+                f"The following did not get replaced: {self.filter(~self.is_null() & result.is_null()).unique().to_list()}"
+            )
+            raise ValueError(msg)
+        return result
+
     def sort(
         self, *, descending: bool = False, nulls_last: bool = False
     ) -> PandasLikeSeries:
diff --git a/narwhals/_polars/dataframe.py b/narwhals/_polars/dataframe.py
index 832331eb..b5640493 100644
--- a/narwhals/_polars/dataframe.py
+++ b/narwhals/_polars/dataframe.py
@@ -84,15 +84,15 @@ class PolarsDataFrame:
         return func
 
     def __array__(self, dtype: Any | None = None, copy: bool | None = None) -> np.ndarray:
-        if self._backend_version < (0, 20, 28) and copy is not None:  # pragma: no cover
+        if self._backend_version < (0, 20, 28) and copy is not None:
             msg = "`copy` in `__array__` is only supported for Polars>=0.20.28"
             raise NotImplementedError(msg)
-        if self._backend_version < (0, 20, 28):  # pragma: no cover
+        if self._backend_version < (0, 20, 28):
             return self._native_frame.__array__(dtype)
         return self._native_frame.__array__(dtype)
 
     def collect_schema(self) -> dict[str, Any]:
-        if self._backend_version < (1,):  # pragma: no cover
+        if self._backend_version < (1,):
             schema = self._native_frame.schema
         else:
             schema = dict(self._native_frame.collect_schema())
@@ -209,12 +209,12 @@ class PolarsDataFrame:
         return PolarsGroupBy(self, list(by), drop_null_keys=drop_null_keys)
 
     def with_row_index(self, name: str) -> Any:
-        if self._backend_version < (0, 20, 4):  # pragma: no cover
+        if self._backend_version < (0, 20, 4):
             return self._from_native_frame(self._native_frame.with_row_count(name))
         return self._from_native_frame(self._native_frame.with_row_index(name))
 
     def drop(self: Self, columns: list[str], strict: bool) -> Self:  # noqa: FBT001
-        if self._backend_version < (1, 0, 0):  # pragma: no cover
+        if self._backend_version < (1, 0, 0):
             to_drop = parse_columns_to_drop(
                 compliant_frame=self, columns=columns, strict=strict
             )
@@ -228,7 +228,7 @@ class PolarsDataFrame:
         variable_name: str | None,
         value_name: str | None,
     ) -> Self:
-        if self._backend_version < (1, 0, 0):  # pragma: no cover
+        if self._backend_version < (1, 0, 0):
             return self._from_native_frame(
                 self._native_frame.melt(
                     id_vars=index,
@@ -296,7 +296,7 @@ class PolarsLazyFrame:
         }
 
     def collect_schema(self) -> dict[str, Any]:
-        if self._backend_version < (1,):  # pragma: no cover
+        if self._backend_version < (1,):
             schema = self._native_frame.schema
         else:
             schema = dict(self._native_frame.collect_schema())
@@ -318,12 +318,12 @@ class PolarsLazyFrame:
         return PolarsLazyGroupBy(self, list(by), drop_null_keys=drop_null_keys)
 
     def with_row_index(self, name: str) -> Any:
-        if self._backend_version < (0, 20, 4):  # pragma: no cover
+        if self._backend_version < (0, 20, 4):
             return self._from_native_frame(self._native_frame.with_row_count(name))
         return self._from_native_frame(self._native_frame.with_row_index(name))
 
     def drop(self: Self, columns: list[str], strict: bool) -> Self:  # noqa: FBT001
-        if self._backend_version < (1, 0, 0):  # pragma: no cover
+        if self._backend_version < (1, 0, 0):
             return self._from_native_frame(self._native_frame.drop(columns))
         return self._from_native_frame(self._native_frame.drop(columns, strict=strict))
 
@@ -334,7 +334,7 @@ class PolarsLazyFrame:
         variable_name: str | None,
         value_name: str | None,
     ) -> Self:
-        if self._backend_version < (1, 0, 0):  # pragma: no cover
+        if self._backend_version < (1, 0, 0):
             return self._from_native_frame(
                 self._native_frame.melt(
                     id_vars=index,
diff --git a/narwhals/_polars/expr.py b/narwhals/_polars/expr.py
index 8a4c9373..fb531943 100644
--- a/narwhals/_polars/expr.py
+++ b/narwhals/_polars/expr.py
@@ -2,6 +2,7 @@ from __future__ import annotations
 
 from typing import TYPE_CHECKING
 from typing import Any
+from typing import Sequence
 
 from narwhals._polars.utils import extract_args_kwargs
 from narwhals._polars.utils import extract_native
@@ -16,16 +17,21 @@ if TYPE_CHECKING:
 
 
 class PolarsExpr:
-    def __init__(self, expr: Any, dtypes: DTypes) -> None:
+    def __init__(
+        self, expr: Any, dtypes: DTypes, backend_version: tuple[int, ...]
+    ) -> None:
         self._native_expr = expr
         self._implementation = Implementation.POLARS
         self._dtypes = dtypes
+        self._backend_version = backend_version
 
     def __repr__(self) -> str:  # pragma: no cover
         return "PolarsExpr"
 
     def _from_native_expr(self, expr: Any) -> Self:
-        return self.__class__(expr, dtypes=self._dtypes)
+        return self.__class__(
+            expr, dtypes=self._dtypes, backend_version=self._backend_version
+        )
 
     def __getattr__(self, attr: str) -> Any:
         def func(*args: Any, **kwargs: Any) -> Any:
@@ -41,6 +47,18 @@ class PolarsExpr:
         dtype = narwhals_to_native_dtype(dtype, self._dtypes)
         return self._from_native_expr(expr.cast(dtype))
 
+    def replace_strict(
+        self, old: Sequence[Any], new: Sequence[Any], *, return_dtype: DType
+    ) -> Self:
+        expr = self._native_expr
+        return_dtype = narwhals_to_native_dtype(return_dtype, self._dtypes)
+        if self._backend_version < (1,):
+            msg = f"`replace_strict` is only available in Polars>=1.0, found version {self._backend_version}"
+            raise NotImplementedError(msg)
+        return self._from_native_expr(
+            expr.replace_strict(old, new, return_dtype=return_dtype)
+        )
+
     def __eq__(self, other: object) -> Self:  # type: ignore[override]
         return self._from_native_expr(self._native_expr.__eq__(extract_native(other)))
 
diff --git a/narwhals/_polars/namespace.py b/narwhals/_polars/namespace.py
index 4eb8451b..4969ec9c 100644
--- a/narwhals/_polars/namespace.py
+++ b/narwhals/_polars/namespace.py
@@ -33,7 +33,11 @@ class PolarsNamespace:
 
         def func(*args: Any, **kwargs: Any) -> Any:
             args, kwargs = extract_args_kwargs(args, kwargs)  # type: ignore[assignment]
-            return PolarsExpr(getattr(pl, attr)(*args, **kwargs), dtypes=self._dtypes)
+            return PolarsExpr(
+                getattr(pl, attr)(*args, **kwargs),
+                dtypes=self._dtypes,
+                backend_version=self._backend_version,
+            )
 
         return func
 
@@ -42,19 +46,27 @@ class PolarsNamespace:
 
         from narwhals._polars.expr import PolarsExpr
 
-        if self._backend_version < (1, 0, 0):  # pragma: no cover
+        if self._backend_version < (1, 0, 0):
             msg = "`nth` is only supported for Polars>=1.0.0. Please use `col` for columns selection instead."
             raise AttributeError(msg)
-        return PolarsExpr(pl.nth(*indices), dtypes=self._dtypes)
+        return PolarsExpr(
+            pl.nth(*indices), dtypes=self._dtypes, backend_version=self._backend_version
+        )
 
     def len(self) -> PolarsExpr:
         import polars as pl  # ignore-banned-import()
 
         from narwhals._polars.expr import PolarsExpr
 
-        if self._backend_version < (0, 20, 5):  # pragma: no cover
-            return PolarsExpr(pl.count().alias("len"), dtypes=self._dtypes)
-        return PolarsExpr(pl.len(), dtypes=self._dtypes)
+        if self._backend_version < (0, 20, 5):
+            return PolarsExpr(
+                pl.count().alias("len"),
+                dtypes=self._dtypes,
+                backend_version=self._backend_version,
+            )
+        return PolarsExpr(
+            pl.len(), dtypes=self._dtypes, backend_version=self._backend_version
+        )
 
     def concat(
         self,
@@ -86,17 +98,28 @@ class PolarsNamespace:
             return PolarsExpr(
                 pl.lit(value, dtype=narwhals_to_native_dtype(dtype, self._dtypes)),
                 dtypes=self._dtypes,
+                backend_version=self._backend_version,
             )
-        return PolarsExpr(pl.lit(value), dtypes=self._dtypes)
+        return PolarsExpr(
+            pl.lit(value), dtypes=self._dtypes, backend_version=self._backend_version
+        )
 
     def mean(self, *column_names: str) -> PolarsExpr:
         import polars as pl  # ignore-banned-import()
 
         from narwhals._polars.expr import PolarsExpr
 
-        if self._backend_version < (0, 20, 4):  # pragma: no cover
-            return PolarsExpr(pl.mean([*column_names]), dtypes=self._dtypes)  # type: ignore[arg-type]
-        return PolarsExpr(pl.mean(*column_names), dtypes=self._dtypes)
+        if self._backend_version < (0, 20, 4):
+            return PolarsExpr(
+                pl.mean([*column_names]),  # type: ignore[arg-type]
+                dtypes=self._dtypes,
+                backend_version=self._backend_version,
+            )
+        return PolarsExpr(
+            pl.mean(*column_names),
+            dtypes=self._dtypes,
+            backend_version=self._backend_version,
+        )
 
     def mean_horizontal(self, *exprs: IntoPolarsExpr) -> PolarsExpr:
         import polars as pl  # ignore-banned-import()
@@ -105,16 +128,18 @@ class PolarsNamespace:
 
         polars_exprs = parse_into_exprs(*exprs, namespace=self)
 
-        if self._backend_version < (0, 20, 8):  # pragma: no cover
+        if self._backend_version < (0, 20, 8):
             return PolarsExpr(
                 pl.sum_horizontal(e._native_expr for e in polars_exprs)
                 / pl.sum_horizontal(1 - e.is_null()._native_expr for e in polars_exprs),
                 dtypes=self._dtypes,
+                backend_version=self._backend_version,
             )
 
         return PolarsExpr(
             pl.mean_horizontal(e._native_expr for e in polars_exprs),
             dtypes=self._dtypes,
+            backend_version=self._backend_version,
         )
 
     def concat_str(
@@ -136,7 +161,7 @@ class PolarsNamespace:
             )
         ]
 
-        if self._backend_version < (0, 20, 6):  # pragma: no cover
+        if self._backend_version < (0, 20, 6):
             null_mask = [expr.is_null() for expr in pl_exprs]
             sep = pl.lit(separator)
 
@@ -163,8 +188,7 @@ class PolarsNamespace:
                 )
 
             return PolarsExpr(
-                result,
-                dtypes=self._dtypes,
+                result, dtypes=self._dtypes, backend_version=self._backend_version
             )
 
         return PolarsExpr(
@@ -174,16 +198,18 @@ class PolarsNamespace:
                 ignore_nulls=ignore_nulls,
             ),
             dtypes=self._dtypes,
+            backend_version=self._backend_version,
         )
 
     @property
     def selectors(self) -> PolarsSelectors:
-        return PolarsSelectors(self._dtypes)
+        return PolarsSelectors(self._dtypes, backend_version=self._backend_version)
 
 
 class PolarsSelectors:
-    def __init__(self, dtypes: DTypes) -> None:
+    def __init__(self, dtypes: DTypes, backend_version: tuple[int, ...]) -> None:
         self._dtypes = dtypes
+        self._backend_version = backend_version
 
     def by_dtype(self, dtypes: Iterable[DType]) -> PolarsExpr:
         import polars as pl  # ignore-banned-import()
@@ -195,6 +221,7 @@ class PolarsSelectors:
                 [narwhals_to_native_dtype(dtype, self._dtypes) for dtype in dtypes]
             ),
             dtypes=self._dtypes,
+            backend_version=self._backend_version,
         )
 
     def numeric(self) -> PolarsExpr:
@@ -202,32 +229,50 @@ class PolarsSelectors:
 
         from narwhals._polars.expr import PolarsExpr
 
-        return PolarsExpr(pl.selectors.numeric(), dtypes=self._dtypes)
+        return PolarsExpr(
+            pl.selectors.numeric(),
+            dtypes=self._dtypes,
+            backend_version=self._backend_version,
+        )
 
     def boolean(self) -> PolarsExpr:
         import polars as pl  # ignore-banned-import()
 
         from narwhals._polars.expr import PolarsExpr
 
-        return PolarsExpr(pl.selectors.boolean(), dtypes=self._dtypes)
+        return PolarsExpr(
+            pl.selectors.boolean(),
+            dtypes=self._dtypes,
+            backend_version=self._backend_version,
+        )
 
     def string(self) -> PolarsExpr:
         import polars as pl  # ignore-banned-import()
 
         from narwhals._polars.expr import PolarsExpr
 
-        return PolarsExpr(pl.selectors.string(), dtypes=self._dtypes)
+        return PolarsExpr(
+            pl.selectors.string(),
+            dtypes=self._dtypes,
+            backend_version=self._backend_version,
+        )
 
     def categorical(self) -> PolarsExpr:
         import polars as pl  # ignore-banned-import()
 
         from narwhals._polars.expr import PolarsExpr
 
-        return PolarsExpr(pl.selectors.categorical(), dtypes=self._dtypes)
+        return PolarsExpr(
+            pl.selectors.categorical(),
+            dtypes=self._dtypes,
+            backend_version=self._backend_version,
+        )
 
     def all(self) -> PolarsExpr:
         import polars as pl  # ignore-banned-import()
 
         from narwhals._polars.expr import PolarsExpr
 
-        return PolarsExpr(pl.selectors.all(), dtypes=self._dtypes)
+        return PolarsExpr(
+            pl.selectors.all(), dtypes=self._dtypes, backend_version=self._backend_version
+        )
diff --git a/narwhals/_polars/series.py b/narwhals/_polars/series.py
index 9ca2f704..2abe0257 100644
--- a/narwhals/_polars/series.py
+++ b/narwhals/_polars/series.py
@@ -105,8 +105,18 @@ class PolarsSeries:
         dtype = narwhals_to_native_dtype(dtype, self._dtypes)
         return self._from_native_series(ser.cast(dtype))
 
+    def replace_strict(
+        self, old: Sequence[Any], new: Sequence[Any], *, return_dtype: DType
+    ) -> Self:
+        ser = self._native_series
+        dtype = narwhals_to_native_dtype(return_dtype, self._dtypes)
+        if self._backend_version < (1,):
+            msg = f"`replace_strict` is only available in Polars>=1.0, found version {self._backend_version}"
+            raise NotImplementedError(msg)
+        return self._from_native_series(ser.replace_strict(old, new, return_dtype=dtype))
+
     def __array__(self, dtype: Any = None, copy: bool | None = None) -> np.ndarray:
-        if self._backend_version < (0, 20, 29):  # pragma: no cover
+        if self._backend_version < (0, 20, 29):
             return self._native_series.__array__(dtype=dtype)
         return self._native_series.__array__(dtype=dtype, copy=copy)
 
@@ -184,7 +194,7 @@ class PolarsSeries:
     ) -> PolarsDataFrame:
         from narwhals._polars.dataframe import PolarsDataFrame
 
-        if self._backend_version < (0, 20, 15):  # pragma: no cover
+        if self._backend_version < (0, 20, 15):
             result = self._native_series.to_dummies(separator=separator)
             result = result.select(result.columns[int(drop_first) :])
         else:
@@ -197,7 +207,7 @@ class PolarsSeries:
         )
 
     def sort(self, *, descending: bool = False, nulls_last: bool = False) -> Self:
-        if self._backend_version < (0, 20, 6):  # pragma: no cover
+        if self._backend_version < (0, 20, 6):
             result = self._native_series.sort(descending=descending)
 
             if nulls_last:
@@ -228,7 +238,7 @@ class PolarsSeries:
     ) -> PolarsDataFrame:
         from narwhals._polars.dataframe import PolarsDataFrame
 
-        if self._backend_version < (1, 0, 0):  # pragma: no cover
+        if self._backend_version < (1, 0, 0):
             import polars as pl  # ignore-banned-import()
 
             value_name_ = name or ("proportion" if normalize else "count")
diff --git a/narwhals/expr.py b/narwhals/expr.py
index f1d9a193..624c253b 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -974,6 +974,74 @@ class Expr:
         """
         return self.__class__(lambda plx: self._call(plx).shift(n))
 
+    def replace_strict(
+        self, old: Sequence[Any], new: Sequence[Any], *, return_dtype: DType | type[DType]
+    ) -> Self:
+        """
+        Replace old values with new values.
+
+        This function must replace all non-null input values (else it raises an error),
+        and the return dtype must be specified.
+
+        Arguments:
+            old: Sequence of old values to replace.
+            new: Sequence of new values to replace.
+            return_dtype: Return dtype.
+
+        Examples:
+            >>> import narwhals as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> df_pd = pd.DataFrame({"a": [3, 0, 1, 2]})
+            >>> df_pl = pl.DataFrame({"a": [3, 0, 1, 2]})
+            >>> df_pa = pa.table({"a": [3, 0, 1, 2]})
+
+            Let's define dataframe-agnostic functions:
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns(
+            ...         b=nw.col("a").replace_strict(
+            ...             [0,1,2,3], ['zero', 'one', 'two', 'three']
+            ...             return_dtype=nw.String,
+            ...         )
+            ...     )
+
+            We can then pass any supported library such as Pandas, Polars, or PyArrow to `func`:
+
+            >>> func(df_pd)
+               a      b
+            0  3  three
+            1  0   zero
+            2  1    one
+            3  2    two
+            >>> func(df_pl)
+            shape: (4, 2)
+            ┌─────┬───────┐
+            │ a   ┆ b     │
+            │ --- ┆ ---   │
+            │ i64 ┆ str   │
+            ╞═════╪═══════╡
+            │ 3   ┆ three │
+            │ 0   ┆ zero  │
+            │ 1   ┆ one   │
+            │ 2   ┆ two   │
+            └─────┴───────┘
+            >>> func(df_pa)
+            pyarrow.Table
+            a: int64
+            b: string
+            ----
+            a: [[3,0,1,2]]
+            b: [["three","zero","one","two"]]
+        """
+        return self.__class__(
+            lambda plx: self._call(plx).replace_strict(
+                old, new, return_dtype=return_dtype
+            )
+        )
+
     def sort(self, *, descending: bool = False, nulls_last: bool = False) -> Self:
         """
         Sort this column. Place null values first.
diff --git a/narwhals/series.py b/narwhals/series.py
index 28c24113..7b179cce 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -1349,6 +1349,69 @@ class Series:
         """
         return self.alias(name=name)
 
+    def replace_strict(
+        self, old: Sequence[Any], new: Sequence[Any], *, return_dtype: DType | type[DType]
+    ) -> Self:
+        """
+        Replace old values with values.
+
+        This function must replace all non-null input values (else it raises an error),
+        and the return dtype must be specified.
+
+        Arguments:
+            old: Sequence of old values to replace.
+            new: Sequence of new values to replace.
+            return_dtype: Return dtype.
+
+        Examples:
+            >>> import narwhals as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> df_pd = pd.DataFrame({"a": [3, 0, 1, 2]})
+            >>> df_pl = pl.DataFrame({"a": [3, 0, 1, 2]})
+            >>> df_pa = pa.table({"a": [3, 0, 1, 2]})
+
+            Let's define dataframe-agnostic functions:
+
+            >>> @nw.narwhalify
+            ... def func(s):
+            ...     return s.replace_strict(
+            ...         [0, 1, 2, 3], ["zero", "one", "two", "three"], return_dtype=nw.String
+            ...     )
+
+            We can then pass any supported library such as Pandas, Polars, or PyArrow to `func`:
+
+            >>> func(df_pd["a"])
+            0    three
+            1     zero
+            2      one
+            3      two
+            Name: a, dtype: object
+            >>> func(df_pl["a"])  # doctest: +NORMALIZE_WHITESPACE
+            shape: (4,)
+            Series: 'a' [str]
+            [
+                "three"
+                "zero"
+                "one"
+                "two"
+            ]
+            >>> func(df_pa["a"])
+            <pyarrow.lib.ChunkedArray object at ...>
+            [
+              [
+                "three",
+                "zero",
+                "one",
+                "two"
+              ]
+            ]
+        """
+        return self._from_compliant_series(
+            self._compliant_series.replace_strict(old, new, return_dtype=return_dtype)
+        )
+
     def sort(self, *, descending: bool = False, nulls_last: bool = False) -> Self:
         """
         Sort this Series. Place null values first.
diff --git a/pyproject.toml b/pyproject.toml
index 167806dd..308089e8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -140,7 +140,8 @@ exclude_also = [
   "if sys.version_info() <",
   "if (:?self._)?implementation is Implementation.MODIN",
   "if (:?self._)?implementation is Implementation.CUDF",
-  'request.applymarker\(pytest.mark.xfail\)'
+  'request.applymarker\(pytest.mark.xfail\)',
+  'if self._backend_version < '
 ]
 
 [tool.mypy]


Test patch:
diff --git a/tests/expr_and_series/replace_strict_test.py b/tests/expr_and_series/replace_strict_test.py
new file mode 100644
index 00000000..8362897a
--- /dev/null
+++ b/tests/expr_and_series/replace_strict_test.py
@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+import pytest
+
+import narwhals.stable.v1 as nw
+from tests.utils import POLARS_VERSION
+from tests.utils import Constructor
+from tests.utils import ConstructorEager
+from tests.utils import assert_equal_data
+
+
+@pytest.mark.skipif(
+    POLARS_VERSION < (1, 0), reason="replace_strict only available after 1.0"
+)
+def test_replace_strict(constructor: Constructor, request: pytest.FixtureRequest) -> None:
+    if "dask" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+    df = nw.from_native(constructor({"a": [1, 2, 3]}))
+    result = df.select(
+        nw.col("a").replace_strict(
+            [1, 2, 3], ["one", "two", "three"], return_dtype=nw.String
+        )
+    )
+    assert_equal_data(result, {"a": ["one", "two", "three"]})
+
+
+@pytest.mark.skipif(
+    POLARS_VERSION < (1, 0), reason="replace_strict only available after 1.0"
+)
+def test_replace_strict_series(constructor_eager: ConstructorEager) -> None:
+    df = nw.from_native(constructor_eager({"a": [1, 2, 3]}))
+    result = df.select(
+        df["a"].replace_strict([1, 2, 3], ["one", "two", "three"], return_dtype=nw.String)
+    )
+    assert_equal_data(result, {"a": ["one", "two", "three"]})
+
+
+@pytest.mark.skipif(
+    POLARS_VERSION < (1, 0), reason="replace_strict only available after 1.0"
+)
+def test_replace_non_full(
+    constructor: Constructor, request: pytest.FixtureRequest
+) -> None:
+    from polars.exceptions import PolarsError
+
+    if "dask" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+    df = nw.from_native(constructor({"a": [1, 2, 3]}))
+    if isinstance(df, nw.LazyFrame):
+        with pytest.raises((ValueError, PolarsError)):
+            df.select(
+                nw.col("a").replace_strict([1, 3], [3, 4], return_dtype=nw.Int64)
+            ).collect()
+    else:
+        with pytest.raises((ValueError, PolarsError)):
+            df.select(nw.col("a").replace_strict([1, 3], [3, 4], return_dtype=nw.Int64))