Repository: narwhals-dev/narwhals (python)

Issue:
[Enh]: Ability to convert string to date
### We would like to learn about your use case. For example, if this feature is needed  to adopt Narwhals in an open source project, could you please enter the link to it below?


There is ability to convert string into datetime, however, to date is missing, request is to add this feature.

Current work around: 
`nw.col("my_col").str.to_datetime(format="%Y%m").cast(nw.Date)`

### Please describe the purpose of the new feature or describe the problem to solve.

To simplify the conversion from string to date without the use of datetime.

### Suggest a solution if possible.

_No response_

### If you have tried alternatives, please describe them below.

`nw.col("my_col").str.to_datetime(format="%Y%m").cast(nw.Date)`

### Additional information that may help us understand your needs.

_No response_

Pull request:
feat: Add `Expr|Series.str.to_date`
## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [x] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues

- Closes #2728 

## 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

Whenever some `to_date`-like functionality exists, I fallback to `to_datetime(fmt).dt.date()`

Patch:
diff --git a/docs/api-reference/expr_str.md b/docs/api-reference/expr_str.md
index 9fd31704..fbd50d48 100644
--- a/docs/api-reference/expr_str.md
+++ b/docs/api-reference/expr_str.md
@@ -15,6 +15,7 @@
         - starts_with
         - strip_chars
         - tail
+        - to_date
         - to_datetime
         - to_lowercase
         - to_uppercase
diff --git a/docs/api-reference/series_str.md b/docs/api-reference/series_str.md
index d2d70e69..4d36554c 100644
--- a/docs/api-reference/series_str.md
+++ b/docs/api-reference/series_str.md
@@ -15,6 +15,7 @@
         - starts_with
         - strip_chars
         - tail
+        - to_date
         - to_datetime
         - to_lowercase
         - to_uppercase
diff --git a/narwhals/_arrow/series_str.py b/narwhals/_arrow/series_str.py
index 728ba023..0d73022e 100644
--- a/narwhals/_arrow/series_str.py
+++ b/narwhals/_arrow/series_str.py
@@ -57,6 +57,9 @@ class ArrowSeriesStringNamespace(ArrowSeriesNamespace):
         timestamp_array = pc.strptime(self.native, format=format, unit="us")
         return self.with_native(timestamp_array)
 
+    def to_date(self, format: str | None) -> ArrowSeries:
+        return self.to_datetime(format=format).dt.date()
+
     def to_uppercase(self) -> ArrowSeries:
         return self.with_native(pc.utf8_upper(self.native))
 
diff --git a/narwhals/_compliant/any_namespace.py b/narwhals/_compliant/any_namespace.py
index 96510d95..fcacfd90 100644
--- a/narwhals/_compliant/any_namespace.py
+++ b/narwhals/_compliant/any_namespace.py
@@ -78,6 +78,7 @@ class StringNamespace(_StoresCompliant[CompliantT_co], Protocol[CompliantT_co]):
     def slice(self, offset: int, length: int | None) -> CompliantT_co: ...
     def split(self, by: str) -> CompliantT_co: ...
     def to_datetime(self, format: str | None) -> CompliantT_co: ...
+    def to_date(self, format: str | None) -> CompliantT_co: ...
     def to_lowercase(self) -> CompliantT_co: ...
     def to_uppercase(self) -> CompliantT_co: ...
     def zfill(self, width: int) -> CompliantT_co: ...
diff --git a/narwhals/_compliant/expr.py b/narwhals/_compliant/expr.py
index bfd06837..708e4960 100644
--- a/narwhals/_compliant/expr.py
+++ b/narwhals/_compliant/expr.py
@@ -1190,6 +1190,9 @@ class EagerExprStringNamespace(
     def to_datetime(self, format: str | None) -> EagerExprT:
         return self.compliant._reuse_series_namespace("str", "to_datetime", format=format)
 
+    def to_date(self, format: str | None) -> EagerExprT:
+        return self.compliant._reuse_series_namespace("str", "to_date", format=format)
+
     def to_lowercase(self) -> EagerExprT:
         return self.compliant._reuse_series_namespace("str", "to_lowercase")
 
diff --git a/narwhals/_dask/expr_str.py b/narwhals/_dask/expr_str.py
index 584f6f3b..d4c2509b 100644
--- a/narwhals/_dask/expr_str.py
+++ b/narwhals/_dask/expr_str.py
@@ -6,6 +6,7 @@ import dask.dataframe as dd
 
 from narwhals._compliant.any_namespace import StringNamespace
 from narwhals._compliant.expr import LazyExprNamespace
+from narwhals._utils import not_implemented
 
 if TYPE_CHECKING:
     from narwhals._dask.expr import DaskExpr
@@ -101,3 +102,5 @@ class DaskExprStringNamespace(LazyExprNamespace["DaskExpr"], StringNamespace["Da
         return self.compliant._with_callable(
             lambda expr, width: expr.str.zfill(width), "zfill", width=width
         )
+
+    to_date = not_implemented()
diff --git a/narwhals/_duckdb/expr_str.py b/narwhals/_duckdb/expr_str.py
index 4462bf08..2d0bf3bd 100644
--- a/narwhals/_duckdb/expr_str.py
+++ b/narwhals/_duckdb/expr_str.py
@@ -87,6 +87,13 @@ class DuckDBExprStringNamespace(
             lambda expr: F("strptime", expr, lit(format))
         )
 
+    def to_date(self, format: str | None) -> DuckDBExpr:
+        if format is not None:
+            return self.to_datetime(format=format).dt.date()
+
+        compliant_expr = self.compliant
+        return compliant_expr.cast(compliant_expr._version.dtypes.Date())
+
     def zfill(self, width: int) -> DuckDBExpr:
         # DuckDB does not have a built-in zfill function, so we need to implement it manually
         # using string manipulation functions.
diff --git a/narwhals/_ibis/expr_str.py b/narwhals/_ibis/expr_str.py
index c5260dae..155e10b8 100644
--- a/narwhals/_ibis/expr_str.py
+++ b/narwhals/_ibis/expr_str.py
@@ -100,6 +100,16 @@ class IbisExprStringNamespace(LazyExprNamespace["IbisExpr"], StringNamespace["Ib
         fn = self._to_datetime_naive if _is_naive_format(format) else self._to_datetime
         return self.compliant._with_callable(fn(format))
 
+    def to_date(self, format: str | None) -> IbisExpr:
+        if format is None:
+            msg = "Cannot infer format with Ibis backend"
+            raise NotImplementedError(msg)
+
+        def fn(expr: ir.StringColumn) -> ir.DateValue:
+            return expr.as_date(format)
+
+        return self._compliant_expr._with_callable(fn)
+
     def zfill(self, width: int) -> IbisExpr:
         def func(expr: ir.StringColumn) -> ir.Value:
             length = expr.length()
diff --git a/narwhals/_pandas_like/series_str.py b/narwhals/_pandas_like/series_str.py
index ea838f2c..ca907e18 100644
--- a/narwhals/_pandas_like/series_str.py
+++ b/narwhals/_pandas_like/series_str.py
@@ -72,6 +72,9 @@ class PandasLikeSeriesStringNamespace(
             else result
         )
 
+    def to_date(self, format: str | None) -> PandasLikeSeries:
+        return self.to_datetime(format=format).dt.date()
+
     def to_uppercase(self) -> PandasLikeSeries:
         return self.with_native(self.native.str.upper())
 
diff --git a/narwhals/_polars/expr.py b/narwhals/_polars/expr.py
index 6ad483a2..316671a6 100644
--- a/narwhals/_polars/expr.py
+++ b/narwhals/_polars/expr.py
@@ -408,6 +408,7 @@ class PolarsExprStringNamespace(PolarsExprNamespace):
     contains: Method[PolarsExpr]
     slice: Method[PolarsExpr]
     split: Method[PolarsExpr]
+    to_date: Method[PolarsExpr]
     to_datetime: Method[PolarsExpr]
     to_lowercase: Method[PolarsExpr]
     to_uppercase: Method[PolarsExpr]
diff --git a/narwhals/_spark_like/expr_str.py b/narwhals/_spark_like/expr_str.py
index 0a81efe9..6293e45a 100644
--- a/narwhals/_spark_like/expr_str.py
+++ b/narwhals/_spark_like/expr_str.py
@@ -103,6 +103,12 @@ class SparkLikeExprStringNamespace(
             lambda expr: function(F.replace(expr, F.lit("T"), F.lit(" ")))
         )
 
+    def to_date(self, format: str | None) -> SparkLikeExpr:
+        F = self._compliant_expr._F  # noqa: N806
+        return self._compliant_expr._with_callable(
+            lambda expr: F.to_date(expr, format=strptime_to_pyspark_format(format))
+        )
+
     def zfill(self, width: int) -> SparkLikeExpr:
         def func(expr: Column) -> Column:
             F = self.compliant._F  # noqa: N806
diff --git a/narwhals/expr_str.py b/narwhals/expr_str.py
index d6b5115e..74b136c3 100644
--- a/narwhals/expr_str.py
+++ b/narwhals/expr_str.py
@@ -395,6 +395,38 @@ class ExprStringNamespace(Generic[ExprT]):
             lambda plx: self._expr._to_compliant_expr(plx).str.to_datetime(format=format)
         )
 
+    def to_date(self, format: str | None = None) -> ExprT:
+        """Convert to date dtype.
+
+        Warning:
+            As different backends auto-infer format in different ways, if `format=None`
+            there is no guarantee that the result will be equal.
+
+        Arguments:
+            format: Format to use for conversion. If set to None (default), the format is inferred from the data.
+
+        Returns:
+            A new expression.
+
+        Examples:
+            >>> import pyarrow as pa
+            >>> import narwhals as nw
+            >>> df_native = pa.table({"a": ["2020-01-01", "2020-01-02"]})
+            >>> df = nw.from_native(df_native)
+            >>> df.select(nw.col("a").str.to_date(format="%Y-%m-%d"))
+            ┌────────────────────────────┐
+            |     Narwhals DataFrame     |
+            |----------------------------|
+            |pyarrow.Table               |
+            |a: date32[day]              |
+            |----                        |
+            |a: [[2020-01-01,2020-01-02]]|
+            └────────────────────────────┘
+        """
+        return self._expr._with_elementwise_op(
+            lambda plx: self._expr._to_compliant_expr(plx).str.to_date(format=format)
+        )
+
     def to_uppercase(self) -> ExprT:
         r"""Transform string to uppercase variant.
 
diff --git a/narwhals/series_str.py b/narwhals/series_str.py
index e2b149af..1e600000 100644
--- a/narwhals/series_str.py
+++ b/narwhals/series_str.py
@@ -399,6 +399,38 @@ class SeriesStringNamespace(Generic[SeriesT]):
             self._narwhals_series._compliant_series.str.to_datetime(format=format)
         )
 
+    def to_date(self, format: str | None = None) -> SeriesT:
+        """Convert to date dtype.
+
+        Warning:
+            As different backends auto-infer format in different ways, if `format=None`
+            there is no guarantee that the result will be equal.
+
+        Arguments:
+            format: Format to use for conversion. If set to None (default), the format is
+                inferred from the data.
+
+        Returns:
+            A new expression.
+
+        Examples:
+            >>> import pyarrow as pa
+            >>> import narwhals as nw
+            >>> s_native = pa.chunked_array([["2020-01-01", "2020-01-02"]])
+            >>> s = nw.from_native(s_native, series_only=True)
+            >>> s.str.to_date(format="%Y-%m-%d").to_native()  # doctest: +ELLIPSIS
+            <pyarrow.lib.ChunkedArray object at ...>
+            [
+              [
+                2020-01-01,
+                2020-01-02
+              ]
+            ]
+        """
+        return self._narwhals_series._with_compliant(
+            self._narwhals_series._compliant_series.str.to_date(format=format)
+        )
+
     def zfill(self, width: int) -> SeriesT:
         r"""Pad strings with zeros on the left.
 


Test patch:
diff --git a/tests/expr_and_series/str/to_date_test.py b/tests/expr_and_series/str/to_date_test.py
new file mode 100644
index 00000000..d1f7558c
--- /dev/null
+++ b/tests/expr_and_series/str/to_date_test.py
@@ -0,0 +1,92 @@
+from __future__ import annotations
+
+from datetime import date
+from typing import TYPE_CHECKING
+
+import pytest
+
+import narwhals as nw
+from tests.utils import assert_equal_data, uses_pyarrow_backend
+
+if TYPE_CHECKING:
+    from tests.utils import Constructor, ConstructorEager
+
+data = {"a": ["2020-01-01", "2020-01-02", None]}
+expected = {"a": [date(2020, 1, 1), date(2020, 1, 2), None]}
+
+
+def test_to_date_with_fmt_series(
+    request: pytest.FixtureRequest, constructor_eager: ConstructorEager
+) -> None:
+    if "cudf" in str(constructor_eager) or (
+        "pandas" in str(constructor_eager)
+        and not uses_pyarrow_backend(constructor=constructor_eager)
+    ):
+        reason = "Date type is not supported"
+        request.applymarker(pytest.mark.xfail(reason=reason))
+
+    result = nw.from_native(constructor_eager(data), eager_only=True)["a"].str.to_date(
+        format="%Y-%m-%d"
+    )
+    assert isinstance(result.dtype, nw.Date)
+    assert_equal_data({"a": result}, expected)
+
+
+def test_to_date_infer_fmt_series(
+    request: pytest.FixtureRequest, constructor_eager: ConstructorEager
+) -> None:
+    if "cudf" in str(constructor_eager) or (
+        "pandas" in str(constructor_eager)
+        and not uses_pyarrow_backend(constructor=constructor_eager)
+    ):
+        reason = "Date type is not supported"
+        request.applymarker(pytest.mark.xfail(reason=reason))
+
+    result = nw.from_native(constructor_eager(data), eager_only=True)["a"].str.to_date()
+    assert isinstance(result.dtype, nw.Date)
+    assert_equal_data({"a": result}, expected)
+
+
+def test_to_date_with_fmt_expr(
+    request: pytest.FixtureRequest, constructor: Constructor
+) -> None:
+    if "cudf" in str(constructor) or (
+        "pandas" in str(constructor) and not uses_pyarrow_backend(constructor=constructor)
+    ):
+        reason = "Date type is not supported"
+        request.applymarker(pytest.mark.xfail(reason=reason))
+
+    if "dask" in str(constructor):
+        reason = "not implemented"
+        request.applymarker(pytest.mark.xfail(reason=reason))
+
+    result = nw.from_native(constructor(data)).select(
+        a=nw.col("a").str.to_date(format="%Y-%m-%d")
+    )
+    result_schema = result.collect_schema()
+    assert isinstance(result_schema["a"], nw.Date)
+
+    assert_equal_data(result, expected)
+
+
+def test_to_date_infer_fmt_expr(
+    request: pytest.FixtureRequest, constructor: Constructor
+) -> None:
+    if "cudf" in str(constructor) or (
+        "pandas" in str(constructor) and not uses_pyarrow_backend(constructor=constructor)
+    ):
+        reason = "Date type is not supported"
+        request.applymarker(pytest.mark.xfail(reason=reason))
+    if "ibis" in str(constructor):
+        reason = "Cannot infer format"
+        request.applymarker(pytest.mark.xfail(reason=reason))
+
+    if "dask" in str(constructor):
+        reason = "not implemented"
+        request.applymarker(pytest.mark.xfail(reason=reason))
+
+    result = nw.from_native(constructor(data)).select(a=nw.col("a").str.to_date())
+    result_schema = result.collect_schema()
+    assert isinstance(result_schema["a"], nw.Date)
+
+    assert_equal_data(result, expected)
diff --git a/tests/expr_and_series/str/zfill_test.py b/tests/expr_and_series/str/zfill_test.py
index d8acc783..7ecc3d36 100644
--- a/tests/expr_and_series/str/zfill_test.py
+++ b/tests/expr_and_series/str/zfill_test.py
@@ -9,19 +9,13 @@ from tests.utils import (
     Constructor,
     ConstructorEager,
     assert_equal_data,
+    uses_pyarrow_backend,
 )
 
 data = {"a": ["-1", "+1", "1", "12", "123", "99999", "+9999", None]}
 expected = {"a": ["-01", "+01", "001", "012", "123", "99999", "+9999", None]}
 
 
-def uses_pyarrow_backend(constructor: Constructor | ConstructorEager) -> bool:
-    return constructor.__name__ in {
-        "pandas_pyarrow_constructor",
-        "modin_pyarrow_constructor",
-    }
-
-
 def test_str_zfill(request: pytest.FixtureRequest, constructor: Constructor) -> None:
     if uses_pyarrow_backend(constructor):
         reason = (
diff --git a/tests/utils.py b/tests/utils.py
index 16dae25b..a86b2f7f 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -155,3 +155,11 @@ def windows_has_tzdata() -> bool:  # pragma: no cover
 def is_pyarrow_windows_no_tzdata(constructor: Constructor, /) -> bool:
     """Skip test on Windows when the tz database is not configured."""
     return "pyarrow" in str(constructor) and is_windows() and not windows_has_tzdata()
+
+
+def uses_pyarrow_backend(constructor: Constructor | ConstructorEager) -> bool:
+    """Checks if the pandas-like constructor uses pyarrow backend."""
+    return constructor.__name__ in {
+        "pandas_pyarrow_constructor",
+        "modin_pyarrow_constructor",
+    }