Repository: narwhals-dev/narwhals

Issue:
feat: Add clip expression to the api
### We would like to learn about your use case. For example, if this feature is needed  to adopt Narwhals in an open source project, could you please enter the link to it below?


_No response_

### Please describe the purpose of the new feature or describe the problem to solve.

It would be a nice QOL and likely a speed enhancement to have a `clip` expression in the api

Since it is a simple mathematical function, it seems like `polars` and `pandas` largely agree on the api, looking something like - `clip(lower_limit, upper_limit, *, **kwargs)`

### Suggest a solution if possible.

Adding a `polars` looking expression `clip(lower_bound, upper_bound)` would solve this issue

### If you have tried alternatives, please describe them below.

_No response_

### Additional information that may help us understand your needs.

_No response_

Discussion:

Patch:
diff --git a/docs/api-reference/expr.md b/docs/api-reference/expr.md
index 492066aa..d37a1266 100644
--- a/docs/api-reference/expr.md
+++ b/docs/api-reference/expr.md
@@ -18,6 +18,7 @@
         - filter
         - gather_every
         - head
+        - clip
         - is_between
         - is_duplicated
         - is_first_distinct
diff --git a/docs/api-reference/series.md b/docs/api-reference/series.md
index 89ed25c6..011e4357 100644
--- a/docs/api-reference/series.md
+++ b/docs/api-reference/series.md
@@ -20,6 +20,7 @@
         - gather_every
         - head
         - is_between
+        - clip
         - is_duplicated
         - is_empty
         - is_first_distinct
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index e798bd4d..5231412e 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -196,6 +196,12 @@ class PandasLikeExpr:
         return reuse_series_implementation(self, "min", returns_scalar=True)
 
     # Other
+
+    def clip(self, lower_bound: Any, upper_bound: Any) -> Self:
+        return reuse_series_implementation(
+            self, "clip", lower_bound=lower_bound, upper_bound=upper_bound
+        )
+
     def is_between(
         self, lower_bound: Any, upper_bound: Any, closed: str = "both"
     ) -> Self:
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index e0746186..acd99bf5 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -631,6 +631,13 @@ class PandasLikeSeries:
     def gather_every(self: Self, n: int, offset: int = 0) -> Self:
         return self._from_native_series(self._native_series.iloc[offset::n])
 
+    def clip(
+        self: Self, lower_bound: Any | None = None, upper_bound: Any | None = None
+    ) -> Self:
+        return self._from_native_series(
+            self._native_series.clip(lower_bound, upper_bound)
+        )
+
     @property
     def str(self) -> PandasLikeSeriesStringNamespace:
         return PandasLikeSeriesStringNamespace(self)
diff --git a/narwhals/expr.py b/narwhals/expr.py
index baab0f25..27185a6e 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -1765,6 +1765,122 @@ class Expr:
             lambda plx: self._call(plx).gather_every(n=n, offset=offset)
         )
 
+    # need to allow numeric typing
+    # TODO @aivanoved: make type alias for numeric type
+    def clip(
+        self,
+        lower_bound: IntoExpr | Any | None = None,
+        upper_bound: IntoExpr | Any | None = None,
+    ) -> Self:
+        r"""
+        Clip values in the Series.
+
+        Arguments:
+            lower_bound: Lower bound value.
+            upper_bound: Upper bound value.
+
+        Examples:
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import narwhals as nw
+            >>>
+            >>> s = [1, 2, 3]
+            >>> df_pd = pd.DataFrame({"s": s})
+            >>> df_pl = pl.DataFrame({"s": s})
+
+            We define a library agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func_lower(df):
+            ...     return df.select(nw.col("s").clip(2))
+
+            We can then pass either pandas or Polars to `func_lower`:
+
+            >>> func_lower(df_pd)
+               s
+            0  2
+            1  2
+            2  3
+            >>> func_lower(df_pl)  # doctest: +NORMALIZE_WHITESPACE
+            shape: (3, 1)
+            ┌─────┐
+            │ s   │
+            │ --- │
+            │ i64 │
+            ╞═════╡
+            │ 2   │
+            │ 2   │
+            │ 3   │
+            └─────┘
+
+            We define another library agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func_upper(df):
+            ...     return df.select(nw.col("s").clip(upper_bound=2))
+
+            We can then pass either pandas or Polars to `func_upper`:
+
+            >>> func_upper(df_pd)
+               s
+            0  1
+            1  2
+            2  2
+            >>> func_upper(df_pl)  # doctest: +NORMALIZE_WHITESPACE
+            shape: (3, 1)
+            ┌─────┐
+            │ s   │
+            │ --- │
+            │ i64 │
+            ╞═════╡
+            │ 1   │
+            │ 2   │
+            │ 2   │
+            └─────┘
+
+            We can have both at the same time
+
+            >>> s = [-1, 1, -3, 3, -5, 5]
+            >>> df_pd = pd.DataFrame({"s": s})
+            >>> df_pl = pl.DataFrame({"s": s})
+
+            We define a library agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.select(nw.col("s").clip(-1, 3))
+
+            We can pass either pandas or Polars to `func`:
+
+            >>> func(df_pd)
+               s
+            0 -1
+            1  1
+            2 -1
+            3  3
+            4 -1
+            5  3
+            >>> func(df_pl)  # doctest: +NORMALIZE_WHITESPACE
+            shape: (6, 1)
+            ┌─────┐
+            │ s   │
+            │ --- │
+            │ i64 │
+            ╞═════╡
+            │ -1  │
+            │ 1   │
+            │ -1  │
+            │ 3   │
+            │ -1  │
+            │ 3   │
+            └─────┘
+        """
+        return self.__class__(
+            lambda plx: self._call(plx).clip(
+                extract_compliant(plx, lower_bound), extract_compliant(plx, upper_bound)
+            )
+        )
+
     @property
     def str(self: Self) -> ExprStringNamespace:
         return ExprStringNamespace(self)
diff --git a/narwhals/series.py b/narwhals/series.py
index 2582978c..b8e98cd7 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -544,6 +544,107 @@ class Series:
         """
         return self._compliant_series.std(ddof=ddof)
 
+    def clip(
+        self, lower_bound: Any | None = None, upper_bound: Any | None = None
+    ) -> Self:
+        r"""
+        Clip values in the Series.
+
+        Arguments:
+            lower_bound: Lower bound value.
+            upper_bound: Upper bound value.
+
+        Examples:
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import narwhals as nw
+            >>>
+            >>> s = [1, 2, 3]
+            >>> s_pd = pd.Series(s)
+            >>> s_pl = pl.Series(s)
+
+            We define a library agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func_lower(s):
+            ...     return s.clip(2)
+
+            We can then pass either pandas or Polars to `func_lower`:
+
+            >>> func_lower(s_pd)
+            0    2
+            1    2
+            2    3
+            dtype: int64
+            >>> func_lower(s_pl)  # doctest: +NORMALIZE_WHITESPACE
+            shape: (3,)
+            Series: '' [i64]
+            [
+               2
+               2
+               3
+            ]
+
+            We define another library agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func_upper(s):
+            ...     return s.clip(upper_bound=2)
+
+            We can then pass either pandas or Polars to `func_upper`:
+
+            >>> func_upper(s_pd)
+            0    1
+            1    2
+            2    2
+            dtype: int64
+            >>> func_upper(s_pl)  # doctest: +NORMALIZE_WHITESPACE
+            shape: (3,)
+            Series: '' [i64]
+            [
+               1
+               2
+               2
+            ]
+
+            We can have both at the same time
+
+            >>> s = [-1, 1, -3, 3, -5, 5]
+            >>> s_pd = pd.Series(s)
+            >>> s_pl = pl.Series(s)
+
+            We define a library agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(s):
+            ...     return s.clip(-1, 3)
+
+            We can pass either pandas or Polars to `func`:
+
+            >>> func(s_pd)
+            0   -1
+            1    1
+            2   -1
+            3    3
+            4   -1
+            5    3
+            dtype: int64
+            >>> func(s_pl)  # doctest: +NORMALIZE_WHITESPACE
+            shape: (6,)
+            Series: '' [i64]
+            [
+               -1
+               1
+               -1
+               3
+               -1
+               3
+            ]
+        """
+        return self._from_compliant_series(
+            self._compliant_series.clip(lower_bound=lower_bound, upper_bound=upper_bound)
+        )
+
     def is_in(self, other: Any) -> Self:
         """
         Check if the elements of this Series are in the other sequence.


Test patch:
diff --git a/tests/expr_and_series/clip_test.py b/tests/expr_and_series/clip_test.py
new file mode 100644
index 00000000..94b54272
--- /dev/null
+++ b/tests/expr_and_series/clip_test.py
@@ -0,0 +1,24 @@
+from typing import Any
+
+import pytest
+
+import narwhals.stable.v1 as nw
+from tests.utils import compare_dicts
+
+
+def test_clip(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({"a": [1, 2, 3, -4, 5]}))
+    result = df.select(b=nw.col("a").clip(3, 5))
+    expected = {"b": [3, 3, 3, 3, 5]}
+    compare_dicts(result, expected)
+
+
+def test_clip_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({"a": [1, 2, 3, -4, 5]}), eager_only=True)
+    result = {"b": df["a"].clip(3, 5)}
+    expected = {"b": [3, 3, 3, 3, 5]}
+    compare_dicts(result, expected)