Repository: narwhals-dev/narwhals

Issue:
enh: add dt.timestamp
Here's the Polars method: https://docs.pola.rs/py-polars/html/reference/expressions/api/polars.Expr.dt.timestamp.html

We should add this to Narwhals for use in Plotly

Discussion:
FBruzzesi: I am catching up with Marco's today's livestream - as he explains how the integer representation works, wouldn't make sense to have something like the following pseudocode (details such as time units, time zones, date vs datetime, etc need to be taken care)

```py
diff = series - datetime(1970, 1, 1, 0, 0, 0, tzinfo=...)
if time_unit == "ns":
    return diff.dt.total_nanoseconds()
elif ...
...
```

Maybe it ends up being similar effort, but feels less redundant than all the nested `if` cases.

Edit: pyarrow also has `nanoseconds_between`, `microseconds_between` and `milliseconds_between` two date/datetimes
MarcoGorelli: thanks @FBruzzesi for taking a look! `total_nanoseconds` is for `Duration`, not `Datetime`, so we can't use it here unfortunately 😅 
FBruzzesi: > thanks @FBruzzesi for taking a look! `total_nanoseconds` is for `Duration`, not `Datetime`, so we can't use it here unfortunately 😅

I am most likely missing something - what I am suggesting is, instead of using the date(time) representation, compute the difference between the series/expr of interest and the scalar 1970-01-01 00:00:00 (unix_epoch). This would result in a Duration object (wouldn't it?) and then use the Duration methods. Sorry if I misunderstood the internal representation explanation  
MarcoGorelli: ah sorry you're right 🤦 maybe that's indeed simpler, will try!
MarcoGorelli: i think a cast would always be more efficient, from a little timing test:
```
In [50]: %timeit (s - datetime(1970,1,1)).dt.total_seconds()
465 μs ± 64.1 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

In [51]: %timeit s.astype('int64')
52.9 μs ± 10.8 μs per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
```

Patch:
diff --git a/docs/api-reference/expr_dt.md b/docs/api-reference/expr_dt.md
index 5c9ab41f..604ac4ab 100644
--- a/docs/api-reference/expr_dt.md
+++ b/docs/api-reference/expr_dt.md
@@ -6,22 +6,23 @@
       members:
         - convert_time_zone
         - date
-        - year
-        - month
         - day
-        - ordinal_day
         - hour
-        - minute
-        - second
-        - millisecond
         - microsecond
+        - millisecond
+        - minute
+        - month
         - nanosecond
+        - ordinal_day
         - replace_time_zone
-        - total_minutes
-        - total_seconds
-        - total_milliseconds
+        - second
+        - timestamp
         - total_microseconds
+        - total_milliseconds
+        - total_minutes
         - total_nanoseconds
+        - total_seconds
         - to_string
+        - year
       show_source: false
       show_bases: false
diff --git a/docs/api-reference/series_dt.md b/docs/api-reference/series_dt.md
index c9259241..23d4817c 100644
--- a/docs/api-reference/series_dt.md
+++ b/docs/api-reference/series_dt.md
@@ -6,22 +6,23 @@
       members:
         - convert_time_zone
         - date
-        - year
-        - month
         - day
-        - ordinal_day
         - hour
-        - minute
-        - second
-        - millisecond
         - microsecond
+        - millisecond
+        - minute
+        - month
         - nanosecond
+        - ordinal_day
         - replace_time_zone
-        - total_minutes
-        - total_seconds
-        - total_milliseconds
+        - second
+        - timestamp
         - total_microseconds
+        - total_milliseconds
+        - total_minutes
         - total_nanoseconds
+        - total_seconds
         - to_string
+        - year
       show_source: false
       show_bases: false
diff --git a/narwhals/_arrow/expr.py b/narwhals/_arrow/expr.py
index 55c529d3..c9ee160e 100644
--- a/narwhals/_arrow/expr.py
+++ b/narwhals/_arrow/expr.py
@@ -420,6 +420,11 @@ class ArrowExprDateTimeNamespace:
             self._expr, "dt", "convert_time_zone", time_zone
         )
 
+    def timestamp(self: Self, time_unit: Literal["ns", "us", "ms"] = "us") -> ArrowExpr:
+        return reuse_series_namespace_implementation(
+            self._expr, "dt", "timestamp", time_unit
+        )
+
     def date(self: Self) -> ArrowExpr:
         return reuse_series_namespace_implementation(self._expr, "dt", "date")
 
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index 2eb73829..f029a4d5 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -780,6 +780,59 @@ class ArrowSeriesDateTimeNamespace:
 
         return self._arrow_series._from_native_series(result)
 
+    def timestamp(self: Self, time_unit: Literal["ns", "us", "ms"] = "us") -> ArrowSeries:
+        import pyarrow as pa  # ignore-banned-import
+        import pyarrow.compute as pc  # ignore-banned-import
+
+        s = self._arrow_series._native_series
+        dtype = self._arrow_series.dtype
+        if dtype == self._arrow_series._dtypes.Datetime:
+            unit = dtype.time_unit  # type: ignore[attr-defined]
+            s_cast = s.cast(pa.int64())
+            if unit == "ns":
+                if time_unit == "ns":
+                    result = s_cast
+                elif time_unit == "us":
+                    result = floordiv_compat(s_cast, 1_000)
+                else:
+                    result = floordiv_compat(s_cast, 1_000_000)
+            elif unit == "us":
+                if time_unit == "ns":
+                    result = pc.multiply(s_cast, 1_000)
+                elif time_unit == "us":
+                    result = s_cast
+                else:
+                    result = floordiv_compat(s_cast, 1_000)
+            elif unit == "ms":
+                if time_unit == "ns":
+                    result = pc.multiply(s_cast, 1_000_000)
+                elif time_unit == "us":
+                    result = pc.multiply(s_cast, 1_000)
+                else:
+                    result = s_cast
+            elif unit == "s":
+                if time_unit == "ns":
+                    result = pc.multiply(s_cast, 1_000_000_000)
+                elif time_unit == "us":
+                    result = pc.multiply(s_cast, 1_000_000)
+                else:
+                    result = pc.multiply(s_cast, 1_000)
+            else:  # pragma: no cover
+                msg = f"unexpected time unit {unit}, please report an issue at https://github.com/narwhals-dev/narwhals"
+                raise AssertionError(msg)
+        elif dtype == self._arrow_series._dtypes.Date:
+            time_s = pc.multiply(s.cast(pa.int32()), 86400)
+            if time_unit == "ns":
+                result = pc.multiply(time_s, 1_000_000_000)
+            elif time_unit == "us":
+                result = pc.multiply(time_s, 1_000_000)
+            else:
+                result = pc.multiply(time_s, 1_000)
+        else:
+            msg = "Input should be either of Date or Datetime type"
+            raise TypeError(msg)
+        return self._arrow_series._from_native_series(result)
+
     def date(self: Self) -> ArrowSeries:
         import pyarrow as pa  # ignore-banned-import()
 
diff --git a/narwhals/_dask/expr.py b/narwhals/_dask/expr.py
index 693fcad5..1347dad8 100644
--- a/narwhals/_dask/expr.py
+++ b/narwhals/_dask/expr.py
@@ -10,6 +10,8 @@ from typing import NoReturn
 from narwhals._dask.utils import add_row_index
 from narwhals._dask.utils import maybe_evaluate
 from narwhals._dask.utils import narwhals_to_native_dtype
+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.utils import generate_unique_token
 
@@ -953,6 +955,37 @@ class DaskExprDateTimeNamespace:
             returns_scalar=False,
         )
 
+    def timestamp(self, time_unit: Literal["ns", "us", "ms"] = "us") -> DaskExpr:
+        def func(
+            s: dask_expr.Series, time_unit: Literal["ns", "us", "ms"] = "us"
+        ) -> dask_expr.Series:
+            dtype = native_to_narwhals_dtype(s, self._expr._dtypes)
+            is_pyarrow_dtype = "pyarrow" in str(dtype)
+            mask_na = s.isna()
+            if dtype == self._expr._dtypes.Date:
+                # Date is only supported in pandas dtypes if pyarrow-backed
+                s_cast = s.astype("Int32[pyarrow]")
+                result = calculate_timestamp_date(s_cast, time_unit)
+            elif dtype == self._expr._dtypes.Datetime:
+                original_time_unit = dtype.time_unit  # type: ignore[attr-defined]
+                s_cast = (
+                    s.astype("Int64[pyarrow]") if is_pyarrow_dtype else s.astype("int64")
+                )
+                result = calculate_timestamp_datetime(
+                    s_cast, original_time_unit, time_unit
+                )
+            else:
+                msg = "Input should be either of Date or Datetime type"
+                raise TypeError(msg)
+            return result.where(~mask_na)
+
+        return self._expr._from_call(
+            func,
+            "datetime",
+            time_unit,
+            returns_scalar=False,
+        )
+
     def total_minutes(self) -> DaskExpr:
         return self._expr._from_call(
             lambda _input: _input.dt.total_seconds() // 60,
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index 07ba3e56..a4082235 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -582,6 +582,11 @@ class PandasLikeExprDateTimeNamespace:
             self._expr, "dt", "convert_time_zone", time_zone
         )
 
+    def timestamp(self, time_unit: Literal["ns", "us", "ms"] = "us") -> PandasLikeExpr:
+        return reuse_series_namespace_implementation(
+            self._expr, "dt", "timestamp", time_unit
+        )
+
 
 class PandasLikeExprNameNamespace:
     def __init__(self: Self, expr: PandasLikeExpr) -> None:
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index 9dc9f20f..a8e59bc1 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -8,6 +8,8 @@ from typing import Literal
 from typing import Sequence
 from typing import overload
 
+from narwhals._pandas_like.utils import calculate_timestamp_date
+from narwhals._pandas_like.utils import calculate_timestamp_datetime
 from narwhals._pandas_like.utils import int_dtype_mapper
 from narwhals._pandas_like.utils import narwhals_to_native_dtype
 from narwhals._pandas_like.utils import native_series_from_iterable
@@ -944,3 +946,30 @@ class PandasLikeSeriesDateTimeNamespace:
         else:
             result = self._pandas_series._native_series.dt.tz_convert(time_zone)
         return self._pandas_series._from_native_series(result)
+
+    def timestamp(self, time_unit: Literal["ns", "us", "ms"] = "us") -> PandasLikeSeries:
+        s = self._pandas_series._native_series
+        dtype = self._pandas_series.dtype
+        is_pyarrow_dtype = "pyarrow" in str(self._pandas_series._native_series.dtype)
+        mask_na = s.isna()
+        if dtype == self._pandas_series._dtypes.Date:
+            # Date is only supported in pandas dtypes if pyarrow-backed
+            s_cast = s.astype("Int32[pyarrow]")
+            result = calculate_timestamp_date(s_cast, time_unit)
+        elif dtype == self._pandas_series._dtypes.Datetime:
+            original_time_unit = dtype.time_unit  # type: ignore[attr-defined]
+            if (
+                self._pandas_series._implementation is Implementation.PANDAS
+                and self._pandas_series._backend_version < (2,)
+            ):  # pragma: no cover
+                s_cast = s.view("Int64[pyarrow]") if is_pyarrow_dtype else s.view("int64")
+            else:
+                s_cast = (
+                    s.astype("Int64[pyarrow]") if is_pyarrow_dtype else s.astype("int64")
+                )
+            result = calculate_timestamp_datetime(s_cast, original_time_unit, time_unit)
+        else:
+            msg = "Input should be either of Date or Datetime type"
+            raise TypeError(msg)
+        result[mask_na] = None
+        return self._pandas_series._from_native_series(result)
diff --git a/narwhals/_pandas_like/utils.py b/narwhals/_pandas_like/utils.py
index 0c5ec471..2c4612eb 100644
--- a/narwhals/_pandas_like/utils.py
+++ b/narwhals/_pandas_like/utils.py
@@ -542,3 +542,51 @@ def convert_str_slice_to_int_slice(
     stop = columns.get_loc(str_slice.stop) + 1 if str_slice.stop is not None else None
     step = str_slice.step
     return (start, stop, step)
+
+
+def calculate_timestamp_datetime(
+    s: pd.Series, original_time_unit: str, time_unit: str
+) -> pd.Series:
+    if original_time_unit == "ns":
+        if time_unit == "ns":
+            result = s
+        elif time_unit == "us":
+            result = s // 1_000
+        else:
+            result = s // 1_000_000
+    elif original_time_unit == "us":
+        if time_unit == "ns":
+            result = s * 1_000
+        elif time_unit == "us":
+            result = s
+        else:
+            result = s // 1_000
+    elif original_time_unit == "ms":
+        if time_unit == "ns":
+            result = s * 1_000_000
+        elif time_unit == "us":
+            result = s * 1_000
+        else:
+            result = s
+    elif original_time_unit == "s":
+        if time_unit == "ns":
+            result = s * 1_000_000_000
+        elif time_unit == "us":
+            result = s * 1_000_000
+        else:
+            result = s * 1_000
+    else:  # pragma: no cover
+        msg = f"unexpected time unit {original_time_unit}, please report a bug at https://github.com/narwhals-dev/narwhals"
+        raise AssertionError(msg)
+    return result
+
+
+def calculate_timestamp_date(s: pd.Series, time_unit: str) -> pd.Series:
+    s = s * 86_400  # number of seconds in a day
+    if time_unit == "ns":
+        result = s * 1_000_000_000
+    elif time_unit == "us":
+        result = s * 1_000_000
+    else:
+        result = s * 1_000
+    return result
diff --git a/narwhals/expr.py b/narwhals/expr.py
index 908db905..3156c426 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -3976,6 +3976,72 @@ class ExprDateTimeNamespace(Generic[T]):
             lambda plx: self._expr._call(plx).dt.convert_time_zone(time_zone)
         )
 
+    def timestamp(self: Self, time_unit: Literal["ns", "us", "ms"] = "us") -> T:
+        """
+        Return a timestamp in the given time unit.
+
+        Arguments:
+            time_unit: {'ns', 'us', 'ms'}
+                Time unit.
+
+        Examples:
+            >>> from datetime import date
+            >>> import narwhals as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> data = {"date": [date(2001, 1, 1), None, date(2001, 1, 3)]}
+            >>> df_pd = pd.DataFrame(data, dtype="datetime64[ns]")
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
+
+            Let's define a dataframe-agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns(
+            ...         nw.col("date").dt.timestamp().alias("timestamp_us"),
+            ...         nw.col("date").dt.timestamp("ms").alias("timestamp_ms"),
+            ...     )
+
+            We can then pass pandas / PyArrow / Polars / any other supported library:
+
+            >>> func(df_pd)
+                    date  timestamp_us  timestamp_ms
+            0 2001-01-01  9.783072e+14  9.783072e+11
+            1        NaT           NaN           NaN
+            2 2001-01-03  9.784800e+14  9.784800e+11
+            >>> func(df_pl)
+            shape: (3, 3)
+            ┌────────────┬─────────────────┬──────────────┐
+            │ date       ┆ timestamp_us    ┆ timestamp_ms │
+            │ ---        ┆ ---             ┆ ---          │
+            │ date       ┆ i64             ┆ i64          │
+            ╞════════════╪═════════════════╪══════════════╡
+            │ 2001-01-01 ┆ 978307200000000 ┆ 978307200000 │
+            │ null       ┆ null            ┆ null         │
+            │ 2001-01-03 ┆ 978480000000000 ┆ 978480000000 │
+            └────────────┴─────────────────┴──────────────┘
+            >>> func(df_pa)
+            pyarrow.Table
+            date: date32[day]
+            timestamp_us: int64
+            timestamp_ms: int64
+            ----
+            date: [[2001-01-01,null,2001-01-03]]
+            timestamp_us: [[978307200000000,null,978480000000000]]
+            timestamp_ms: [[978307200000,null,978480000000]]
+        """
+        if time_unit not in {"ns", "us", "ms"}:
+            msg = (
+                "invalid `time_unit`"
+                f"\n\nExpected one of {{'ns', 'us', 'ms'}}, got {time_unit!r}."
+            )
+            raise ValueError(msg)
+        return self._expr.__class__(
+            lambda plx: self._expr._call(plx).dt.timestamp(time_unit)
+        )
+
 
 class ExprNameNamespace(Generic[T]):
     def __init__(self: Self, expr: T) -> None:
diff --git a/narwhals/series.py b/narwhals/series.py
index ddaf009f..ea043223 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -4006,3 +4006,63 @@ class SeriesDateTimeNamespace(Generic[T]):
         return self._narwhals_series._from_compliant_series(
             self._narwhals_series._compliant_series.dt.convert_time_zone(time_zone)
         )
+
+    def timestamp(self: Self, time_unit: Literal["ns", "us", "ms"] = "us") -> T:
+        """
+        Return a timestamp in the given time unit.
+
+        Arguments:
+            time_unit: {'ns', 'us', 'ms'}
+                Time unit.
+
+        Examples:
+            >>> from datetime import date
+            >>> import narwhals as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> data = [date(2001, 1, 1), None, date(2001, 1, 3)]
+            >>> s_pd = pd.Series(data, dtype="datetime64[ns]")
+            >>> s_pl = pl.Series(data)
+            >>> s_pa = pa.chunked_array([data])
+
+            Let's define a dataframe-agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(s):
+            ...     return s.dt.timestamp("ms")
+
+            We can then pass pandas / PyArrow / Polars / any other supported library:
+
+            >>> func(s_pd)
+            0    9.783072e+11
+            1             NaN
+            2    9.784800e+11
+            dtype: float64
+            >>> func(s_pl)  # doctest: +NORMALIZE_WHITESPACE
+            shape: (3,)
+            Series: '' [i64]
+            [
+                    978307200000
+                    null
+                    978480000000
+            ]
+            >>> func(s_pa)
+            <pyarrow.lib.ChunkedArray object at ...>
+            [
+              [
+                978307200000,
+                null,
+                978480000000
+              ]
+            ]
+        """
+        if time_unit not in {"ns", "us", "ms"}:
+            msg = (
+                "invalid `time_unit`"
+                f"\n\nExpected one of {{'ns', 'us', 'ms'}}, got {time_unit!r}."
+            )
+            raise ValueError(msg)
+        return self._narwhals_series._from_compliant_series(
+            self._narwhals_series._compliant_series.dt.timestamp(time_unit)
+        )


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 017daace..cf025317 100644
--- a/tests/expr_and_series/dt/datetime_attributes_test.py
+++ b/tests/expr_and_series/dt/datetime_attributes_test.py
@@ -109,10 +109,13 @@ def test_datetime_chained_attributes(
 def test_to_date(request: pytest.FixtureRequest, constructor: Constructor) -> None:
     if any(
         x in str(constructor)
-        for x in ("pandas_constructor", "pandas_nullable_constructor", "dask")
+        for x in ("pandas_constructor", "pandas_nullable_constructor")
     ):
         request.applymarker(pytest.mark.xfail)
     dates = {"a": [datetime(2001, 1, 1), None, datetime(2001, 1, 3)]}
-    df = nw.from_native(constructor(dates))
+    if "dask" in str(constructor):
+        df = nw.from_native(constructor(dates).astype({"a": "timestamp[ns][pyarrow]"}))  # type: ignore[union-attr]
+    else:
+        df = nw.from_native(constructor(dates))
     result = df.select(nw.col("a").dt.date())
     assert result.collect_schema() == {"a": nw.Date}
diff --git a/tests/expr_and_series/dt/timestamp_test.py b/tests/expr_and_series/dt/timestamp_test.py
new file mode 100644
index 00000000..4e3dcdf3
--- /dev/null
+++ b/tests/expr_and_series/dt/timestamp_test.py
@@ -0,0 +1,221 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Literal
+
+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.utils import parse_version
+from tests.utils import Constructor
+from tests.utils import ConstructorEager
+from tests.utils import compare_dicts
+from tests.utils import is_windows
+
+data = {
+    "a": [
+        datetime(2021, 3, 1, 12, 34, 56, 49000),
+        datetime(2020, 1, 2, 2, 4, 14, 715000),
+    ],
+}
+
+
+@pytest.mark.parametrize(
+    ("original_time_unit", "time_unit", "expected"),
+    [
+        ("ns", "ns", [978307200000000000, None, 978480000000000000]),
+        ("ns", "us", [978307200000000, None, 978480000000000]),
+        ("ns", "ms", [978307200000, None, 978480000000]),
+        ("us", "ns", [978307200000000000, None, 978480000000000000]),
+        ("us", "us", [978307200000000, None, 978480000000000]),
+        ("us", "ms", [978307200000, None, 978480000000]),
+        ("ms", "ns", [978307200000000000, None, 978480000000000000]),
+        ("ms", "us", [978307200000000, None, 978480000000000]),
+        ("ms", "ms", [978307200000, None, 978480000000]),
+        ("s", "ns", [978307200000000000, None, 978480000000000000]),
+        ("s", "us", [978307200000000, None, 978480000000000]),
+        ("s", "ms", [978307200000, None, 978480000000]),
+    ],
+)
+def test_timestamp_datetimes(
+    request: pytest.FixtureRequest,
+    constructor: Constructor,
+    original_time_unit: Literal["us", "ns", "ms", "s"],
+    time_unit: Literal["ns", "us", "ms"],
+    expected: list[int | None],
+) -> None:
+    if original_time_unit == "s" and "polars" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+    if "pandas_pyarrow" in str(constructor) and parse_version(pd.__version__) < (
+        2,
+        2,
+    ):  # pragma: no cover
+        # pyarrow-backed timestamps were too inconsistent and unreliable before 2.2
+        request.applymarker(pytest.mark.xfail(strict=False))
+    datetimes = {"a": [datetime(2001, 1, 1), None, datetime(2001, 1, 3)]}
+    df = nw.from_native(constructor(datetimes))
+    result = df.select(
+        nw.col("a").cast(nw.Datetime(original_time_unit)).dt.timestamp(time_unit)
+    )
+    compare_dicts(result, {"a": expected})
+
+
+@pytest.mark.parametrize(
+    ("original_time_unit", "time_unit", "expected"),
+    [
+        ("ns", "ns", [978307200000000000, None, 978480000000000000]),
+        ("ns", "us", [978307200000000, None, 978480000000000]),
+        ("ns", "ms", [978307200000, None, 978480000000]),
+        ("us", "ns", [978307200000000000, None, 978480000000000000]),
+        ("us", "us", [978307200000000, None, 978480000000000]),
+        ("us", "ms", [978307200000, None, 978480000000]),
+        ("ms", "ns", [978307200000000000, None, 978480000000000000]),
+        ("ms", "us", [978307200000000, None, 978480000000000]),
+        ("ms", "ms", [978307200000, None, 978480000000]),
+        ("s", "ns", [978307200000000000, None, 978480000000000000]),
+        ("s", "us", [978307200000000, None, 978480000000000]),
+        ("s", "ms", [978307200000, None, 978480000000]),
+    ],
+)
+def test_timestamp_datetimes_tz_aware(
+    request: pytest.FixtureRequest,
+    constructor: Constructor,
+    original_time_unit: Literal["us", "ns", "ms", "s"],
+    time_unit: Literal["ns", "us", "ms"],
+    expected: list[int | None],
+) -> None:
+    if (
+        (any(x in str(constructor) for x in ("pyarrow",)) and is_windows())
+        or ("pandas_pyarrow" in str(constructor) and parse_version(pd.__version__) < (2,))
+        or ("pyarrow_table" in str(constructor) and parse_version(pa.__version__) < (12,))
+        or ("cudf" in str(constructor))
+    ):
+        request.applymarker(pytest.mark.xfail)
+    if "pandas_pyarrow" in str(constructor) and parse_version(pd.__version__) < (
+        2,
+        2,
+    ):  # pragma: no cover
+        # pyarrow-backed timestamps were too inconsistent and unreliable before 2.2
+        request.applymarker(pytest.mark.xfail(strict=False))
+    if "dask" in str(constructor):
+        import dask
+
+        if parse_version(dask.__version__) < (2024, 8):  # pragma: no cover
+            request.applymarker(pytest.mark.xfail)
+
+    if original_time_unit == "s" and "polars" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+    datetimes = {"a": [datetime(2001, 1, 1), None, datetime(2001, 1, 3)]}
+    df = nw.from_native(constructor(datetimes))
+    result = df.select(
+        nw.col("a")
+        .cast(nw.Datetime(original_time_unit))
+        .dt.replace_time_zone("UTC")
+        .dt.convert_time_zone("Asia/Kathmandu")
+        .dt.timestamp(time_unit)
+    )
+    compare_dicts(result, {"a": expected})
+
+
+@pytest.mark.parametrize(
+    ("time_unit", "expected"),
+    [
+        ("ns", [978307200000000000, None, 978480000000000000]),
+        ("us", [978307200000000, None, 978480000000000]),
+        ("ms", [978307200000, None, 978480000000]),
+    ],
+)
+def test_timestamp_dates(
+    request: pytest.FixtureRequest,
+    constructor: Constructor,
+    time_unit: Literal["ns", "us", "ms"],
+    expected: list[int | None],
+) -> None:
+    if any(
+        x in str(constructor)
+        for x in ("pandas_constructor", "pandas_nullable_constructor", "cudf")
+    ):
+        request.applymarker(pytest.mark.xfail)
+
+    dates = {"a": [datetime(2001, 1, 1), None, datetime(2001, 1, 3)]}
+    if "dask" in str(constructor):
+        df = nw.from_native(
+            constructor(dates).astype({"a": "timestamp[ns][pyarrow]"})  # type: ignore[union-attr]
+        )
+    else:
+        df = nw.from_native(constructor(dates))
+    result = df.select(nw.col("a").dt.date().dt.timestamp(time_unit))
+    compare_dicts(result, {"a": expected})
+
+
+def test_timestamp_invalid_date(
+    request: pytest.FixtureRequest, constructor: Constructor
+) -> None:
+    if "polars" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+    data_str = {"a": ["x", "y", None]}
+    data_num = {"a": [1, 2, None]}
+    df_str = nw.from_native(constructor(data_str))
+    df_num = nw.from_native(constructor(data_num))
+    msg = "Input should be either of Date or Datetime type"
+    with pytest.raises(TypeError, match=msg):
+        df_str.select(nw.col("a").dt.timestamp())
+    with pytest.raises(TypeError, match=msg):
+        df_num.select(nw.col("a").dt.timestamp())
+
+
+def test_timestamp_invalid_unit_expr(constructor: Constructor) -> None:
+    time_unit_invalid = "i"
+    msg = (
+        "invalid `time_unit`"
+        f"\n\nExpected one of {{'ns', 'us', 'ms'}}, got {time_unit_invalid!r}."
+    )
+    with pytest.raises(ValueError, match=msg):
+        nw.from_native(constructor(data)).select(
+            nw.col("a").dt.timestamp(time_unit_invalid)  # type: ignore[arg-type]
+        )
+
+
+def test_timestamp_invalid_unit_series(constructor_eager: ConstructorEager) -> None:
+    time_unit_invalid = "i"
+    msg = (
+        "invalid `time_unit`"
+        f"\n\nExpected one of {{'ns', 'us', 'ms'}}, got {time_unit_invalid!r}."
+    )
+    with pytest.raises(ValueError, match=msg):
+        nw.from_native(constructor_eager(data))["a"].dt.timestamp(time_unit_invalid)  # type: ignore[arg-type]
+
+
+@given(  # type: ignore[misc]
+    inputs=st.datetimes(min_value=datetime(1960, 1, 1), max_value=datetime(1980, 1, 1)),
+    time_unit=st.sampled_from(["ms", "us", "ns"]),
+    # We keep 'ms' out for now due to an upstream bug: https://github.com/pola-rs/polars/issues/19309
+    starting_time_unit=st.sampled_from(["us", "ns"]),
+)
+@pytest.mark.skipif(parse_version(pd.__version__) < (2, 2), reason="bug in old pandas")
+def test_timestamp_hypothesis(
+    inputs: datetime,
+    time_unit: Literal["ms", "us", "ns"],
+    starting_time_unit: Literal["ms", "us", "ns"],
+) -> None:
+    import pandas as pd
+    import polars as pl
+    import pyarrow as pa
+
+    @nw.narwhalify
+    def func(s: nw.Series) -> nw.Series:
+        return s.dt.timestamp(time_unit)
+
+    result_pl = func(pl.Series([inputs], dtype=pl.Datetime(starting_time_unit)))
+    result_pd = func(pd.Series([inputs], dtype=f"datetime64[{starting_time_unit}]"))
+    result_pdpa = func(
+        pd.Series([inputs], dtype=f"timestamp[{starting_time_unit}][pyarrow]")
+    )
+    result_pa = func(pa.chunked_array([[inputs]], type=pa.timestamp(starting_time_unit)))
+    assert result_pl[0] == result_pd[0]
+    assert result_pl[0] == result_pdpa[0]
+    assert result_pl[0] == result_pa[0].as_py()