Repository: narwhals-dev/narwhals (python)

Issue:
feat: is_nan
Looks like this would be useful to Tubular: https://github.com/lvgig/tubular/pull/349#discussion_r1884006320

There would need to be a caveat in the docstring about how pandas doesn't distinguish between nan and null but other libraries typically do

This requires some attention to check that we get such nan vs null details correct:

- for Polars, there is already is_nan
- for PyArrow, there is https://arrow.apache.org/docs/python/generated/pyarrow.compute.is_nan.html
- for pandas, we need to be careful. We don't want `is_nan` to pick up `NaT` / `None` / anything else which is used for missing values, but instead, specifically, we only want `nan`. So, we should check
  - that the dtype is `Float`
  - that `s != s`

Pull request description:
feat: add is_nan expression & series method
- add support for pandas, arrow, dask
- add to docs
- add tests


<!--
# Thanks for contributing a pull request! 
## Please make sure you see our contribution guidelines: https://github.com/narwhals-dev/narwhals/blob/main/CONTRIBUTING.md
-->

## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [x] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues

- Related issue #1583 
- Closes #1583 

## Checklist

- [x] Code follows style guide (ruff)
- [x] Tests added
- [x] Documented the changes

## If you have comments or can explain your changes, please do so below

The `is_nan` implementation for pandas and dask currently return True for the following conditions:
1. The backend datatype is float64
2. The NaN value is not equal to itself

This leaves space for false rejections when there is a NaN value inside of a Series with 'object' dtype, though I am uncertain if we care about this edgecase.

Additionally, I was uncertain on how to structure the tests with regards to having NaN values inside of nullable datatypes. I currently dynamically change the expected result but was considering creating separate tests for data that are backed by a nullable datatype vs a non-nullable datatype so some guidance here would be appreciated.

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",


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()