Repository: narwhals-dev/narwhals
Pull request #1710 (2025-01-02 19:04:53)

docs: add spark-like in backend completeness

## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [ ] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [x] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues

- Related [discord message](https://discord.com/channels/1235257048170762310/1235257049626181656/1324448945472143404)




Patch:
diff --git a/docs/api-reference/expr.md b/docs/api-reference/expr.md
index fef26d1e..299ab2d4 100644
--- a/docs/api-reference/expr.md
+++ b/docs/api-reference/expr.md
@@ -32,6 +32,7 @@
         - is_first_distinct
         - is_in
         - is_last_distinct
+        - is_nan
         - is_null
         - is_unique
         - len
diff --git a/docs/api-reference/expr_dt.md b/docs/api-reference/expr_dt.md
index 604ac4ab..15cbe5ef 100644
--- a/docs/api-reference/expr_dt.md
+++ b/docs/api-reference/expr_dt.md
@@ -23,6 +23,7 @@
         - total_nanoseconds
         - total_seconds
         - to_string
+        - weekday
         - year
       show_source: false
       show_bases: false
diff --git a/docs/api-reference/series.md b/docs/api-reference/series.md
index d5a446dc..c2e35a3c 100644
--- a/docs/api-reference/series.md
+++ b/docs/api-reference/series.md
@@ -38,6 +38,7 @@
         - is_first_distinct
         - is_in
         - is_last_distinct
+        - is_nan
         - is_null
         - is_sorted
         - is_unique
diff --git a/docs/api-reference/series_dt.md b/docs/api-reference/series_dt.md
index 23d4817c..6d93e2ea 100644
--- a/docs/api-reference/series_dt.md
+++ b/docs/api-reference/series_dt.md
@@ -23,6 +23,7 @@
         - total_nanoseconds
         - total_seconds
         - to_string
+        - weekday
         - year
       show_source: false
       show_bases: false
diff --git a/narwhals/_arrow/expr.py b/narwhals/_arrow/expr.py
index 15afcba1..04c5c930 100644
--- a/narwhals/_arrow/expr.py
+++ b/narwhals/_arrow/expr.py
@@ -317,6 +317,9 @@ class ArrowExpr(CompliantExpr[ArrowSeries]):
     def is_null(self: Self) -> Self:
         return reuse_series_implementation(self, "is_null")
 
+    def is_nan(self: Self) -> Self:
+        return reuse_series_implementation(self, "is_nan")
+
     def is_between(self: Self, lower_bound: Any, upper_bound: Any, closed: str) -> Self:
         return reuse_series_implementation(
             self,
@@ -658,6 +661,11 @@ class ArrowExprDateTimeNamespace:
             self._compliant_expr, "dt", "ordinal_day"
         )
 
+    def weekday(self: Self) -> ArrowExpr:
+        return reuse_series_namespace_implementation(
+            self._compliant_expr, "dt", "weekday"
+        )
+
     def total_minutes(self: Self) -> ArrowExpr:
         return reuse_series_namespace_implementation(
             self._compliant_expr, "dt", "total_minutes"
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index 239ab5c1..37dce5db 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -520,6 +520,11 @@ class ArrowSeries(CompliantSeries):
         ser = self._native_series
         return self._from_native_series(ser.is_null())
 
+    def is_nan(self: Self) -> Self:
+        import pyarrow.compute as pc
+
+        return self._from_native_series(pc.is_nan(self._native_series))
+
     def cast(self: Self, dtype: DType) -> Self:
         import pyarrow.compute as pc
 
@@ -1303,6 +1308,13 @@ class ArrowSeriesDateTimeNamespace:
             pc.day_of_year(self._compliant_series._native_series)
         )
 
+    def weekday(self: Self) -> ArrowSeries:
+        import pyarrow.compute as pc
+
+        return self._compliant_series._from_native_series(
+            pc.day_of_week(self._compliant_series._native_series, count_from_zero=False)
+        )
+
     def total_minutes(self: Self) -> ArrowSeries:
         import pyarrow as pa
         import pyarrow.compute as pc
diff --git a/narwhals/_dask/expr.py b/narwhals/_dask/expr.py
index 8da2d422..9b4b5f6a 100644
--- a/narwhals/_dask/expr.py
+++ b/narwhals/_dask/expr.py
@@ -16,6 +16,7 @@ from narwhals._pandas_like.utils import calculate_timestamp_date
 from narwhals._pandas_like.utils import calculate_timestamp_datetime
 from narwhals._pandas_like.utils import native_to_narwhals_dtype
 from narwhals.exceptions import ColumnNotFoundError
+from narwhals.exceptions import InvalidOperationError
 from narwhals.typing import CompliantExpr
 from narwhals.utils import Implementation
 from narwhals.utils import generate_temporary_column_name
@@ -706,6 +707,20 @@ class DaskExpr(CompliantExpr["dask_expr.Series"]):
             returns_scalar=self._returns_scalar,
         )
 
+    def is_nan(self: Self) -> Self:
+        def func(_input: dask_expr.Series) -> dask_expr.Series:
+            dtype = native_to_narwhals_dtype(_input, self._version, self._implementation)
+            if dtype.is_numeric():
+                return _input != _input  # noqa: PLR0124
+            msg = f"`.is_nan` only supported for numeric dtypes and not {dtype}, did you mean `.is_null`?"
+            raise InvalidOperationError(msg)
+
+        return self._from_call(
+            func,
+            "is_null",
+            returns_scalar=self._returns_scalar,
+        )
+
     def len(self: Self) -> Self:
         return self._from_call(
             lambda _input: _input.size,
@@ -1212,6 +1227,13 @@ class DaskExprDateTimeNamespace:
             returns_scalar=self._compliant_expr._returns_scalar,
         )
 
+    def weekday(self) -> DaskExpr:
+        return self._compliant_expr._from_call(
+            lambda _input: _input.dt.weekday + 1,  # Dask is 0-6
+            "weekday",
+            returns_scalar=self._compliant_expr._returns_scalar,
+        )
+
     def to_string(self, format: str) -> DaskExpr:  # noqa: A002
         return self._compliant_expr._from_call(
             lambda _input, format: _input.dt.strftime(format.replace("%.f", ".%f")),
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index 07543011..df451b49 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -311,6 +311,9 @@ class PandasLikeExpr(CompliantExpr[PandasLikeSeries]):
     def is_null(self) -> Self:
         return reuse_series_implementation(self, "is_null")
 
+    def is_nan(self) -> Self:
+        return reuse_series_implementation(self, "is_nan")
+
     def fill_null(
         self,
         value: Any | None = None,
@@ -835,6 +838,11 @@ class PandasLikeExprDateTimeNamespace:
             self._compliant_expr, "dt", "ordinal_day"
         )
 
+    def weekday(self) -> PandasLikeExpr:
+        return reuse_series_namespace_implementation(
+            self._compliant_expr, "dt", "weekday"
+        )
+
     def total_minutes(self) -> PandasLikeExpr:
         return reuse_series_namespace_implementation(
             self._compliant_expr, "dt", "total_minutes"
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index c9f3d006..04086664 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -20,6 +20,7 @@ from narwhals._pandas_like.utils import select_columns_by_name
 from narwhals._pandas_like.utils import set_index
 from narwhals._pandas_like.utils import to_datetime
 from narwhals.dependencies import is_numpy_scalar
+from narwhals.exceptions import InvalidOperationError
 from narwhals.typing import CompliantSeries
 from narwhals.utils import Implementation
 from narwhals.utils import import_dtypes_module
@@ -623,8 +624,6 @@ class PandasLikeSeries(CompliantSeries):
         return ser.mean()
 
     def median(self) -> Any:
-        from narwhals.exceptions import InvalidOperationError
-
         if not self.dtype.is_numeric():
             msg = "`median` operation not supported for non-numeric input type."
             raise InvalidOperationError(msg)
@@ -663,6 +662,13 @@ class PandasLikeSeries(CompliantSeries):
         ser = self._native_series
         return self._from_native_series(ser.isna())
 
+    def is_nan(self) -> PandasLikeSeries:
+        ser = self._native_series
+        if self.dtype.is_numeric():
+            return self._from_native_series(ser != ser)  # noqa: PLR0124
+        msg = f"`.is_nan` only supported for numeric dtype and not {self.dtype}, did you mean `.is_null`?"
+        raise InvalidOperationError(msg)
+
     def fill_null(
         self,
         value: Any | None = None,
@@ -1298,6 +1304,14 @@ class PandasLikeSeriesDateTimeNamespace:
             )
         )
 
+    def weekday(self) -> PandasLikeSeries:
+        return (
+            self._compliant_series._from_native_series(
+                self._compliant_series._native_series.dt.weekday,
+            )
+            + 1  # Pandas is 0-6 while Polars is 1-7
+        )
+
     def _get_total_seconds(self) -> Any:
         if hasattr(self._compliant_series._native_series.dt, "total_seconds"):
             return self._compliant_series._native_series.dt.total_seconds()
diff --git a/narwhals/_polars/expr.py b/narwhals/_polars/expr.py
index 6c50c99a..230ce37d 100644
--- a/narwhals/_polars/expr.py
+++ b/narwhals/_polars/expr.py
@@ -79,6 +79,15 @@ class PolarsExpr:
             )
         return self._from_native_expr(native_expr)
 
+    def is_nan(self: Self) -> Self:
+        if self._backend_version < (1, 18):  # pragma: no cover
+            import polars as pl
+
+            return self._from_native_expr(
+                pl.when(self._native_expr.is_not_null()).then(self._native_expr.is_nan())
+            )
+        return self._from_native_expr(self._native_expr.is_nan())
+
     def rolling_var(
         self: Self,
         window_size: int,
diff --git a/narwhals/_polars/series.py b/narwhals/_polars/series.py
index d7088bb0..30cd90fd 100644
--- a/narwhals/_polars/series.py
+++ b/narwhals/_polars/series.py
@@ -220,6 +220,19 @@ class PolarsSeries:
     def __invert__(self: Self) -> Self:
         return self._from_native_series(self._native_series.__invert__())
 
+    def is_nan(self: Self) -> Self:
+        import polars as pl
+
+        native = self._native_series
+
+        if self._backend_version < (1, 18):  # pragma: no cover
+            return self._from_native_series(
+                pl.select(pl.when(native.is_not_null()).then(native.is_nan()))[
+                    native.name
+                ]
+            )
+        return self._from_native_series(native.is_nan())
+
     def median(self: Self) -> Any:
         from narwhals.exceptions import InvalidOperationError
 
diff --git a/narwhals/_spark_like/utils.py b/narwhals/_spark_like/utils.py
index 12892ca0..a3c77033 100644
--- a/narwhals/_spark_like/utils.py
+++ b/narwhals/_spark_like/utils.py
@@ -121,7 +121,7 @@ def maybe_evaluate(df: SparkLikeLazyFrame, obj: Any) -> Any:
 
 
 def _std(
-    _input: Column,
+    _input: Column | str,
     ddof: int,
     backend_version: tuple[int, ...],
     np_version: tuple[int, ...],
@@ -136,12 +136,14 @@ def _std(
         return F.stddev_samp(_input) * F.sqrt((n_rows - 1) / (n_rows - ddof))
 
     from pyspark.pandas.spark.functions import stddev
+    from pyspark.sql import functions as F  # noqa: N812
 
-    return stddev(_input, ddof=ddof)
+    input_col = F.col(_input) if isinstance(_input, str) else _input
+    return stddev(input_col, ddof=ddof)
 
 
 def _var(
-    _input: Column,
+    _input: Column | str,
     ddof: int,
     backend_version: tuple[int, ...],
     np_version: tuple[int, ...],
@@ -156,5 +158,7 @@ def _var(
         return F.var_samp(_input) * (n_rows - 1) / (n_rows - ddof)
 
     from pyspark.pandas.spark.functions import var
+    from pyspark.sql import functions as F  # noqa: N812
 
-    return var(_input, ddof=ddof)
+    input_col = F.col(_input) if isinstance(_input, str) else _input
+    return var(input_col, ddof=ddof)
diff --git a/narwhals/expr.py b/narwhals/expr.py
index ba0896db..fe5dc929 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -1925,6 +1925,70 @@ class Expr:
         """
         return self.__class__(lambda plx: self._to_compliant_expr(plx).is_null())
 
+    def is_nan(self) -> Self:
+        """Indicate which values are NaN.
+
+        Returns:
+            A new expression.
+
+        Notes:
+            pandas handles null values differently from Polars and PyArrow.
+            See [null_handling](../pandas_like_concepts/null_handling.md/)
+            for reference.
+
+        Examples:
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoFrameT
+            >>> data = {"orig": [0.0, None, 2.0]}
+            >>> df_pd = pd.DataFrame(data).astype({"orig": "Float64"})
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
+
+            Let's define a dataframe-agnostic function:
+
+            >>> def agnostic_self_div_is_nan(df_native: IntoFrameT) -> IntoFrameT:
+            ...     df = nw.from_native(df_native)
+            ...     return df.with_columns(
+            ...         divided=nw.col("orig") / nw.col("orig"),
+            ...         divided_is_nan=(nw.col("orig") / nw.col("orig")).is_nan(),
+            ...     ).to_native()
+
+            We can then pass any supported library such as Pandas, Polars, or PyArrow to `agnostic_self_div_is_nan`:
+
+            >>> print(agnostic_self_div_is_nan(df_pd))
+               orig  divided  divided_is_nan
+            0   0.0      NaN            True
+            1  <NA>     <NA>            <NA>
+            2   2.0      1.0           False
+
+            >>> print(agnostic_self_div_is_nan(df_pl))
+            shape: (3, 3)
+            ┌──────┬─────────┬────────────────┐
+            │ orig ┆ divided ┆ divided_is_nan │
+            │ ---  ┆ ---     ┆ ---            │
+            │ f64  ┆ f64     ┆ bool           │
+            ╞══════╪═════════╪════════════════╡
+            │ 0.0  ┆ NaN     ┆ true           │
+            │ null ┆ null    ┆ null           │
+            │ 2.0  ┆ 1.0     ┆ false          │
+            └──────┴─────────┴────────────────┘
+
+            >>> print(agnostic_self_div_is_nan(df_pa))
+            pyarrow.Table
+            orig: double
+            divided: double
+            divided_is_nan: bool
+            ----
+            orig: [[0,null,2]]
+            divided: [[nan,null,1]]
+            divided_is_nan: [[true,null,false]]
+
+        """
+        return self.__class__(lambda plx: self._to_compliant_expr(plx).is_nan())
+
     def arg_true(self) -> Self:
         """Find elements where boolean expression is True.
 
@@ -5203,6 +5267,59 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             lambda plx: self._expr._to_compliant_expr(plx).dt.ordinal_day()
         )
 
+    def weekday(self: Self) -> ExprT:
+        """Extract the week day from the underlying Date representation.
+
+        Returns:
+            Returns the ISO weekday number where monday = 1 and sunday = 7
+
+
+        Examples:
+            >>> from datetime import datetime
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoFrameT
+            >>> data = {"a": [datetime(2020, 1, 1), datetime(2020, 8, 3)]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
+
+            We define a dataframe-agnostic function:
+
+            >>> def agnostic_weekday(df_native: IntoFrameT) -> IntoFrameT:
+            ...     df = nw.from_native(df_native)
+            ...     return df.with_columns(a_weekday=nw.col("a").dt.weekday()).to_native()
+
+            We can then pass either pandas, Polars, PyArrow, and other supported libraries to `agnostic_weekday`:
+
+            >>> agnostic_weekday(df_pd)
+                       a  a_weekday
+            0 2020-01-01          3
+            1 2020-08-03          1
+            >>> agnostic_weekday(df_pl)
+            shape: (2, 2)
+            ┌─────────────────────┬───────────┐
+            │ a                   ┆ a_weekday │
+            │ ---                 ┆ ---       │
+            │ datetime[μs]        ┆ i8        │
+            ╞═════════════════════╪═══════════╡
+            │ 2020-01-01 00:00:00 ┆ 3         │
+            │ 2020-08-03 00:00:00 ┆ 1         │
+            └─────────────────────┴───────────┘
+            >>> agnostic_weekday(df_pa)
+            pyarrow.Table
+            a: timestamp[us]
+            a_weekday: int64
+            ----
+            a: [[2020-01-01 00:00:00.000000,2020-08-03 00:00:00.000000]]
+            a_weekday: [[3,1]]
+        """
+        return self._expr.__class__(
+            lambda plx: self._expr._to_compliant_expr(plx).dt.weekday()
+        )
+
     def total_minutes(self: Self) -> ExprT:
         """Get total minutes.
 
diff --git a/narwhals/series.py b/narwhals/series.py
index 4203cdb7..fb607795 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -2437,6 +2437,59 @@ class Series(Generic[IntoSeriesT]):
         """
         return self._from_compliant_series(self._compliant_series.is_null())
 
+    def is_nan(self) -> Self:
+        """Returns a boolean Series indicating which values are NaN.
+
+        Returns:
+            A boolean Series indicating which values are NaN.
+
+        Notes:
+            pandas handles null values differently from Polars and PyArrow.
+            See [null_handling](../pandas_like_concepts/null_handling.md/)
+            for reference.
+
+        Examples:
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoSeriesT
+
+            >>> data = [0.0, None, 2.0]
+            >>> s_pd = pd.Series(data, dtype="Float64")
+            >>> s_pl = pl.Series(data)
+            >>> s_pa = pa.chunked_array([data], type=pa.float64())
+
+            >>> def agnostic_self_div_is_nan(s_native: IntoSeriesT) -> IntoSeriesT:
+            ...     s = nw.from_native(s_native, series_only=True)
+            ...     return s.is_nan().to_native()
+
+            >>> print(agnostic_self_div_is_nan(s_pd))
+            0    False
+            1     <NA>
+            2    False
+            dtype: boolean
+
+            >>> print(agnostic_self_div_is_nan(s_pl))  # doctest: +NORMALIZE_WHITESPACE
+            shape: (3,)
+            Series: '' [bool]
+            [
+                    false
+                    null
+                    false
+            ]
+
+            >>> print(agnostic_self_div_is_nan(s_pa))  # doctest: +NORMALIZE_WHITESPACE
+            [
+              [
+                false,
+                null,
+                false
+              ]
+            ]
+        """
+        return self._from_compliant_series(self._compliant_series.is_nan())
+
     def fill_null(
         self,
         value: Any | None = None,
@@ -6250,6 +6303,58 @@ class SeriesDateTimeNamespace(Generic[SeriesT]):
             self._narwhals_series._compliant_series.dt.ordinal_day()
         )
 
+    def weekday(self: Self) -> SeriesT:
+        """Extract the week day in a datetime series.
+
+        Returns:
+            A new Series containing the week day for each datetime value.
+            Returns the ISO weekday number where monday = 1 and sunday = 7
+
+
+        Examples:
+            >>> from datetime import datetime
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> import narwhals as nw
+            >>> from narwhals.typing import IntoSeriesT
+            >>> data = [datetime(2020, 1, 1), datetime(2020, 8, 3)]
+            >>> s_pd = pd.Series(data)
+            >>> s_pl = pl.Series(data)
+            >>> s_pa = pa.chunked_array([data])
+
+            We define a library agnostic function:
+
+            >>> def agnostic_weekday(s_native: IntoSeriesT) -> IntoSeriesT:
+            ...     s = nw.from_native(s_native, series_only=True)
+            ...     return s.dt.weekday().to_native()
+
+            We can then pass either pandas, Polars, PyArrow, and other supported libraries to `agnostic_weekday`:
+
+            >>> agnostic_weekday(s_pd)
+            0    3
+            1    1
+            dtype: int32
+            >>> agnostic_weekday(s_pl)  # doctest: +NORMALIZE_WHITESPACE
+            shape: (2,)
+            Series: '' [i8]
+            [
+               3
+               1
+            ]
+            >>> agnostic_weekday(s_pa)  # doctest: +ELLIPSIS
+            <pyarrow.lib.ChunkedArray object at ...>
+            [
+              [
+                3,
+                1
+              ]
+            ]
+        """
+        return self._narwhals_series._from_compliant_series(
+            self._narwhals_series._compliant_series.dt.weekday()
+        )
+
     def total_minutes(self: Self) -> SeriesT:
         """Get total minutes.
 
diff --git a/noxfile.py b/noxfile.py
index 49bb6053..87825be3 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -14,12 +14,12 @@ PYTHON_VERSIONS = ["3.8", "3.9", "3.10", "3.11", "3.12"]
 
 
 def run_common(session: Session, coverage_threshold: float) -> None:
-    if session.python == "3.12":
-        session.install("-e .[dev,extra,dask,modin]")
-    elif session.python != "3.8":
-        session.install("-e .[dev,extra,dask,modin,pyspark,ibis]")
+    if session.python == "3.8":
+        session.install("-e .[dev,core]")
+    elif session.python == "3.12":
+        session.install("-e .[dev,core,extra,dask,modin]")
     else:
-        session.install("-e .[dev]")
+        session.install("-e .[dev,core,extra,dask,modin,pyspark,ibis]")
 
     session.run(
         "pytest",
diff --git a/utils/generate_backend_completeness.py b/utils/generate_backend_completeness.py
index cb333ac2..2ab8d218 100644
--- a/utils/generate_backend_completeness.py
+++ b/utils/generate_backend_completeness.py
@@ -34,11 +34,12 @@ BACKENDS = [
     Backend(name="pandas-like", module="_pandas_like", type_=BackendType.EAGER),
     Backend(name="arrow", module="_arrow", type_=BackendType.EAGER),
     Backend(name="dask", module="_dask", type_=BackendType.LAZY),
+    Backend(name="spark-like", module="_spark_like", type_=BackendType.LAZY),
 ]
 
 EXCLUDE_CLASSES = {"BaseFrame", "Then", "When"}
 
-DIRECTLY_IMPLEMENTED_METHODS = ["pipe"]
+DIRECTLY_IMPLEMENTED_METHODS = ["pipe", "implementation", "to_native"]
 
 
 def get_class_methods(kls: type[Any]) -> list[str]:


Test patch:
diff --git a/tests/expr_and_series/dt/datetime_attributes_test.py b/tests/expr_and_series/dt/datetime_attributes_test.py
index 0235920e..ad5f8dc3 100644
--- a/tests/expr_and_series/dt/datetime_attributes_test.py
+++ b/tests/expr_and_series/dt/datetime_attributes_test.py
@@ -32,6 +32,7 @@ data = {
         ("microsecond", [49000, 715000]),
         ("nanosecond", [49000000, 715000000]),
         ("ordinal_day", [60, 2]),
+        ("weekday", [1, 4]),
     ],
 )
 def test_datetime_attributes(
@@ -68,6 +69,7 @@ def test_datetime_attributes(
         ("microsecond", [49000, 715000]),
         ("nanosecond", [49000000, 715000000]),
         ("ordinal_day", [60, 2]),
+        ("weekday", [1, 4]),
     ],
 )
 def test_datetime_attributes_series(
diff --git a/tests/expr_and_series/is_nan_test.py b/tests/expr_and_series/is_nan_test.py
new file mode 100644
index 00000000..d98b39e8
--- /dev/null
+++ b/tests/expr_and_series/is_nan_test.py
@@ -0,0 +1,119 @@
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+import narwhals.stable.v1 as nw
+from tests.conftest import dask_lazy_p1_constructor
+from tests.conftest import dask_lazy_p2_constructor
+from tests.conftest import modin_constructor
+from tests.conftest import pandas_constructor
+from tests.utils import Constructor
+from tests.utils import ConstructorEager
+from tests.utils import assert_equal_data
+
+NON_NULLABLE_CONSTRUCTORS = [
+    pandas_constructor,
+    dask_lazy_p1_constructor,
+    dask_lazy_p2_constructor,
+    modin_constructor,
+]
+
+
+def test_nan(constructor: Constructor) -> None:
+    data_na = {"int": [0, 1, None]}
+    df = nw.from_native(constructor(data_na)).with_columns(
+        float=nw.col("int").cast(nw.Float64), float_na=nw.col("int") / nw.col("int")
+    )
+    result = df.select(
+        int=nw.col("int").is_nan(),
+        float=nw.col("float").is_nan(),
+        float_na=nw.col("float_na").is_nan(),
+    )
+
+    expected: dict[str, list[Any]]
+    if any(constructor is c for c in NON_NULLABLE_CONSTRUCTORS):
+        # Null values are coerced to NaN for non-nullable datatypes
+        expected = {
+            "int": [False, False, True],
+            "float": [False, False, True],
+            "float_na": [True, False, True],
+        }
+    else:
+        # Null are preserved and should be differentiated for nullable datatypes
+        expected = {
+            "int": [False, False, None],
+            "float": [False, False, None],
+            "float_na": [True, False, None],
+        }
+
+    assert_equal_data(result, expected)
+
+
+def test_nan_series(constructor_eager: ConstructorEager) -> None:
+    data_na = {"int": [0, 1, None]}
+    df = nw.from_native(constructor_eager(data_na), eager_only=True).with_columns(
+        float=nw.col("int").cast(nw.Float64), float_na=nw.col("int") / nw.col("int")
+    )
+
+    result = {
+        "int": df["int"].is_nan(),
+        "float": df["float"].is_nan(),
+        "float_na": df["float_na"].is_nan(),
+    }
+    expected: dict[str, list[Any]]
+    if any(constructor_eager is c for c in NON_NULLABLE_CONSTRUCTORS):
+        # Null values are coerced to NaN for non-nullable datatypes
+        expected = {
+            "int": [False, False, True],
+            "float": [False, False, True],
+            "float_na": [True, False, True],
+        }
+    else:
+        # Null are preserved and should be differentiated for nullable datatypes
+        expected = {
+            "int": [False, False, None],
+            "float": [False, False, None],
+            "float_na": [True, False, None],
+        }
+
+    assert_equal_data(result, expected)
+
+
+def test_nan_non_float(constructor: Constructor) -> None:
+    from polars.exceptions import InvalidOperationError as PlInvalidOperationError
+    from pyarrow.lib import ArrowNotImplementedError
+
+    from narwhals.exceptions import InvalidOperationError as NwInvalidOperationError
+
+    data = {"a": ["x", "y"]}
+    df = nw.from_native(constructor(data))
+
+    exc = NwInvalidOperationError
+    if "polars" in str(constructor):
+        exc = PlInvalidOperationError
+    elif "pyarrow_table" in str(constructor):
+        exc = ArrowNotImplementedError
+
+    with pytest.raises(exc):
+        df.select(nw.col("a").is_nan()).lazy().collect()
+
+
+def test_nan_non_float_series(constructor_eager: ConstructorEager) -> None:
+    from polars.exceptions import InvalidOperationError as PlInvalidOperationError
+    from pyarrow.lib import ArrowNotImplementedError
+
+    from narwhals.exceptions import InvalidOperationError as NwInvalidOperationError
+
+    data = {"a": ["x", "y"]}
+    df = nw.from_native(constructor_eager(data), eager_only=True)
+
+    exc = NwInvalidOperationError
+    if "polars" in str(constructor_eager):
+        exc = PlInvalidOperationError
+    elif "pyarrow_table" in str(constructor_eager):
+        exc = ArrowNotImplementedError
+
+    with pytest.raises(exc):
+        df["a"].is_nan()