Repository: narwhals-dev/narwhals

Issue:
api: "unstable" features
I'm slightly hesitant about how to merge #1298 and #1290 - rolling_mean is marked as unstable in Polars. ewm_mean isn't, but follows the api kind of API style

I do think we should merge them, I just think we need a way to signal that we won't make the same kinds of promises for them as we do for the rest of the API in `stable.v1`

So, I'm tempted to say:
- let's wait for some decision / further discussion on https://github.com/plotly/plotly.py/issues/4834
- we can discuss the Narwhals unstable API

I'm sorry for the delay here in merging these PRs

Discussion:
MarcoGorelli: Love the creativity here

I tried running this hypothesis test
```python
from hypothesis import given
import hypothesis.strategies as st
import pyarrow as pa
import pandas as pd

@given(
    center = st.booleans(),
    values = st.lists(st.floats(-10, 10), min_size=3, max_size=10),
)
@pytest.mark.filterwarnings('ignore:.*:narwhals.exceptions.NarwhalsUnstableWarning')
def test_rolling_sum_hypothesis(center: bool, values: list[float]) -> None:
    s = pd.Series(values)
    n_missing = random.randint(0, len(s)-1)
    window_size = random.randint(1, len(s))
    min_periods = random.randint(0, window_size)
    mask = random.sample(range(len(s)), n_missing)
    s[mask] = None
    df = pd.DataFrame({'a': s})
    expected = s.rolling(window=window_size, center=center, min_periods=min_periods).sum().to_frame('a')
    result = nw.from_native(pa.Table.from_pandas(df)).select(nw.col('a').rolling_sum(window_size, center=center, min_periods=min_periods))
    expected_dict = nw.from_native(expected, eager_only=True).to_dict(as_series=False)
    assert_equal_data(result, expected_dict)
```
and it's picking up some small inconsistencies:

```
In [19]: s
Out[19]: 
0    0.0
1    NaN
2    0.0
dtype: float64

In [20]: s.rolling(min_periods=0, center=False, window=2).sum()
Out[20]: 
0    0.0
1    0.0
2    0.0
dtype: float64

In [21]: nw.from_native(pa.chunked_array([s]), series_only=True).rolling_sum(min_periods=0, center=False, window_size=2).to_native()
Out[21]: 
<pyarrow.lib.ChunkedArray object at 0x7f7cd5793ee0>
[
  [
    null,
    null,
    null
  ]
]

In [22]: nw.from_native(pl.from_pandas(s), series_only=True).rolling_sum(min_periods=0, center=False, window_size=2).to_native()
Out[22]: 
shape: (3,)
Series: '' [f64]
[
        0.0
        0.0
        0.0
]
```
FBruzzesi: > Love the creativity here
> 
> I tried running this hypothesis test
> 
> ```python
> from hypothesis import given
> import hypothesis.strategies as st
> import pyarrow as pa
> import pandas as pd
> 
> @given(
>     center = st.booleans(),
>     values = st.lists(st.floats(-10, 10), min_size=3, max_size=10),
> )
> @pytest.mark.filterwarnings('ignore:.*:narwhals.exceptions.NarwhalsUnstableWarning')
> def test_rolling_sum_hypothesis(center: bool, values: list[float]) -> None:
>     s = pd.Series(values)
>     n_missing = random.randint(0, len(s)-1)
>     window_size = random.randint(1, len(s))
>     min_periods = random.randint(0, window_size)
>     mask = random.sample(range(len(s)), n_missing)
>     s[mask] = None
>     df = pd.DataFrame({'a': s})
>     expected = s.rolling(window=window_size, center=center, min_periods=min_periods).sum().to_frame('a')
>     result = nw.from_native(pa.Table.from_pandas(df)).select(nw.col('a').rolling_sum(window_size, center=center, min_periods=min_periods))
>     expected_dict = nw.from_native(expected, eager_only=True).to_dict(as_series=False)
>     assert_equal_data(result, expected_dict)
> ```
> 
> and it's picking up some small inconsistencies:
> 
> ```
> In [19]: s
> Out[19]: 
> 0    0.0
> 1    NaN
> 2    0.0
> dtype: float64
> 
> In [20]: s.rolling(min_periods=0, center=False, window=2).sum()
> Out[20]: 
> 0    0.0
> 1    0.0
> 2    0.0
> dtype: float64
> 
> In [21]: nw.from_native(pa.chunked_array([s]), series_only=True).rolling_sum(min_periods=0, center=False, window_size=2).to_native()
> Out[21]: 
> <pyarrow.lib.ChunkedArray object at 0x7f7cd5793ee0>
> [
>   [
>     null,
>     null,
>     null
>   ]
> ]
> 
> In [22]: nw.from_native(pl.from_pandas(s), series_only=True).rolling_sum(min_periods=0, center=False, window_size=2).to_native()
> Out[22]: 
> shape: (3,)
> Series: '' [f64]
> [
>         0.0
>         0.0
>         0.0
> ]
> ```

Well ok `min_periods = min_periods or window_size` evaluates to 2, because of `0 or 2`. Let me adjust
FBruzzesi: I would tend towards restricting the API to:

- `window_size` strictly positive
- `min_periods` either None or strictly positive

and raise in other cases.
MarcoGorelli: thanks - shall we also include the hypothesis test?

Patch:
diff --git a/docs/api-reference/exceptions.md b/docs/api-reference/exceptions.md
index b6841597..e37b0f3e 100644
--- a/docs/api-reference/exceptions.md
+++ b/docs/api-reference/exceptions.md
@@ -7,5 +7,6 @@
         - ColumnNotFoundError
         - InvalidIntoExprError
         - InvalidOperationError
+        - NarwhalsUnstableWarning
       show_source: false
       show_bases: false
diff --git a/docs/api-reference/expr.md b/docs/api-reference/expr.md
index 1dfde821..627e1cb4 100644
--- a/docs/api-reference/expr.md
+++ b/docs/api-reference/expr.md
@@ -44,6 +44,7 @@
         - pipe
         - quantile
         - replace_strict
+        - rolling_sum
         - round
         - sample
         - shift
diff --git a/docs/api-reference/series.md b/docs/api-reference/series.md
index 6539dcc2..12ca7220 100644
--- a/docs/api-reference/series.md
+++ b/docs/api-reference/series.md
@@ -51,6 +51,7 @@
         - quantile
         - rename
         - replace_strict
+        - rolling_sum
         - round
         - sample
         - scatter
diff --git a/narwhals/_arrow/expr.py b/narwhals/_arrow/expr.py
index 4828ea4b..7e43620b 100644
--- a/narwhals/_arrow/expr.py
+++ b/narwhals/_arrow/expr.py
@@ -453,6 +453,21 @@ class ArrowExpr:
     def cum_prod(self: Self, *, reverse: bool) -> Self:
         return reuse_series_implementation(self, "cum_prod", reverse=reverse)
 
+    def rolling_sum(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None,
+        center: bool,
+    ) -> Self:
+        return reuse_series_implementation(
+            self,
+            "rolling_sum",
+            window_size=window_size,
+            min_periods=min_periods,
+            center=center,
+        )
+
     @property
     def dt(self: Self) -> ArrowExprDateTimeNamespace:
         return ArrowExprDateTimeNamespace(self)
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index 0edaedd3..3c41c42a 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -874,6 +874,54 @@ class ArrowSeries:
         )
         return self._from_native_series(result)
 
+    def rolling_sum(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None,
+        center: bool,
+    ) -> Self:
+        import pyarrow as pa  # ignore-banned-import
+        import pyarrow.compute as pc  # ignore-banned-import
+
+        min_periods = min_periods if min_periods is not None else window_size
+        if center:
+            offset_left = window_size // 2
+            offset_right = offset_left - (
+                window_size % 2 == 0
+            )  # subtract one if window_size is even
+
+            native_series = self._native_series
+
+            pad_left = pa.array([None] * offset_left, type=native_series.type)
+            pad_right = pa.array([None] * offset_right, type=native_series.type)
+            padded_arr = self._from_native_series(
+                pa.concat_arrays([pad_left, native_series.combine_chunks(), pad_right])
+            )
+        else:
+            padded_arr = self
+
+        cum_sum = padded_arr.cum_sum(reverse=False).fill_null(strategy="forward")
+        rolling_sum = (
+            cum_sum - cum_sum.shift(window_size).fill_null(0)
+            if window_size != 0
+            else cum_sum
+        )
+
+        valid_count = padded_arr.cum_count(reverse=False)
+        count_in_window = valid_count - valid_count.shift(window_size).fill_null(0)
+
+        result = self._from_native_series(
+            pc.if_else(
+                (count_in_window >= min_periods)._native_series,
+                rolling_sum._native_series,
+                None,
+            )
+        )
+        if center:
+            result = result[offset_left + offset_right :]
+        return result
+
     def __iter__(self: Self) -> Iterator[Any]:
         yield from self._native_series.__iter__()
 
diff --git a/narwhals/_dask/expr.py b/narwhals/_dask/expr.py
index da1ee52e..912d6029 100644
--- a/narwhals/_dask/expr.py
+++ b/narwhals/_dask/expr.py
@@ -840,6 +840,32 @@ class DaskExpr:
             returns_scalar=False,
         )
 
+    def rolling_sum(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None,
+        center: bool,
+    ) -> Self:
+        def func(
+            _input: dask_expr.Series,
+            _window: int,
+            _min_periods: int | None,
+            _center: bool,  # noqa: FBT001
+        ) -> dask_expr.Series:
+            return _input.rolling(
+                window=_window, min_periods=_min_periods, center=_center
+            ).sum()
+
+        return self._from_call(
+            func,
+            "rolling_sum",
+            window_size,
+            min_periods,
+            center,
+            returns_scalar=False,
+        )
+
 
 class DaskExprStringNamespace:
     def __init__(self, expr: DaskExpr) -> None:
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index dd1ad405..80201097 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -464,6 +464,21 @@ class PandasLikeExpr:
     def cum_prod(self: Self, *, reverse: bool) -> Self:
         return reuse_series_implementation(self, "cum_prod", reverse=reverse)
 
+    def rolling_sum(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None,
+        center: bool,
+    ) -> Self:
+        return reuse_series_implementation(
+            self,
+            "rolling_sum",
+            window_size=window_size,
+            min_periods=min_periods,
+            center=center,
+        )
+
     @property
     def str(self: Self) -> PandasLikeExprStringNamespace:
         return PandasLikeExprStringNamespace(self)
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index 1c20b7ba..a1b37926 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -457,7 +457,7 @@ class PandasLikeSeries:
         value: Any | None = None,
         strategy: Literal["forward", "backward"] | None = None,
         limit: int | None = None,
-    ) -> PandasLikeSeries:
+    ) -> Self:
         ser = self._native_series
         if value is not None:
             res_ser = self._from_native_series(ser.fillna(value=value))
@@ -798,6 +798,18 @@ class PandasLikeSeries:
         )
         return self._from_native_series(result)
 
+    def rolling_sum(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None,
+        center: bool,
+    ) -> Self:
+        result = self._native_series.rolling(
+            window=window_size, min_periods=min_periods, center=center
+        ).sum()
+        return self._from_native_series(result)
+
     def __iter__(self: Self) -> Iterator[Any]:
         yield from self._native_series.__iter__()
 
diff --git a/narwhals/exceptions.py b/narwhals/exceptions.py
index 26485dfc..12f85d1a 100644
--- a/narwhals/exceptions.py
+++ b/narwhals/exceptions.py
@@ -60,3 +60,7 @@ class InvalidIntoExprError(TypeError):
             "  column with literal value `0`."
         )
         return InvalidIntoExprError(message)
+
+
+class NarwhalsUnstableWarning(UserWarning):
+    """Warning issued when a method or function is considered unstable in the stable api."""
diff --git a/narwhals/expr.py b/narwhals/expr.py
index 2cdeb313..850ba783 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -11,6 +11,7 @@ from typing import Sequence
 from typing import TypeVar
 
 from narwhals.dependencies import is_numpy_array
+from narwhals.exceptions import InvalidOperationError
 from narwhals.utils import flatten
 
 if TYPE_CHECKING:
@@ -2990,6 +2991,123 @@ class Expr:
         """
         return self.__class__(lambda plx: self._call(plx).cum_prod(reverse=reverse))
 
+    def rolling_sum(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None = None,
+        center: bool = False,
+    ) -> Self:
+        """Apply a rolling sum (moving sum) over the values.
+
+        !!! warning
+            This functionality is considered **unstable**. It may be changed at any point
+            without it being considered a breaking change.
+
+        A window of length `window_size` will traverse the values. The resulting values
+        will be aggregated to their sum.
+
+        The window at a given row will include the row itself and the `window_size - 1`
+        elements before it.
+
+        Arguments:
+            window_size: The length of the window in number of elements. It must be a
+                strictly positive integer.
+            min_periods: The number of values in the window that should be non-null before
+                computing a result. If set to `None` (default), it will be set equal to
+                `window_size`. If provided, it must be a strictly positive integer, and
+                less than or equal to `window_size`
+            center: Set the labels at the center of the window.
+
+        Returns:
+            A new expression.
+
+        Examples:
+            >>> import narwhals as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> data = {"a": [1.0, 2.0, None, 4.0]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
+
+            We define a library agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns(
+            ...         b=nw.col("a").rolling_sum(window_size=3, min_periods=1)
+            ...     )
+
+            We can then pass any supported library such as Pandas, Polars, or PyArrow to `func`:
+
+            >>> func(df_pd)
+                 a    b
+            0  1.0  1.0
+            1  2.0  3.0
+            2  NaN  3.0
+            3  4.0  6.0
+
+            >>> func(df_pl)
+            shape: (4, 2)
+            ┌──────┬─────┐
+            │ a    ┆ b   │
+            │ ---  ┆ --- │
+            │ f64  ┆ f64 │
+            ╞══════╪═════╡
+            │ 1.0  ┆ 1.0 │
+            │ 2.0  ┆ 3.0 │
+            │ null ┆ 3.0 │
+            │ 4.0  ┆ 6.0 │
+            └──────┴─────┘
+
+            >>> func(df_pa)  #  doctest:+ELLIPSIS
+            pyarrow.Table
+            a: double
+            b: double
+            ----
+            a: [[1,2,null,4]]
+            b: [[1,3,3,6]]
+        """
+        if window_size < 1:
+            msg = "window_size must be greater or equal than 1"
+            raise ValueError(msg)
+
+        if not isinstance(window_size, int):
+            _type = window_size.__class__.__name__
+            msg = (
+                f"argument 'window_size': '{_type}' object cannot be "
+                "interpreted as an integer"
+            )
+            raise TypeError(msg)
+
+        if min_periods is not None:
+            if min_periods < 1:
+                msg = "min_periods must be greater or equal than 1"
+                raise ValueError(msg)
+
+            if not isinstance(min_periods, int):
+                _type = min_periods.__class__.__name__
+                msg = (
+                    f"argument 'min_periods': '{_type}' object cannot be "
+                    "interpreted as an integer"
+                )
+                raise TypeError(msg)
+            if min_periods > window_size:
+                msg = "`min_periods` must be less or equal than `window_size`"
+                raise InvalidOperationError(msg)
+        else:
+            min_periods = window_size
+
+        return self.__class__(
+            lambda plx: self._call(plx).rolling_sum(
+                window_size=window_size,
+                min_periods=min_periods,
+                center=center,
+            )
+        )
+
     @property
     def str(self: Self) -> ExprStringNamespace[Self]:
         return ExprStringNamespace(self)
diff --git a/narwhals/series.py b/narwhals/series.py
index 654b8450..78801f78 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -11,6 +11,7 @@ from typing import Sequence
 from typing import TypeVar
 from typing import overload
 
+from narwhals.exceptions import InvalidOperationError
 from narwhals.utils import parse_version
 
 if TYPE_CHECKING:
@@ -2898,6 +2899,124 @@ class Series:
             self._compliant_series.cum_prod(reverse=reverse)
         )
 
+    def rolling_sum(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None = None,
+        center: bool = False,
+    ) -> Self:
+        """Apply a rolling sum (moving sum) over the values.
+
+        !!! warning
+            This functionality is considered **unstable**. It may be changed at any point
+            without it being considered a breaking change.
+
+        A window of length `window_size` will traverse the values. The resulting values
+        will be aggregated to their sum.
+
+        The window at a given row will include the row itself and the `window_size - 1`
+        elements before it.
+
+        Arguments:
+            window_size: The length of the window in number of elements. It must be a
+                strictly positive integer.
+            min_periods: The number of values in the window that should be non-null before
+                computing a result. If set to `None` (default), it will be set equal to
+                `window_size`. If provided, it must be a strictly positive integer, and
+                less than or equal to `window_size`
+            center: Set the labels at the center of the window.
+
+        Returns:
+            A new expression.
+
+        Examples:
+            >>> import narwhals as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> data = [1.0, 2.0, 3.0, 4.0]
+            >>> s_pd = pd.Series(data)
+            >>> s_pl = pl.Series(data)
+            >>> s_pa = pa.chunked_array([data])
+
+            We define a library agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.rolling_sum(window_size=2)
+
+            We can then pass any supported library such as Pandas, Polars, or PyArrow to `func`:
+
+            >>> func(s_pd)
+            0    NaN
+            1    3.0
+            2    5.0
+            3    7.0
+            dtype: float64
+
+            >>> func(s_pl)  # doctest:+NORMALIZE_WHITESPACE
+            shape: (4,)
+            Series: '' [f64]
+            [
+               null
+               3.0
+               5.0
+               7.0
+            ]
+
+            >>> func(s_pa)  # doctest:+ELLIPSIS
+            <pyarrow.lib.ChunkedArray object at ...>
+            [
+              [
+                null,
+                3,
+                5,
+                7
+              ]
+            ]
+        """
+        if window_size < 1:
+            msg = "window_size must be greater or equal than 1"
+            raise ValueError(msg)
+
+        if not isinstance(window_size, int):
+            _type = window_size.__class__.__name__
+            msg = (
+                f"argument 'window_size': '{_type}' object cannot be "
+                "interpreted as an integer"
+            )
+            raise TypeError(msg)
+
+        if min_periods is not None:
+            if min_periods < 1:
+                msg = "min_periods must be greater or equal than 1"
+                raise ValueError(msg)
+
+            if not isinstance(min_periods, int):
+                _type = min_periods.__class__.__name__
+                msg = (
+                    f"argument 'min_periods': '{_type}' object cannot be "
+                    "interpreted as an integer"
+                )
+                raise TypeError(msg)
+            if min_periods > window_size:
+                msg = "`min_periods` must be less or equal than `window_size`"
+                raise InvalidOperationError(msg)
+        else:
+            min_periods = window_size
+
+        if len(self) == 0:  # pragma: no cover
+            return self
+
+        return self._from_compliant_series(
+            self._compliant_series.rolling_sum(
+                window_size=window_size,
+                min_periods=min_periods,
+                center=center,
+            )
+        )
+
     def __iter__(self: Self) -> Iterator[Any]:
         yield from self._compliant_series.__iter__()
 
diff --git a/narwhals/stable/v1/__init__.py b/narwhals/stable/v1/__init__.py
index 874337cf..d35ecd43 100644
--- a/narwhals/stable/v1/__init__.py
+++ b/narwhals/stable/v1/__init__.py
@@ -9,6 +9,7 @@ from typing import Literal
 from typing import Sequence
 from typing import TypeVar
 from typing import overload
+from warnings import warn
 
 import narwhals as nw
 from narwhals import dependencies
@@ -492,11 +493,195 @@ class Series(NwSeries):
             sort=sort, parallel=parallel, name=name, normalize=normalize
         )
 
+    def rolling_sum(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None = None,
+        center: bool = False,
+    ) -> Self:
+        """Apply a rolling sum (moving sum) over the values.
+
+        !!! warning
+            This functionality is considered **unstable**. It may be changed at any point
+            without it being considered a breaking change.
+
+        A window of length `window_size` will traverse the values. The resulting values
+        will be aggregated to their sum.
+
+        The window at a given row will include the row itself and the `window_size - 1`
+        elements before it.
+
+        Arguments:
+            window_size: The length of the window in number of elements. It must be a
+                strictly positive integer.
+            min_periods: The number of values in the window that should be non-null before
+                computing a result. If set to `None` (default), it will be set equal to
+                `window_size`. If provided, it must be a strictly positive integer, and
+                less than or equal to `window_size`
+            center: Set the labels at the center of the window.
+
+        Returns:
+            A new expression.
+
+        Examples:
+            >>> import narwhals as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> data = [1.0, 2.0, 3.0, 4.0]
+            >>> s_pd = pd.Series(data)
+            >>> s_pl = pl.Series(data)
+            >>> s_pa = pa.chunked_array([data])
+
+            We define a library agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.rolling_sum(window_size=2)
+
+            We can then pass any supported library such as Pandas, Polars, or PyArrow to `func`:
+
+            >>> func(s_pd)
+            0    NaN
+            1    3.0
+            2    5.0
+            3    7.0
+            dtype: float64
+
+            >>> func(s_pl)  # doctest:+NORMALIZE_WHITESPACE
+            shape: (4,)
+            Series: '' [f64]
+            [
+               null
+               3.0
+               5.0
+               7.0
+            ]
+
+            >>> func(s_pa)  # doctest:+ELLIPSIS
+            <pyarrow.lib.ChunkedArray object at ...>
+            [
+              [
+                null,
+                3,
+                5,
+                7
+              ]
+            ]
+        """
+        from narwhals.exceptions import NarwhalsUnstableWarning
+        from narwhals.utils import find_stacklevel
+
+        msg = (
+            "`Series.rolling_sum` is being called from the stable API although considered "
+            "an unstable feature."
+        )
+        warn(message=msg, category=NarwhalsUnstableWarning, stacklevel=find_stacklevel())
+        return super().rolling_sum(
+            window_size=window_size,
+            min_periods=min_periods,
+            center=center,
+        )
+
 
 class Expr(NwExpr):
     def _l1_norm(self) -> Self:
         return super()._taxicab_norm()
 
+    def rolling_sum(
+        self: Self,
+        window_size: int,
+        *,
+        min_periods: int | None = None,
+        center: bool = False,
+    ) -> Self:
+        """Apply a rolling sum (moving sum) over the values.
+
+        !!! warning
+            This functionality is considered **unstable**. It may be changed at any point
+            without it being considered a breaking change.
+
+        A window of length `window_size` will traverse the values. The resulting values
+        will be aggregated to their sum.
+
+        The window at a given row will include the row itself and the `window_size - 1`
+        elements before it.
+
+        Arguments:
+            window_size: The length of the window in number of elements. It must be a
+                strictly positive integer.
+            min_periods: The number of values in the window that should be non-null before
+                computing a result. If set to `None` (default), it will be set equal to
+                `window_size`. If provided, it must be a strictly positive integer, and
+                less than or equal to `window_size`
+            center: Set the labels at the center of the window.
+
+        Returns:
+            A new expression.
+
+        Examples:
+            >>> import narwhals as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> data = {"a": [1.0, 2.0, None, 4.0]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
+
+            We define a library agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns(
+            ...         b=nw.col("a").rolling_sum(window_size=3, min_periods=1)
+            ...     )
+
+            We can then pass any supported library such as Pandas, Polars, or PyArrow to `func`:
+
+            >>> func(df_pd)
+                 a    b
+            0  1.0  1.0
+            1  2.0  3.0
+            2  NaN  3.0
+            3  4.0  6.0
+
+            >>> func(df_pl)
+            shape: (4, 2)
+            ┌──────┬─────┐
+            │ a    ┆ b   │
+            │ ---  ┆ --- │
+            │ f64  ┆ f64 │
+            ╞══════╪═════╡
+            │ 1.0  ┆ 1.0 │
+            │ 2.0  ┆ 3.0 │
+            │ null ┆ 3.0 │
+            │ 4.0  ┆ 6.0 │
+            └──────┴─────┘
+
+            >>> func(df_pa)  #  doctest:+ELLIPSIS
+            pyarrow.Table
+            a: double
+            b: double
+            ----
+            a: [[1,2,null,4]]
+            b: [[1,3,3,6]]
+        """
+        from narwhals.exceptions import NarwhalsUnstableWarning
+        from narwhals.utils import find_stacklevel
+
+        msg = (
+            "`Expr.rolling_sum` is being called from the stable API although considered "
+            "an unstable feature."
+        )
+        warn(message=msg, category=NarwhalsUnstableWarning, stacklevel=find_stacklevel())
+        return super().rolling_sum(
+            window_size=window_size,
+            min_periods=min_periods,
+            center=center,
+        )
+
 
 class Schema(NwSchema):
     """Ordered mapping of column names to their data type.


Test patch:
diff --git a/tests/expr_and_series/rolling_sum_test.py b/tests/expr_and_series/rolling_sum_test.py
new file mode 100644
index 00000000..cbf999a0
--- /dev/null
+++ b/tests/expr_and_series/rolling_sum_test.py
@@ -0,0 +1,220 @@
+from __future__ import annotations
+
+import random
+from typing import Any
+
+import hypothesis.strategies as st
+import pandas as pd
+import pyarrow as pa
+import pytest
+from hypothesis import given
+
+import narwhals.stable.v1 as nw
+from narwhals.exceptions import InvalidOperationError
+from tests.utils import PANDAS_VERSION
+from tests.utils import Constructor
+from tests.utils import ConstructorEager
+from tests.utils import assert_equal_data
+
+data = {"a": [None, 1, 2, None, 4, 6, 11]}
+
+kwargs_and_expected = {
+    "x1": {"kwargs": {"window_size": 3}, "expected": [float("nan")] * 6 + [21]},
+    "x2": {
+        "kwargs": {"window_size": 3, "min_periods": 1},
+        "expected": [float("nan"), 1.0, 3.0, 3.0, 6.0, 10.0, 21.0],
+    },
+    "x3": {
+        "kwargs": {"window_size": 2, "min_periods": 1},
+        "expected": [float("nan"), 1.0, 3.0, 2.0, 4.0, 10.0, 17.0],
+    },
+    "x4": {
+        "kwargs": {"window_size": 5, "min_periods": 1, "center": True},
+        "expected": [3.0, 3.0, 7.0, 13.0, 23.0, 21.0, 21.0],
+    },
+    "x5": {
+        "kwargs": {"window_size": 4, "min_periods": 1, "center": True},
+        "expected": [1.0, 3.0, 3.0, 7.0, 12.0, 21.0, 21.0],
+    },
+}
+
+
+@pytest.mark.filterwarnings(
+    "ignore:`Expr.rolling_sum` is being called from the stable API although considered an unstable feature."
+)
+def test_rolling_sum_expr(
+    request: pytest.FixtureRequest, constructor: Constructor
+) -> None:
+    if "dask" in str(constructor):
+        # TODO(FBruzzesi): Dask is raising the following error:
+        # NotImplementedError: Partition size is less than overlapping window size.
+        # Try using ``df.repartition`` to increase the partition size.
+        request.applymarker(pytest.mark.xfail)
+
+    df = nw.from_native(constructor(data))
+    result = df.select(
+        **{
+            name: nw.col("a").rolling_sum(**values["kwargs"])  # type: ignore[arg-type]
+            for name, values in kwargs_and_expected.items()
+        }
+    )
+    expected = {name: values["expected"] for name, values in kwargs_and_expected.items()}
+
+    assert_equal_data(result, expected)
+
+
+@pytest.mark.filterwarnings(
+    "ignore:`Series.rolling_sum` is being called from the stable API although considered an unstable feature."
+)
+def test_rolling_sum_series(constructor_eager: ConstructorEager) -> None:
+    df = nw.from_native(constructor_eager(data), eager_only=True)
+
+    result = df.select(
+        **{
+            name: df["a"].rolling_sum(**values["kwargs"])  # type: ignore[arg-type]
+            for name, values in kwargs_and_expected.items()
+        }
+    )
+    expected = {name: values["expected"] for name, values in kwargs_and_expected.items()}
+    assert_equal_data(result, expected)
+
+
+@pytest.mark.filterwarnings(
+    "ignore:`Expr.rolling_sum` is being called from the stable API although considered an unstable feature."
+)
+@pytest.mark.parametrize(
+    ("window_size", "min_periods", "context"),
+    [
+        (
+            -1,
+            None,
+            pytest.raises(
+                ValueError, match="window_size must be greater or equal than 1"
+            ),
+        ),
+        (
+            4.2,
+            None,
+            pytest.raises(
+                TypeError,
+                match="argument 'window_size': 'float' object cannot be interpreted as an integer",
+            ),
+        ),
+        (
+            2,
+            -1,
+            pytest.raises(
+                ValueError, match="min_periods must be greater or equal than 1"
+            ),
+        ),
+        (
+            2,
+            4.2,
+            pytest.raises(
+                TypeError,
+                match="argument 'min_periods': 'float' object cannot be interpreted as an integer",
+            ),
+        ),
+        (
+            1,
+            2,
+            pytest.raises(
+                InvalidOperationError,
+                match="`min_periods` must be less or equal than `window_size`",
+            ),
+        ),
+    ],
+)
+def test_rolling_sum_expr_invalid_params(
+    constructor: Constructor, window_size: int, min_periods: int | None, context: Any
+) -> None:
+    df = nw.from_native(constructor(data))
+
+    with context:
+        df.select(
+            nw.col("a").rolling_sum(window_size=window_size, min_periods=min_periods)
+        )
+
+
+@pytest.mark.filterwarnings(
+    "ignore:`Series.rolling_sum` is being called from the stable API although considered an unstable feature."
+)
+@pytest.mark.parametrize(
+    ("window_size", "min_periods", "context"),
+    [
+        (
+            -1,
+            None,
+            pytest.raises(
+                ValueError, match="window_size must be greater or equal than 1"
+            ),
+        ),
+        (
+            4.2,
+            None,
+            pytest.raises(
+                TypeError,
+                match="argument 'window_size': 'float' object cannot be interpreted as an integer",
+            ),
+        ),
+        (
+            2,
+            -1,
+            pytest.raises(
+                ValueError, match="min_periods must be greater or equal than 1"
+            ),
+        ),
+        (
+            2,
+            4.2,
+            pytest.raises(
+                TypeError,
+                match="argument 'min_periods': 'float' object cannot be interpreted as an integer",
+            ),
+        ),
+        (
+            1,
+            2,
+            pytest.raises(
+                InvalidOperationError,
+                match="`min_periods` must be less or equal than `window_size`",
+            ),
+        ),
+    ],
+)
+def test_rolling_sum_series_invalid_params(
+    constructor_eager: ConstructorEager,
+    window_size: int,
+    min_periods: int | None,
+    context: Any,
+) -> None:
+    df = nw.from_native(constructor_eager(data))
+
+    with context:
+        df["a"].rolling_sum(window_size=window_size, min_periods=min_periods)
+
+
+@given(  # type: ignore[misc]
+    center=st.booleans(),
+    values=st.lists(st.floats(-10, 10), min_size=3, max_size=10),
+)
+@pytest.mark.skipif(PANDAS_VERSION < (1,), reason="too old for pyarrow")
+@pytest.mark.filterwarnings("ignore:.*:narwhals.exceptions.NarwhalsUnstableWarning")
+def test_rolling_sum_hypothesis(center: bool, values: list[float]) -> None:  # noqa: FBT001
+    s = pd.Series(values)
+    n_missing = random.randint(0, len(s) - 1)  # noqa: S311
+    window_size = random.randint(1, len(s))  # noqa: S311
+    min_periods = random.randint(1, window_size)  # noqa: S311
+    mask = random.sample(range(len(s)), n_missing)
+    s[mask] = None
+    df = pd.DataFrame({"a": s})
+    expected = (
+        s.rolling(window=window_size, center=center, min_periods=min_periods)
+        .sum()
+        .to_frame("a")
+    )
+    result = nw.from_native(pa.Table.from_pandas(df)).select(
+        nw.col("a").rolling_sum(window_size, center=center, min_periods=min_periods)
+    )
+    expected_dict = nw.from_native(expected, eager_only=True).to_dict(as_series=False)
+    assert_equal_data(result, expected_dict)