Repository: elastic/elasticsearch-dsl-py

Issue:
Dates get casted to datetimes on retrieved objects, causing parsing errors during updates
I have the following document class, along with a helper method that adapts objects from our existing SQL database:

```python
class EsIdCard(InnerDoc):
    id: int
    card_number: str
    expiration_date: date

    @staticmethod
    def from_sql_id_card(sql_id_card: SqlIdCard) -> "EsIdCard":
        return EsIdCard(
            id=sql_id_card.id,
            card_number=sql_id_card.card_number,
            expiration_date=sql_id_card.expiration_date,
        )
```

When I save `IdCard` objects to the database, it behaves how I would expect:

```python
print(expiration) # => datetime.date(2025, 10, 4)
new_id_card: SqlIdCard = SqlIdCard(expiration_date=expiration, card_number=num, owner_id=person.id)
# save to SQL, re-fetch object (to get auto-incremented ID), then:
es_person.id_card = EsIdCard.from_sql_id_card(new_id_card) # => EsIdCard(expiration_date=datetime.date(2025, 10, 4), ...)
es_person.save()
```

```bash
curl -X GET localhost:9200/people/_doc/123 -H "Content-Type: application/json" | python -m json.tool
{
    "_index": "people",
    "_id": "123",
    "...": "...",
    "_source": {
        "...": "...",
        "id_card": {
            "id": 1234,
            "card_number": "1234-5678",
            "expiration_date": "2025-10-04"
        }
    }
}
```

However, when I go to retrieve the person via the DSL class, the dates get casted into datetime objects:

```python
person: EsPerson = EsPerson.get(id=123, index="people")
print(person.id_card.expiration_date) # => datetime.datetime(2025, 10, 4, 0, 0)
```

As a result, unless I manually re-cast the date fields to date, modifying the person and calling `.save()` throws an exception:

```python
person.unrelated_attribute = "foo"
person.save() # => elasticsearch.BadRequestError: BadRequestError(400, 'document_parsing_exception', "[1:68] failed to parse field [id_card.expiration_date] of type [date] in document with id '123'. Preview of field's value: '2025-10-04T00:00:00'")
```

According to the [type mapping documentation](https://elasticsearch-dsl.readthedocs.io/en/latest/persistence.html#python-type-hints), this would seem to be an error. If anything, I would expect the reverse to occur (casting datetimes into dates) based on the type mapping table.

Is casting dates to datetimes the expected behavior? If so, should I be using a different type hint for dates that will never have times?

Edit: If I execute a search, the fields remain dates until they get converted into the DSL class:

```python
results: Response = EsPerson.search(index="people").execute()
people: list[EsPerson] = list(results)
print(results.hits.hits[0]['_source']['id_card']['expiration_date']) # => '2025-10-04'
print(people[0].id_card.expiration_date) # => datetime.datetime(2025, 10, 4, 0, 0)
```

Discussion:

Patch:
diff --git a/elasticsearch_dsl/field.py b/elasticsearch_dsl/field.py
index 26f2336..d992ecc 100644
--- a/elasticsearch_dsl/field.py
+++ b/elasticsearch_dsl/field.py
@@ -315,6 +315,9 @@ class Date(Field):
                 raise ValidationException(
                     f"Could not parse date from the value ({data!r})", e
                 )
+            # we treat the yyyy-MM-dd format as a special case
+            if hasattr(self, "format") and self.format == "yyyy-MM-dd":
+                data = data.date()
 
         if isinstance(data, datetime):
             if self._default_timezone and data.tzinfo is None:


Test patch:
diff --git a/tests/test_field.py b/tests/test_field.py
index ee4ed19..a361e58 100644
--- a/tests/test_field.py
+++ b/tests/test_field.py
@@ -17,7 +17,7 @@
 
 import base64
 import ipaddress
-from datetime import datetime
+from datetime import date, datetime, time
 from typing import cast
 
 import pytest
@@ -48,6 +48,24 @@ def test_boolean_deserialization() -> None:
     assert bf.deserialize(1)
 
 
+def test_datetime_deserialization() -> None:
+    f = field.Date()
+    dt = datetime.now()
+    assert dt == f._deserialize(dt.isoformat())
+
+    d = date.today()
+    assert datetime.combine(d, time()) == f._deserialize(d.isoformat())
+
+
+def test_date_deserialization() -> None:
+    f = field.Date(format="yyyy-MM-dd")
+    d = date.today()
+    assert d == f._deserialize(d.isoformat())
+
+    dt = datetime.now()
+    assert dt.date() == f._deserialize(dt.isoformat())
+
+
 def test_date_field_can_have_default_tz() -> None:
     f = field.Date(default_timezone="UTC")
     now = datetime.now()