Repository: geopandas/geopandas

Issue:
ENH: Should round trip I/O of GPKG automatically use schema for non-numeric dtypes?
#### Question about geopandas

I'm surprised that writing to GPKG format, then reloading from disk, does not complete a round trip for all dtypes. This schema information is written to the file, but gpd.read_file() results in `object` dtypes rather than `string` and `datetime64[ns]`.

I noticed that gf.to_parquet('test.parquet') and gf = read_parquet('test.parquet') does retain `string` and `datetime64[ns]`, so that is a current workaround, but it would be convenient if GPKG did this as well for workflows blending geopandas and QGIS, etc (https://github.com/geopandas/geopandas/issues/1437#issuecomment-631636305).

```python
import geopandas as gpd
world = gpd.read_file(gpd.datasets.get_path('naturalearth_lowres'), ignore_fields=["iso_a3", "continent"], rows=2)
world['date'] = gpd.pd.to_datetime('2021-10-12')
world['name'] = world.name.astype('string')
print(world.dtypes)
world.to_file('world.gpkg', driver='GPKG')
```

```
pop_est                int64
name                  string
gdp_md_est           float64
geometry            geometry
date          datetime64[ns]
dtype: object
```

```python
round_trip = gpd.read_file('world.gpkg')
round_trip.dtypes
```
```
pop_est          int64
name            object
gdp_md_est     float64
date            object
geometry      geometry
dtype: object
```

```python
with fiona.open('world.gpkg') as f:
    print(f.schema)
```

```
{'properties': OrderedDict([('pop_est', 'int'), ('name', 'str'), ('gdp_md_est', 'float'), ('date', 'datetime')]), 'geometry': 'Unknown'}
```


<details>
  <summary>gpd.show_versions()</summary>
  
```
SYSTEM INFO
-----------
python     : 3.9.6 | packaged by conda-forge | (default, Jul 11 2021, 03:39:48)  [GCC 9.3.0]
executable : /home/scott/miniconda3/envs/isce2gimp/bin/python
machine    : Linux-5.4.0-80-generic-x86_64-with-glibc2.31

GEOS, GDAL, PROJ INFO
---------------------
GEOS       : 3.9.1
GEOS lib   : /home/scott/miniconda3/envs/isce2gimp/lib/libgeos_c.so
GDAL       : 3.2.2
GDAL data dir: /home/scott/miniconda3/envs/isce2gimp/lib/python3.9/site-packages/fiona/gdal_data
PROJ       : 8.0.1
PROJ data dir: /home/scott/miniconda3/envs/isce2gimp/share/proj

PYTHON DEPENDENCIES
-------------------
geopandas  : 0.10.1
pandas     : 1.3.3
fiona      : 1.8.20
numpy      : 1.21.1
shapely    : 1.7.1
rtree      : 0.9.7
pyproj     : 3.1.0
matplotlib : 3.4.2
mapclassify: 2.4.3
geopy      : None
psycopg2   : None
geoalchemy2: None
pyarrow    : 5.0.0
pygeos     : None
```

</details>




Discussion:
Thanks for the note!

Well, the truth is that we currently don't even try to preserve it. We just read the features and infer the dtype from their values. I guess that we should use the `schema` provider by `fiona` and map it onto columns (when possible).

Resolving patch:
diff --git a/geopandas/io/file.py b/geopandas/io/file.py
--- a/geopandas/io/file.py
+++ b/geopandas/io/file.py
@@ -236,14 +236,22 @@ def _read_file(filename, bbox=None, mask=None, rows=None, **kwargs):
                 f_filt = features
             # get list of columns
             columns = list(features.schema["properties"])
+            datetime_fields = [
+                k for (k, v) in features.schema["properties"].items() if v == "datetime"
+            ]
             if kwargs.get("ignore_geometry", False):
-                return pd.DataFrame(
+                df = pd.DataFrame(
                     [record["properties"] for record in f_filt], columns=columns
                 )
-
-            return GeoDataFrame.from_features(
-                f_filt, crs=crs, columns=columns + ["geometry"]
-            )
+            else:
+                df = GeoDataFrame.from_features(
+                    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")
+            return df
 
 
 def read_file(*args, **kwargs):


Test patch:
diff --git a/geopandas/io/tests/test_file.py b/geopandas/io/tests/test_file.py
--- a/geopandas/io/tests/test_file.py
+++ b/geopandas/io/tests/test_file.py
@@ -1,5 +1,6 @@
 from collections import OrderedDict
 import datetime
+from distutils.version import LooseVersion
 import io
 import os
 import pathlib
@@ -9,6 +10,8 @@
 import pandas as pd
 
 import fiona
+import pytz
+from pandas.testing import assert_series_equal
 from shapely.geometry import Point, Polygon, box
 
 import geopandas
@@ -21,6 +24,9 @@
 import pytest
 
 
+FIONA_GE_1814 = str(fiona.__version__) >= LooseVersion("1.8.14")  # datetime roundtrip
+
+
 _CRS = "epsg:4326"
 
 
@@ -139,15 +145,48 @@ def test_to_file_bool(tmpdir, driver, ext):
     assert_correct_driver(tempfilename, ext)
 
 
-def test_to_file_datetime(tmpdir):
+TEST_DATE = datetime.datetime(2021, 11, 21, 1, 7, 43, 17500)
+eastern = pytz.timezone("US/Eastern")
+
+datetime_type_tests = (TEST_DATE, eastern.localize(TEST_DATE))
+
+
+@pytest.mark.parametrize(
+    "time", datetime_type_tests, ids=("naive_datetime", "datetime_with_timezone")
+)
+@pytest.mark.parametrize("driver,ext", driver_ext_pairs)
+def test_to_file_datetime(tmpdir, driver, ext, time):
     """Test writing a data file with the datetime column type"""
-    tempfilename = os.path.join(str(tmpdir), "test_datetime.gpkg")
+    if ext in (".shp", ""):
+        pytest.skip(f"Driver corresponding to ext {ext} doesn't support dt fields")
+    if time.tzinfo is not None and FIONA_GE_1814 is False:
+        # https://github.com/Toblerity/Fiona/pull/915
+        pytest.skip("Fiona >= 1.8.14 needed for timezone support")
+
+    tempfilename = os.path.join(str(tmpdir), f"test_datetime{ext}")
     point = Point(0, 0)
-    now = datetime.datetime.now()
-    df = GeoDataFrame({"a": [1, 2], "b": [now, now]}, geometry=[point, point], crs=4326)
-    df.to_file(tempfilename, driver="GPKG")
+
+    df = GeoDataFrame(
+        {"a": [1, 2], "b": [time, time]}, geometry=[point, point], crs=4326
+    )
+    if FIONA_GE_1814:
+        fiona_precision_limit = "ms"
+    else:
+        fiona_precision_limit = "s"
+    df["b"] = df["b"].dt.round(freq=fiona_precision_limit)
+
+    df.to_file(tempfilename, driver=driver)
     df_read = read_file(tempfilename)
-    assert_geoseries_equal(df.geometry, df_read.geometry)
+
+    assert_geodataframe_equal(df.drop(columns=["b"]), df_read.drop(columns=["b"]))
+    if df["b"].dt.tz is not None:
+        # US/Eastern becomes pytz.FixedOffset(-300) when read from file
+        # so compare fairly in terms of UTC
+        assert_series_equal(
+            df["b"].dt.tz_convert(pytz.utc), df_read["b"].dt.tz_convert(pytz.utc)
+        )
+    else:
+        assert_series_equal(df["b"], df_read["b"])
 
 
 @pytest.mark.parametrize("driver,ext", driver_ext_pairs)