Repository: geopandas/geopandas

Issue:
BUG: AttributeError about datetimelike values when reading file with Fiona engine
- [x] I have checked that this issue has not already been reported.

- [x] I have confirmed this bug exists on the latest version of geopandas (on the date of issue, last version is 0.11.0).

- [ ] (optional) I have confirmed this bug exists on the main branch of geopandas.

---

**Note**: Please read [this guide](https://matthewrocklin.com/blog/work/2018/02/28/minimal-bug-reports) detailing how to provide the necessary information for us to reproduce your bug.

#### Code Sample, a copy-pastable example

```python
import geopandas as gpd

path_to_file = <path to the file attached>
gdf = gpd.read_file(path_to_file)
```

#### Problem description
The attached file contains date-like columns. With the last version of geopandas I'm not able to read it anymore, as it raises an AttributeError `AttributeError: Can only use .dt accessor with datetimelike values`.

It was not the case with previous version of geopandas. The date-like columns were identified as object columns but at least no error were raised.

If I switch the engine from Fiona to Pyogrio to read the file, there is no error raised and all columns are well detected as datetime columns.

#### Expected Output

Have all columns parsed with no error raised. Here the output I obtained with engine Pyogrio instead of Fiona:
```
>>> gdf.dtypes
cleabs                             object
nature                             object
nature_detaillee                   object
toponyme                           object
statut_du_toponyme                 object
fictif                               bool
etat_de_l_objet                    object
date_creation              datetime64[ns]
date_modification          datetime64[ns]
date_d_apparition          datetime64[ns]
date_de_confirmation       datetime64[ns]
sources                            object
identifiants_sources               object
precision_planimetrique           float64
geometry                         geometry
```
#### Output of ``geopandas.show_versions()``

<details> 
<pre>
SYSTEM INFO
-----------
python     : 3.8.10 (default, Mar 15 2022, 12:22:08)  [GCC 9.4.0]
executable : my_env/bin/python
machine    : Linux-5.13.0-51-generic-x86_64-with-glibc2.29

GEOS, GDAL, PROJ INFO
---------------------
GEOS       : None
GEOS lib   : None
GDAL       : 3.4.1
GDAL data dir: my_env/lib/python3.8/site-packages/fiona/gdal_data
PROJ       : 8.2.0
PROJ data dir: my_env/lib/python3.8/site-packages/pyproj/proj_dir/share/proj

PYTHON DEPENDENCIES
-------------------
geopandas  : 0.11.0
pandas     : 1.4.3
fiona      : 1.8.21
numpy      : 1.23.0
shapely    : 1.8.2
rtree      : None
pyproj     : 3.3.1
matplotlib : None
mapclassify: None
geopy      : None
psycopg2   : None
geoalchemy2: None
pyarrow    : None
pygeos     : None
</pre>

</details>

Attached file: [grosfi.ch/GzMKuv5CHgu](https://www.grosfichiers.com/GzMKuv5CHgu)

Discussion:
martinfleis: Thanks! Can you also add a test and a note to the change log?

Since this is a regression I would suggest to cut 0.11.1 at some point soon. Maybe just wait a few days in case something else will show up  
m-richards: > Thanks! Can you also add a test and a note to the change log?

Yep, hence draft 😄. Was hoping to do it all at once but just a bit short on time at the moment.

jorisvandenbossche: Alternatively, we could also convert to UTC (`pd.to_datetime(.., utc=True)`) in case of mixed timezones (actually this are not mixed timezones, but rather different _offsets_ that can come from a single timezone, for example when having data from a full year with/without DST). 

Of course, leaving it as object dtype gives the user the choice whether they prefer to keep it as object or rather convert it to datetime64. But returning it as datetime64 (and thus UTC, as that is the only option without knowing the original timezone) makes the resulting columns typically much more useful.
m-richards: > Alternatively, we could also convert to UTC (`pd.to_datetime(.., utc=True)`) in case of mixed timezones 

This seems sensible, just wondering if it should be a separate PR though since this is a specific regression fix. Maybe it's fine since it all relates to a case I missed in the original PR adding datetime read support. 


jorisvandenbossche: Good point, we can indeed already merge this for the regression (the fact that it raises an error). On the other hand, being able to read datetime column as datetime dtype instead of string is also a new feature in 0.11. So if we want to change the behaviour here, probably also good to do this sooner rather than later.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a3dc849d..fbe54ce2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,8 @@ Deprecations and compatibility notes:
     `schema_version` if `version` is one of 0.1.0 or 0.4.0 (#2496).
 
 Bug fixes:
+- Fix a crash in datetime column reading where the file contains mixed timezone
+  offsets (#2479). These will be read as UTC localized values.
 
 - Fix regression (RecursionError) in reshape methods such as ``unstack()``
   and ``pivot()`` involving MultiIndex, or GeoDataFrame construction with
@@ -21,6 +23,15 @@ Bug fixes:
 
 Notes on (optional) dependencies:
 
+Version 0.11.1 (July xx, 2022)
+---------------------------------
+
+Small bug-fix release:
+
+- Fix regression in ``apply()`` causing row-wise all nan float columns to be 
+  casted to GeometryDtype (#2482). 
+
+
 Version 0.11 (June 20, 2022)
 ----------------------------
 
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index 92dc5045..f54f93cd 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -1524,14 +1524,17 @@ individually so that features may have different properties
             else:
                 if self.crs is not None and result.crs is None:
                     result.set_crs(self.crs, inplace=True)
-        elif isinstance(result, Series):
-            # Reconstruct series GeometryDtype if lost by apply
-            try:
-                # Note CRS cannot be preserved in this case as func could refer
-                # to any column
-                result = _ensure_geometry(result)
-            except TypeError:
-                pass
+        elif isinstance(result, Series) and result.dtype == "object":
+            # Try reconstruct series GeometryDtype if lost by apply
+            # If all none and object dtype assert list of nones is more likely
+            # intended than list of null geometry.
+            if not result.isna().all():
+                try:
+                    # not enough info about func to preserve CRS
+                    result = _ensure_geometry(result)
+
+                except TypeError:
+                    pass
 
         return result
 
diff --git a/geopandas/io/file.py b/geopandas/io/file.py
index 8c1783fc..3b4336e8 100644
--- a/geopandas/io/file.py
+++ b/geopandas/io/file.py
@@ -341,9 +341,15 @@ def _read_file_fiona(
                     f_filt, crs=crs, columns=columns + ["geometry"]
                 )
             for k in datetime_fields:
-                # fiona only supports up to ms precision, any microseconds are
-                # floating point rounding error
-                df[k] = pd.to_datetime(df[k]).dt.round(freq="ms")
+                as_dt = pd.to_datetime(df[k])
+                # if to_datetime failed, try again for mixed timezone offsets
+                if as_dt.dtype == "object":
+                    # This can fail if there are invalid datetimes
+                    as_dt = pd.to_datetime(df[k], utc=True)
+                # if to_datetime succeeded, round datetimes as
+                # fiona only supports up to ms precision
+                if not (as_dt.dtype == "object"):
+                    df[k] = as_dt.dt.round(freq="ms")
             return df
 
 


Test patch:
diff --git a/geopandas/io/tests/test_file.py b/geopandas/io/tests/test_file.py
index fcb9e54b..6ef58582 100644
--- a/geopandas/io/tests/test_file.py
+++ b/geopandas/io/tests/test_file.py
@@ -10,6 +10,7 @@ import numpy as np
 import pandas as pd
 
 import pytz
+from pandas.api.types import is_datetime64_any_dtype
 from pandas.testing import assert_series_equal
 from shapely.geometry import Point, Polygon, box
 
@@ -231,6 +232,28 @@ def test_to_file_datetime(tmpdir, driver, ext, time, engine):
         assert_series_equal(df["b"], df_read["b"])
 
 
+def test_read_file_mixed_datetimes(tmpdir):
+    tempfilename = os.path.join(str(tmpdir), "test_mixed_datetime.geojson")
+    df = GeoDataFrame(
+        {
+            "date": [
+                "2014-08-26 10:01:23.040001+02:00",
+                "2019-03-07 17:31:43.118999+01:00",
+            ],
+            "geometry": [Point(1, 1), Point(1, 1)],
+        }
+    )
+    df.to_file(tempfilename)
+    res = read_file(tempfilename)  # check mixed tz don't crash GH2478
+    assert is_datetime64_any_dtype(res["date"])
+    if FIONA_GE_1814:
+        # Convert mixed timezones to UTC equivalent
+        assert res["date"].dt.tz == pytz.utc
+    else:
+        # old fiona and pyogrio ignore timezones and read as datetimes successfully
+        assert is_datetime64_any_dtype(res["date"])
+
+
 @pytest.mark.parametrize("driver,ext", driver_ext_pairs)
 def test_to_file_with_point_z(tmpdir, ext, driver, engine):
     """Test that 3D geometries are retained in writes (GH #612)."""
diff --git a/geopandas/tests/test_pandas_methods.py b/geopandas/tests/test_pandas_methods.py
index 678b5a09..552d53bd 100644
--- a/geopandas/tests/test_pandas_methods.py
+++ b/geopandas/tests/test_pandas_methods.py
@@ -655,6 +655,12 @@ def test_df_apply_returning_series(df):
 
     result = df.apply(lambda row: row.value1, axis=1)
     assert_series_equal(result, df["value1"].rename(None))
+    # https://github.com/geopandas/geopandas/issues/2480
+    result = df.apply(lambda x: float("NaN"), axis=1)
+    assert result.dtype == "float64"
+    # assert list of nones is not promoted to GeometryDtype
+    result = df.apply(lambda x: None, axis=1)
+    assert result.dtype == "object"
 
 
 def test_pivot(df):