Repository: geopandas/geopandas

Issue:
BUG: Can't set parquet version in `GeoDataFrame.to_parquet`
- [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.

- [ ] (optional) I have confirmed this bug exists on the main branch of geopandas.

---

The `version` argument in `to_parquet` clashes between the geoparquet version and the parquet file format version.

#### Code Sample, a copy-pastable example

```pytb
In [1]: import geopandas

In [2]: df = geopandas.read_file(geopandas.datasets.get_path("naturalearth_lowres"))

In [3]: df.to_parquet("out.parquet", version="2.6")
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Input In [3], in <cell line: 1>()
----> 1 df.to_parquet("out.parquet", version="2.6")

File ~/src/stac-utils/staccontainers/.venv/lib/python3.9/site-packages/geopandas/geodataframe.py:1058, in GeoDataFrame.to_parquet(self, path, index, compression, version, **kwargs)
   1051     raise ValueError(
   1052         f"GeoPandas only supports using pyarrow as the engine for "
   1053         f"to_parquet: {engine!r} passed instead."
   1054     )
   1056 from geopandas.io.arrow import _to_parquet
-> 1058 _to_parquet(
   1059     self, path, compression=compression, index=index, version=version, **kwargs
   1060 )

File ~/src/stac-utils/staccontainers/.venv/lib/python3.9/site-packages/geopandas/io/arrow.py:284, in _to_parquet(df, path, index, compression, version, **kwargs)
    279 parquet = import_optional_dependency(
    280     "pyarrow.parquet", extra="pyarrow is required for Parquet support."
    281 )
    283 path = _expand_user(path)
--> 284 table = _geopandas_to_arrow(df, index=index, version=version)
    285 parquet.write_table(table, path, compression=compression, **kwargs)

File ~/src/stac-utils/staccontainers/.venv/lib/python3.9/site-packages/geopandas/io/arrow.py:236, in _geopandas_to_arrow(df, index, version)
    233 _validate_dataframe(df)
    235 # create geo metadata before altering incoming data frame
--> 236 geo_metadata = _create_metadata(df, version=version)
    238 df = df.to_wkb()
    240 table = Table.from_pandas(df, preserve_index=index)

File ~/src/stac-utils/staccontainers/.venv/lib/python3.9/site-packages/geopandas/io/arrow.py:87, in _create_metadata(df, version)
     84 version = version or METADATA_VERSION
     86 if version not in SUPPORTED_VERSIONS:
---> 87     raise ValueError(f"version must be one of: {', '.join(SUPPORTED_VERSIONS)}")
     89 # Construct metadata for each geometry
     90 column_metadata = {}

ValueError: version must be one of: 0.1.0, 0.4.0
```

#### Problem description

User's have no way to set the parquet file format version in `to_parquet`. They would need to convert to a Table (using private geopandas APIs) and write it

```python
In [4]: table = geopandas.io.arrow._geopandas_to_arrow(df)

In [5]: import pyarrow.parquet as pq

In [6]: pq.write_table(table, "out.parquet", version="2.6")
```

One possible solution: change the `version` argument to `geoparquet_version`. Deprecating things might be a tad difficult.... In the short-term, we might have to look at the actual value and guess if it's a valid parquet file format version?

#### Output of ``geopandas.show_versions()``

<details>

Failed `CDLL(libgeos_c.so.1)`
Failed `CDLL(libgeos_c.so)`

SYSTEM INFO
-----------
python     : 3.9.10 | packaged by conda-forge | (main, Feb  1 2022, 21:24:11)  [GCC 9.4.0]
executable : /home/taugspurger/src/stac-utils/staccontainers/.venv/bin/python3
machine    : Linux-5.10.102.1-microsoft-standard-WSL2-x86_64-with-glibc2.31

GEOS, GDAL, PROJ INFO
---------------------
GEOS       : None
GEOS lib   : None
GDAL       : 3.4.1
GDAL data dir: /home/taugspurger/src/stac-utils/staccontainers/.venv/lib/python3.9/site-packages/fiona/gdal_data
PROJ       : 8.2.0
PROJ data dir: /home/taugspurger/src/stac-utils/staccontainers/.venv/lib/python3.9/site-packages/pyproj/proj_dir/share/proj

PYTHON DEPENDENCIES
-------------------
geopandas  : 0.11.0
pandas     : 1.4.3
fiona      : 1.8.21
numpy      : 1.23.1
shapely    : 1.8.2
rtree      : None
pyproj     : 3.3.1
matplotlib : None
mapclassify: None
geopy      : None
psycopg2   : None
geoalchemy2: None
pyarrow    : 8.0.0
pygeos     : 0.12.0


</details>



Discussion:
Ah, good catch, that was an oversight when we added that keyword. 

> One possible solution: change the `version` argument to `geoparquet_version`. 

That sounds good.

> Deprecating things might be a tad difficult.... In the short-term, we might have to look at the actual value and guess if it's a valid parquet file format version?

Yeah, indeed, the version numbers shouldn't overlap I think, so that should allow us to infer when a deprecation warning should be raised.

cc @brendan-ward 

Yeah, that was an oversight, and hopefully not a keyword that has been used heavily.  It seems like in this case geopandas-specific keywords should be additive rather than duplicative to the underlying `write_table` / `write_feather` functions.

Once (if?) the GeoArrow schema diverges from the GeoParquet, it seems like we would not want to use `geoparquet_version` for the `to_feather` method.

What about `schema_version`?  Then we can basically assert that for 0.1.0 and 0.4.0, the arrow and parquet schemas are identical, but that in the future those can diverge, and `schema_version` refers to the schema for the particular format.
> Once (if?) the GeoArrow schema diverges from the GeoParquet, it seems like we would not want to use `geoparquet_version` for the `to_feather` method.

I don't know if we have any intention to do so, but even then, it's a bit strange to use "geoparquet_version" for a non-parquet method. So using a different name like `schema_version` is indeed better (or `metadata_version`, `geo_metadata_version`, ...)
I'd prefer `schema_version` because it encapsulates both the metadata and the geometry encoding.
`schema_version` sounds good to me too.

Resolving patch:
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -996,7 +996,7 @@ def to_wkt(self, **kwargs):
         return df
 
     def to_parquet(
-        self, path, index=None, compression="snappy", version=None, **kwargs
+        self, path, index=None, compression="snappy", schema_version=None, **kwargs
     ):
         """Write a GeoDataFrame to the Parquet format.
 
@@ -1026,7 +1026,7 @@ def to_parquet(
             output except `RangeIndex` which is stored as metadata only.
         compression : {'snappy', 'gzip', 'brotli', None}, default 'snappy'
             Name of the compression to use. Use ``None`` for no compression.
-        version : {'0.1.0', '0.4.0', None}
+        schema_version : {'0.1.0', '0.4.0', None}
             GeoParquet specification version; if not provided will default to
             latest supported version.
         kwargs
@@ -1056,10 +1056,17 @@ def to_parquet(
         from geopandas.io.arrow import _to_parquet
 
         _to_parquet(
-            self, path, compression=compression, index=index, version=version, **kwargs
+            self,
+            path,
+            compression=compression,
+            index=index,
+            schema_version=schema_version,
+            **kwargs,
         )
 
-    def to_feather(self, path, index=None, compression=None, version=None, **kwargs):
+    def to_feather(
+        self, path, index=None, compression=None, schema_version=None, **kwargs
+    ):
         """Write a GeoDataFrame to the Feather format.
 
         Any geometry columns present are serialized to WKB format in the file.
@@ -1089,7 +1096,7 @@ def to_feather(self, path, index=None, compression=None, version=None, **kwargs)
         compression : {'zstd', 'lz4', 'uncompressed'}, optional
             Name of the compression to use. Use ``"uncompressed"`` for no
             compression. By default uses LZ4 if available, otherwise uncompressed.
-        version : {'0.1.0', '0.4.0', None}
+        schema_version : {'0.1.0', '0.4.0', None}
             GeoParquet specification version; if not provided will default to
             latest supported version.
         kwargs
@@ -1110,7 +1117,12 @@ def to_feather(self, path, index=None, compression=None, version=None, **kwargs)
         from geopandas.io.arrow import _to_feather
 
         _to_feather(
-            self, path, index=index, compression=compression, version=version, **kwargs
+            self,
+            path,
+            index=index,
+            compression=compression,
+            schema_version=schema_version,
+            **kwargs,
         )
 
     def to_file(self, filename, driver=None, schema=None, index=None, **kwargs):
diff --git a/geopandas/io/arrow.py b/geopandas/io/arrow.py
--- a/geopandas/io/arrow.py
+++ b/geopandas/io/arrow.py
@@ -66,13 +66,13 @@ def _remove_id_from_member_of_ensembles(json_dict):
                 member.pop("id", None)
 
 
-def _create_metadata(df, version=None):
+def _create_metadata(df, schema_version=None):
     """Create and encode geo metadata dict.
 
     Parameters
     ----------
     df : GeoDataFrame
-    version : {'0.1.0', '0.4.0', None}
+    schema_version : {'0.1.0', '0.4.0', None}
         GeoParquet specification version; if not provided will default to
         latest supported version.
 
@@ -81,10 +81,12 @@ def _create_metadata(df, version=None):
     dict
     """
 
-    version = version or METADATA_VERSION
+    schema_version = schema_version or METADATA_VERSION
 
-    if version not in SUPPORTED_VERSIONS:
-        raise ValueError(f"version must be one of: {', '.join(SUPPORTED_VERSIONS)}")
+    if schema_version not in SUPPORTED_VERSIONS:
+        raise ValueError(
+            f"schema_version must be one of: {', '.join(SUPPORTED_VERSIONS)}"
+        )
 
     # Construct metadata for each geometry
     column_metadata = {}
@@ -94,7 +96,7 @@ def _create_metadata(df, version=None):
 
         crs = None
         if series.crs:
-            if version == "0.1.0":
+            if schema_version == "0.1.0":
                 crs = series.crs.to_wkt()
             else:  # version >= 0.4.0
                 crs = series.crs.to_json_dict()
@@ -112,7 +114,7 @@ def _create_metadata(df, version=None):
     return {
         "primary_column": df._geometry_column_name,
         "columns": column_metadata,
-        "version": METADATA_VERSION,
+        "version": schema_version or METADATA_VERSION,
         "creator": {"library": "geopandas", "version": geopandas.__version__},
     }
 
@@ -224,7 +226,7 @@ def _validate_metadata(metadata):
             raise ValueError("Only WKB geometry encoding is supported")
 
 
-def _geopandas_to_arrow(df, index=None, version=None):
+def _geopandas_to_arrow(df, index=None, schema_version=None):
     """
     Helper function with main, shared logic for to_parquet/to_feather.
     """
@@ -233,7 +235,7 @@ def _geopandas_to_arrow(df, index=None, version=None):
     _validate_dataframe(df)
 
     # create geo metadata before altering incoming data frame
-    geo_metadata = _create_metadata(df, version=version)
+    geo_metadata = _create_metadata(df, schema_version=schema_version)
 
     df = df.to_wkb()
 
@@ -243,10 +245,13 @@ def _geopandas_to_arrow(df, index=None, version=None):
     # This must be done AFTER creating the table or it is not persisted
     metadata = table.schema.metadata
     metadata.update({b"geo": _encode_metadata(geo_metadata)})
+
     return table.replace_schema_metadata(metadata)
 
 
-def _to_parquet(df, path, index=None, compression="snappy", version=None, **kwargs):
+def _to_parquet(
+    df, path, index=None, compression="snappy", schema_version=None, **kwargs
+):
     """
     Write a GeoDataFrame to the Parquet format.
 
@@ -270,7 +275,7 @@ def _to_parquet(df, path, index=None, compression="snappy", version=None, **kwar
         output except `RangeIndex` which is stored as metadata only.
     compression : {'snappy', 'gzip', 'brotli', None}, default 'snappy'
         Name of the compression to use. Use ``None`` for no compression.
-    version : {'0.1.0', '0.4.0', None}
+    schema_version : {'0.1.0', '0.4.0', None}
         GeoParquet specification version; if not provided will default to
         latest supported version.
     kwargs
@@ -280,12 +285,23 @@ def _to_parquet(df, path, index=None, compression="snappy", version=None, **kwar
         "pyarrow.parquet", extra="pyarrow is required for Parquet support."
     )
 
+    if kwargs and "version" in kwargs and kwargs["version"] is not None:
+        if schema_version is None and kwargs["version"] in SUPPORTED_VERSIONS:
+            warnings.warn(
+                "the `version` parameter has been replaced with `schema_version`. "
+                "`version` will instead be passed directly to the underlying "
+                "parquet writer unless `version` is 0.1.0 or 0.4.0.",
+                FutureWarning,
+                stacklevel=2,
+            )
+            schema_version = kwargs.pop("version")
+
     path = _expand_user(path)
-    table = _geopandas_to_arrow(df, index=index, version=version)
+    table = _geopandas_to_arrow(df, index=index, schema_version=schema_version)
     parquet.write_table(table, path, compression=compression, **kwargs)
 
 
-def _to_feather(df, path, index=None, compression=None, version=None, **kwargs):
+def _to_feather(df, path, index=None, compression=None, schema_version=None, **kwargs):
     """
     Write a GeoDataFrame to the Feather format.
 
@@ -310,7 +326,7 @@ def _to_feather(df, path, index=None, compression=None, version=None, **kwargs):
     compression : {'zstd', 'lz4', 'uncompressed'}, optional
         Name of the compression to use. Use ``"uncompressed"`` for no
         compression. By default uses LZ4 if available, otherwise uncompressed.
-    version : {'0.1.0', '0.4.0', None}
+    schema_version : {'0.1.0', '0.4.0', None}
         GeoParquet specification version; if not provided will default to
         latest supported version.
     kwargs
@@ -325,8 +341,19 @@ def _to_feather(df, path, index=None, compression=None, version=None, **kwargs):
     if Version(pyarrow.__version__) < Version("0.17.0"):
         raise ImportError("pyarrow >= 0.17 required for Feather support")
 
+    if kwargs and "version" in kwargs and kwargs["version"] is not None:
+        if schema_version is None and kwargs["version"] in SUPPORTED_VERSIONS:
+            warnings.warn(
+                "the `version` parameter has been replaced with `schema_version`. "
+                "`version` will instead be passed directly to the underlying "
+                "feather writer unless `version` is 0.1.0 or 0.4.0.",
+                FutureWarning,
+                stacklevel=2,
+            )
+            schema_version = kwargs.pop("version")
+
     path = _expand_user(path)
-    table = _geopandas_to_arrow(df, index=index, version=version)
+    table = _geopandas_to_arrow(df, index=index, schema_version=schema_version)
     feather.write_feather(table, path, compression=compression, **kwargs)
 
 
@@ -337,6 +364,7 @@ def _arrow_to_geopandas(table, metadata=None):
     df = table.to_pandas()
 
     metadata = metadata or table.schema.metadata
+
     if metadata is None or b"geo" not in metadata:
         raise ValueError(
             """Missing geo metadata in Parquet/Feather file.


Test patch:
diff --git a/geopandas/io/tests/test_arrow.py b/geopandas/io/tests/test_arrow.py
--- a/geopandas/io/tests/test_arrow.py
+++ b/geopandas/io/tests/test_arrow.py
@@ -105,8 +105,8 @@ def test_crs_metadata_datum_ensemble():
 
 def test_write_metadata_invalid_spec_version():
     gdf = geopandas.GeoDataFrame(geometry=[box(0, 0, 10, 10)], crs="EPSG:4326")
-    with pytest.raises(ValueError, match="version must be one of"):
-        _create_metadata(gdf, version="invalid")
+    with pytest.raises(ValueError, match="schema_version must be one of"):
+        _create_metadata(gdf, schema_version="invalid")
 
 
 def test_encode_metadata():
@@ -259,7 +259,7 @@ def test_to_parquet_does_not_pass_engine_along(mock_to_parquet):
     # assert that engine keyword is not passed through to _to_parquet (and thus
     # parquet.write_table)
     mock_to_parquet.assert_called_with(
-        df, "", compression="snappy", index=None, version=None
+        df, "", compression="snappy", index=None, schema_version=None
     )
 
 
@@ -679,9 +679,10 @@ def test_write_read_default_crs(tmpdir, format):
 
 
 @pytest.mark.parametrize(
-    "format,version", product(["feather", "parquet"], [None] + SUPPORTED_VERSIONS)
+    "format,schema_version",
+    product(["feather", "parquet"], [None] + SUPPORTED_VERSIONS),
 )
-def test_write_spec_version(tmpdir, format, version):
+def test_write_spec_version(tmpdir, format, schema_version):
     if format == "feather":
         from pyarrow.feather import read_table
 
@@ -691,7 +692,7 @@ def test_write_spec_version(tmpdir, format, version):
     filename = os.path.join(str(tmpdir), f"test.{format}")
     gdf = geopandas.GeoDataFrame(geometry=[box(0, 0, 10, 10)], crs="EPSG:4326")
     write = getattr(gdf, f"to_{format}")
-    write(filename, version=version)
+    write(filename, schema_version=schema_version)
 
     # ensure that we can roundtrip data regardless of version
     read = getattr(geopandas, f"read_{format}")
@@ -700,10 +701,10 @@ def test_write_spec_version(tmpdir, format, version):
 
     table = read_table(filename)
     metadata = json.loads(table.schema.metadata[b"geo"])
-    assert metadata["version"] == version or METADATA_VERSION
+    assert metadata["version"] == schema_version or METADATA_VERSION
 
     # verify that CRS is correctly handled between versions
-    if version == "0.1.0":
+    if schema_version == "0.1.0":
         assert metadata["columns"]["geometry"]["crs"] == gdf.crs.to_wkt()
 
     else:
@@ -712,6 +713,46 @@ def test_write_spec_version(tmpdir, format, version):
         assert metadata["columns"]["geometry"]["crs"] == crs_expected
 
 
+@pytest.mark.parametrize(
+    "format,version", product(["feather", "parquet"], [None] + SUPPORTED_VERSIONS)
+)
+def test_write_deprecated_version_parameter(tmpdir, format, version):
+    if format == "feather":
+        from pyarrow.feather import read_table
+
+        version = version or 2
+
+    else:
+        from pyarrow.parquet import read_table
+
+        version = version or "2.6"
+
+    filename = os.path.join(str(tmpdir), f"test.{format}")
+    gdf = geopandas.GeoDataFrame(geometry=[box(0, 0, 10, 10)], crs="EPSG:4326")
+    write = getattr(gdf, f"to_{format}")
+
+    if version in SUPPORTED_VERSIONS:
+        with pytest.warns(
+            FutureWarning,
+            match="the `version` parameter has been replaced with `schema_version`",
+        ):
+            write(filename, version=version)
+
+    else:
+        # no warning raised if not one of the captured versions
+        write(filename, version=version)
+
+    table = read_table(filename)
+    metadata = json.loads(table.schema.metadata[b"geo"])
+
+    if version in SUPPORTED_VERSIONS:
+        # version is captured as a parameter
+        assert metadata["version"] == version
+    else:
+        # version is passed to underlying writer
+        assert metadata["version"] == METADATA_VERSION
+
+
 @pytest.mark.parametrize("version", ["0.1.0", "0.4.0"])
 def test_read_versioned_file(version):
     """