Repository: geopandas/geopandas

Issue:
ENH: support writing + filtered reading from bbox columns in GeoParquet
GeoParquet 1.1 is adding a new feature to the spec: bbox columns ("bbox covering encoding", https://github.com/opengeospatial/geoparquet/blob/main/format-specs/geoparquet.md#covering)

We should add support for that to geopandas:

- Writing (`to_parquet`): optionally compute and add this column to written file (behind some option, eg `write_bbox_covering=True/False`?)
- Reading (`read_parquet`):
  - Allow the user to specify a `bbox` to filter (similarly as `read_file`), and automatically translate this to a pyarrow `filter` predicate.
  - By default drop the bbox struct column when reading and converting to pandas? I would say yes (converting a struct column to pandas is expensive), but would there be a use case for providing an option to keep it?


Discussion:
This would be nice for 1.0.

I would also drop it by default but you may want to be able to read it in case of some debugging.
anyone currently working on this one? This one looks interesting and would love to try tackling it
Definitely feel free to give it a try! And I can certainly give you some pointers or answer questions if needed
Thanks Joris, apologies I haven't actioned yet, have been a bit under the weather. Aiming to have a draft PR up by end of this week.

Resolving patch:
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -1238,7 +1238,13 @@ def to_arrow(
         return ArrowTable(table)
 
     def to_parquet(
-        self, path, index=None, compression="snappy", schema_version=None, **kwargs
+        self,
+        path,
+        index=None,
+        compression="snappy",
+        schema_version=None,
+        write_covering_bbox=False,
+        **kwargs,
     ):
         """Write a GeoDataFrame to the Parquet format.
 
@@ -1262,6 +1268,11 @@ def to_parquet(
         schema_version : {'0.1.0', '0.4.0', '1.0.0', None}
             GeoParquet specification version; if not provided will default to
             latest supported version.
+        write_covering_bbox : bool, default False
+            Writes the bounding box column for each row entry with column
+            name 'bbox'. Writing a bbox column can be computationally
+            expensive, but allows you to specify a `bbox` in :
+            func:`read_parquet` for filtered reading.
         kwargs
             Additional keyword arguments passed to :func:`pyarrow.parquet.write_table`.
 
@@ -1294,6 +1305,7 @@ def to_parquet(
             compression=compression,
             index=index,
             schema_version=schema_version,
+            write_covering_bbox=write_covering_bbox,
             **kwargs,
         )
 
diff --git a/geopandas/io/arrow.py b/geopandas/io/arrow.py
--- a/geopandas/io/arrow.py
+++ b/geopandas/io/arrow.py
@@ -110,7 +110,7 @@ def _get_geometry_types(series):
     return sorted([_geometry_type_names[idx] for idx in geometry_types])
 
 
-def _create_metadata(df, schema_version=None):
+def _create_metadata(df, schema_version=None, write_covering_bbox=False):
     """Create and encode geo metadata dict.
 
     Parameters
@@ -119,6 +119,10 @@ def _create_metadata(df, schema_version=None):
     schema_version : {'0.1.0', '0.4.0', '1.0.0-beta.1', '1.0.0', None}
         GeoParquet specification version; if not provided will default to
         latest supported version.
+    write_covering_bbox : bool, default False
+        Writes the bounding box column for each row entry with column
+        name 'bbox'. Writing a bbox column can be computationally
+        expensive, hence is default setting is False.
 
     Returns
     -------
@@ -164,6 +168,16 @@ def _create_metadata(df, schema_version=None):
             # don't add bbox with NaNs for empty / all-NA geometry column
             column_metadata[col]["bbox"] = bbox
 
+        if write_covering_bbox:
+            column_metadata[col]["covering"] = {
+                "bbox": {
+                    "xmin": ["bbox", "xmin"],
+                    "ymin": ["bbox", "ymin"],
+                    "xmax": ["bbox", "xmax"],
+                    "ymax": ["bbox", "ymax"],
+                },
+            }
+
     return {
         "primary_column": df._geometry_column_name,
         "columns": column_metadata,
@@ -231,7 +245,7 @@ def _validate_dataframe(df):
         raise ValueError("Index level names must be strings")
 
 
-def _validate_metadata(metadata):
+def _validate_geo_metadata(metadata):
     """Validate geo metadata.
     Must not be empty, and must contain the structure specified above.
 
@@ -292,22 +306,42 @@ def _validate_metadata(metadata):
                 stacklevel=4,
             )
 
+        if "covering" in column_metadata:
+            covering = column_metadata["covering"]
+            if "bbox" in covering:
+                bbox = covering["bbox"]
+                for var in ["xmin", "ymin", "xmax", "ymax"]:
+                    if var not in bbox.keys():
+                        raise ValueError("Metadata for bbox column is malformed.")
+
 
-def _geopandas_to_arrow(df, index=None, schema_version=None):
+def _geopandas_to_arrow(df, index=None, schema_version=None, write_covering_bbox=None):
     """
     Helper function with main, shared logic for to_parquet/to_feather.
     """
+    from pyarrow import StructArray
+
     from geopandas.io.geoarrow import geopandas_to_arrow
 
     _validate_dataframe(df)
 
     # create geo metadata before altering incoming data frame
-    geo_metadata = _create_metadata(df, schema_version=schema_version)
+    geo_metadata = _create_metadata(
+        df, schema_version=schema_version, write_covering_bbox=write_covering_bbox
+    )
 
     table = geopandas_to_arrow(
         df, geometry_encoding="WKB", index=index, interleaved=True
     )
 
+    if write_covering_bbox:
+        bounds = df.bounds
+        bbox_array = StructArray.from_arrays(
+            [bounds["minx"], bounds["miny"], bounds["maxx"], bounds["maxy"]],
+            names=["xmin", "ymin", "xmax", "ymax"],
+        )
+        table = table.append_column("bbox", bbox_array)
+
     # Store geopandas specific file-level metadata
     # This must be done AFTER creating the table or it is not persisted
     metadata = table.schema.metadata
@@ -317,7 +351,13 @@ def _geopandas_to_arrow(df, index=None, schema_version=None):
 
 
 def _to_parquet(
-    df, path, index=None, compression="snappy", schema_version=None, **kwargs
+    df,
+    path,
+    index=None,
+    compression="snappy",
+    schema_version=None,
+    write_covering_bbox=False,
+    **kwargs,
 ):
     """
     Write a GeoDataFrame to the Parquet format.
@@ -346,6 +386,10 @@ def _to_parquet(
     schema_version : {'0.1.0', '0.4.0', '1.0.0', None}
         GeoParquet specification version; if not provided will default to
         latest supported version.
+    write_covering_bbox : bool, default False
+        Writes the bounding box column for each row entry with column
+        name 'bbox'. Writing a bbox column can be computationally
+        expensive, hence is default setting is False.
     **kwargs
         Additional keyword arguments passed to pyarrow.parquet.write_table().
     """
@@ -365,7 +409,12 @@ def _to_parquet(
             schema_version = kwargs.pop("version")
 
     path = _expand_user(path)
-    table = _geopandas_to_arrow(df, index=index, schema_version=schema_version)
+    table = _geopandas_to_arrow(
+        df,
+        index=index,
+        schema_version=schema_version,
+        write_covering_bbox=write_covering_bbox,
+    )
     parquet.write_table(table, path, compression=compression, **kwargs)
 
 
@@ -426,31 +475,19 @@ def _to_feather(df, path, index=None, compression=None, schema_version=None, **k
     feather.write_feather(table, path, compression=compression, **kwargs)
 
 
-def _arrow_to_geopandas(table, metadata=None):
+def _arrow_to_geopandas(table, geo_metadata=None):
     """
     Helper function with main, shared logic for read_parquet/read_feather.
     """
     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.
-            Use pandas.read_parquet/read_feather() instead."""
-        )
-
-    try:
-        metadata = _decode_metadata(metadata.get(b"geo", b""))
-
-    except (TypeError, json.decoder.JSONDecodeError):
-        raise ValueError("Missing or malformed geo metadata in Parquet/Feather file")
-
-    _validate_metadata(metadata)
+    geo_metadata = geo_metadata or _decode_metadata(
+        table.schema.metadata.get(b"geo", b"")
+    )
 
     # Find all geometry columns that were read from the file.  May
     # be a subset if 'columns' parameter is used.
-    geometry_columns = df.columns.intersection(metadata["columns"])
+    geometry_columns = df.columns.intersection(geo_metadata["columns"])
 
     if not len(geometry_columns):
         raise ValueError(
@@ -459,7 +496,7 @@ def _arrow_to_geopandas(table, metadata=None):
             use pandas.read_parquet/read_feather() instead."""
         )
 
-    geometry = metadata["primary_column"]
+    geometry = geo_metadata["primary_column"]
 
     # Missing geometry likely indicates a subset of columns was read;
     # promote the first available geometry to the primary geometry.
@@ -476,7 +513,7 @@ def _arrow_to_geopandas(table, metadata=None):
 
     # Convert the WKB columns that are present back to geometry.
     for col in geometry_columns:
-        col_metadata = metadata["columns"][col]
+        col_metadata = geo_metadata["columns"][col]
         if "crs" in col_metadata:
             crs = col_metadata["crs"]
             if isinstance(crs, dict):
@@ -562,6 +599,23 @@ def _ensure_arrow_fs(filesystem):
     return filesystem
 
 
+def _validate_and_decode_metadata(metadata):
+    if metadata is None or b"geo" not in metadata:
+        raise ValueError(
+            """Missing geo metadata in Parquet/Feather file.
+            Use pandas.read_parquet/read_feather() instead."""
+        )
+
+    # check for malformed metadata
+    try:
+        decoded_geo_metadata = _decode_metadata(metadata.get(b"geo", b""))
+    except (TypeError, json.decoder.JSONDecodeError):
+        raise ValueError("Missing or malformed geo metadata in Parquet/Feather file")
+
+    _validate_geo_metadata(decoded_geo_metadata)
+    return decoded_geo_metadata
+
+
 def _read_parquet_schema_and_metadata(path, filesystem):
     """
     Opening the Parquet file/dataset a first time to get the schema and metadata.
@@ -597,7 +651,7 @@ def _read_parquet_schema_and_metadata(path, filesystem):
     return schema, metadata
 
 
-def _read_parquet(path, columns=None, storage_options=None, **kwargs):
+def _read_parquet(path, columns=None, storage_options=None, bbox=None, **kwargs):
     """
     Load a Parquet object from the file path, returning a GeoDataFrame.
 
@@ -641,6 +695,11 @@ def _read_parquet(path, columns=None, storage_options=None, **kwargs):
         both ``pyarrow.fs`` and ``fsspec`` (e.g. "s3://") then the ``pyarrow.fs``
         filesystem is preferred. Provide the instantiated fsspec filesystem using
         the ``filesystem`` keyword if you wish to use its implementation.
+    bbox : tuple, optional
+        Bounding box to be used to filter selection from geoparquet data. This
+        is only usable if the data was saved with the bbox covering metadata.
+        Input is of the tuple format (xmin, ymin, xmax, ymax).
+
     **kwargs
         Any additional kwargs passed to :func:`pyarrow.parquet.read_table`.
 
@@ -672,13 +731,35 @@ def _read_parquet(path, columns=None, storage_options=None, **kwargs):
         path, filesystem=filesystem, storage_options=storage_options
     )
     path = _expand_user(path)
+    schema, metadata = _read_parquet_schema_and_metadata(path, filesystem)
+
+    geo_metadata = _validate_and_decode_metadata(metadata)
+
+    bbox_filter = (
+        _get_parquet_bbox_filter(geo_metadata, bbox) if bbox is not None else None
+    )
+
+    if_bbox_column_exists = _check_if_covering_in_geo_metadata(geo_metadata)
 
-    _, metadata = _read_parquet_schema_and_metadata(path, filesystem)
+    # by default, bbox column is not read in, so must specify which
+    # columns are read in if it exists.
+    if not columns and if_bbox_column_exists:
+        columns = _get_non_bbox_columns(schema, geo_metadata)
+
+    # if both bbox and filters kwargs are used, must splice together.
+    if "filters" in kwargs:
+        filters_kwarg = kwargs.pop("filters")
+        filters = _splice_bbox_and_filters(filters_kwarg, bbox_filter)
+    else:
+        filters = bbox_filter
 
     kwargs["use_pandas_metadata"] = True
-    table = parquet.read_table(path, columns=columns, filesystem=filesystem, **kwargs)
 
-    return _arrow_to_geopandas(table, metadata)
+    table = parquet.read_table(
+        path, columns=columns, filesystem=filesystem, filters=filters, **kwargs
+    )
+
+    return _arrow_to_geopandas(table, geo_metadata)
 
 
 def _read_feather(path, columns=None, **kwargs):
@@ -745,5 +826,55 @@ def _read_feather(path, columns=None, **kwargs):
         raise ImportError("pyarrow >= 0.17 required for Feather support")
 
     path = _expand_user(path)
+
     table = feather.read_table(path, columns=columns, **kwargs)
+    _validate_and_decode_metadata(table.schema.metadata)
     return _arrow_to_geopandas(table)
+
+
+def _get_parquet_bbox_filter(geo_metadata, bbox):
+
+    if not _check_if_covering_in_geo_metadata(geo_metadata):
+        raise ValueError("No covering bbox in parquet file.")
+
+    bbox_column_name = _get_bbox_encoding_column_name(geo_metadata)
+    return _convert_bbox_to_parquet_filter(bbox, bbox_column_name)
+
+
+def _convert_bbox_to_parquet_filter(bbox, bbox_column_name):
+    import pyarrow.compute as pc
+
+    return ~(
+        (pc.field((bbox_column_name, "xmin")) > bbox[2])
+        | (pc.field((bbox_column_name, "ymin")) > bbox[3])
+        | (pc.field((bbox_column_name, "xmax")) < bbox[0])
+        | (pc.field((bbox_column_name, "ymax")) < bbox[1])
+    )
+
+
+def _check_if_covering_in_geo_metadata(geo_metadata):
+    return "covering" in geo_metadata["columns"]["geometry"].keys()
+
+
+def _get_bbox_encoding_column_name(geo_metadata):
+    return geo_metadata["columns"]["geometry"]["covering"]["bbox"]["xmin"][0]
+
+
+def _get_non_bbox_columns(schema, geo_metadata):
+
+    bbox_column_name = _get_bbox_encoding_column_name(geo_metadata)
+    columns = schema.names
+    if bbox_column_name in columns:
+        columns.remove(bbox_column_name)
+    return columns
+
+
+def _splice_bbox_and_filters(kwarg_filters, bbox_filter):
+    parquet = import_optional_dependency(
+        "pyarrow.parquet", extra="pyarrow is required for Parquet support."
+    )
+    if bbox_filter is None:
+        return kwarg_filters
+
+    filters_expression = parquet.filters_to_expression(kwarg_filters)
+    return bbox_filter & filters_expression


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
@@ -20,6 +20,7 @@
 from geopandas.io.arrow import (
     METADATA_VERSION,
     SUPPORTED_VERSIONS,
+    _convert_bbox_to_parquet_filter,
     _create_metadata,
     _decode_metadata,
     _encode_metadata,
@@ -27,7 +28,7 @@
     _get_filesystem_path,
     _remove_id_from_member_of_ensembles,
     _validate_dataframe,
-    _validate_metadata,
+    _validate_geo_metadata,
 )
 
 import pytest
@@ -41,6 +42,8 @@
 # Skip all tests in this module if pyarrow is not available
 pyarrow = pytest.importorskip("pyarrow")
 
+import pyarrow.compute as pc
+
 
 @pytest.fixture(
     params=[
@@ -210,8 +213,8 @@ def test_validate_dataframe(naturalearth_lowres):
         _validate_dataframe("not a dataframe")
 
 
-def test_validate_metadata_valid():
-    _validate_metadata(
+def test_validate_geo_metadata_valid():
+    _validate_geo_metadata(
         {
             "primary_column": "geometry",
             "columns": {"geometry": {"crs": None, "encoding": "WKB"}},
@@ -219,7 +222,7 @@ def test_validate_metadata_valid():
         }
     )
 
-    _validate_metadata(
+    _validate_geo_metadata(
         {
             "primary_column": "geometry",
             "columns": {"geometry": {"crs": None, "encoding": "WKB"}},
@@ -227,7 +230,7 @@ def test_validate_metadata_valid():
         }
     )
 
-    _validate_metadata(
+    _validate_geo_metadata(
         {
             "primary_column": "geometry",
             "columns": {
@@ -296,12 +299,12 @@ def test_validate_metadata_valid():
         ),
     ],
 )
-def test_validate_metadata_invalid(metadata, error):
+def test_validate_geo_metadata_invalid(metadata, error):
     with pytest.raises(ValueError, match=error):
-        _validate_metadata(metadata)
+        _validate_geo_metadata(metadata)
 
 
-def test_validate_metadata_edges():
+def test_validate_geo_metadata_edges():
     metadata = {
         "primary_column": "geometry",
         "columns": {"geometry": {"crs": None, "encoding": "WKB", "edges": "spherical"}},
@@ -311,7 +314,7 @@ def test_validate_metadata_edges():
         UserWarning,
         match="The geo metadata indicate that column 'geometry' has spherical edges",
     ):
-        _validate_metadata(metadata)
+        _validate_geo_metadata(metadata)
 
 
 def test_to_parquet_fails_on_invalid_engine(tmpdir):
@@ -334,7 +337,12 @@ 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, schema_version=None
+        df,
+        "",
+        compression="snappy",
+        index=None,
+        schema_version=None,
+        write_covering_bbox=False,
     )
 
 
@@ -987,3 +995,233 @@ def test_read_parquet_geoarrow(geometry_type):
         / f"data-{geometry_type}-encoding_wkb.parquet"
     )
     assert_geodataframe_equal(result, expected, check_crs=True)
+
+
+def test_to_parquet_bbox_structure_and_metadata(tmpdir, naturalearth_lowres):
+    # check metadata being written for covering.
+    from pyarrow import parquet
+
+    df = read_file(naturalearth_lowres)
+    filename = os.path.join(str(tmpdir), "test.pq")
+    df.to_parquet(filename, write_covering_bbox=True)
+
+    table = parquet.read_table(filename)
+    metadata = json.loads(table.schema.metadata[b"geo"].decode("utf-8"))
+    assert metadata["columns"]["geometry"]["covering"] == {
+        "bbox": {
+            "xmin": ["bbox", "xmin"],
+            "ymin": ["bbox", "ymin"],
+            "xmax": ["bbox", "xmax"],
+            "ymax": ["bbox", "ymax"],
+        }
+    }
+    assert "bbox" in table.schema.names
+    assert [field.name for field in table.schema.field("bbox").type] == [
+        "xmin",
+        "ymin",
+        "xmax",
+        "ymax",
+    ]
+
+
+@pytest.mark.parametrize(
+    "geometry, expected_bbox",
+    [
+        (Point(1, 3), {"xmin": 1.0, "ymin": 3.0, "xmax": 1.0, "ymax": 3.0}),
+        (
+            LineString([(1, 1), (3, 3)]),
+            {"xmin": 1.0, "ymin": 1.0, "xmax": 3.0, "ymax": 3.0},
+        ),
+        (
+            Polygon([(2, 1), (1, 2), (2, 3), (3, 2)]),
+            {"xmin": 1.0, "ymin": 1.0, "xmax": 3.0, "ymax": 3.0},
+        ),
+        (
+            MultiPolygon([box(0, 0, 1, 1), box(2, 2, 3, 3), box(4, 4, 5, 5)]),
+            {"xmin": 0.0, "ymin": 0.0, "xmax": 5.0, "ymax": 5.0},
+        ),
+    ],
+    ids=["Point", "LineString", "Polygon", "Multipolygon"],
+)
+def test_to_parquet_bbox_values(tmpdir, geometry, expected_bbox):
+    # check bbox bounds being written for different geometry types.
+    import pyarrow.parquet as pq
+
+    df = GeoDataFrame(data=[[1, 2]], columns=["a", "b"], geometry=[geometry])
+    filename = os.path.join(str(tmpdir), "test.pq")
+
+    df.to_parquet(filename, write_covering_bbox=True)
+
+    result = pq.read_table(filename).to_pandas()
+    assert result["bbox"][0] == expected_bbox
+
+
+def test_read_parquet_bbox_single_point(tmpdir):
+    # confirm that on a single point, bbox will pick it up.
+    df = GeoDataFrame(data=[[1, 2]], columns=["a", "b"], geometry=[Point(1, 1)])
+    filename = os.path.join(str(tmpdir), "test.pq")
+    df.to_parquet(filename, write_covering_bbox=True)
+    pq_df = read_parquet(filename, bbox=(1, 1, 1, 1))
+    assert len(pq_df) == 1
+    assert pq_df.geometry[0] == Point(1, 1)
+
+
+def test_read_parquet_bbox(tmpdir, naturalearth_lowres):
+    # check bbox is being used to filter results.
+    df = read_file(naturalearth_lowres)
+    filename = os.path.join(str(tmpdir), "test.pq")
+    df.to_parquet(filename, write_covering_bbox=True)
+
+    pq_df = read_parquet(filename, bbox=(0, 0, 10, 10))
+
+    assert pq_df["name"].values.tolist() == [
+        "France",
+        "Benin",
+        "Nigeria",
+        "Cameroon",
+        "Togo",
+        "Ghana",
+        "Burkina Faso",
+        "Gabon",
+        "Eq. Guinea",
+    ]
+
+
+def test_read_parquet_bbox_partitioned(tmpdir, naturalearth_lowres):
+    # check bbox is being used to filter results on partioned data.
+    df = read_file(naturalearth_lowres)
+
+    # manually create partitioned dataset
+    basedir = tmpdir / "partitioned_dataset"
+    basedir.mkdir()
+    df[:100].to_parquet(basedir / "data1.parquet", write_covering_bbox=True)
+    df[100:].to_parquet(basedir / "data2.parquet", write_covering_bbox=True)
+
+    pq_df = read_parquet(basedir, bbox=(0, 0, 10, 10))
+
+    assert pq_df["name"].values.tolist() == [
+        "France",
+        "Benin",
+        "Nigeria",
+        "Cameroon",
+        "Togo",
+        "Ghana",
+        "Burkina Faso",
+        "Gabon",
+        "Eq. Guinea",
+    ]
+
+
+@pytest.mark.parametrize(
+    "geometry, bbox",
+    [
+        (LineString([(1, 1), (3, 3)]), (1.5, 1.5, 3.5, 3.5)),
+        (LineString([(1, 1), (3, 3)]), (3, 3, 3, 3)),
+        (LineString([(1, 1), (3, 3)]), (1.5, 1.5, 2.5, 2.5)),
+        (Polygon([(0, 0), (4, 0), (4, 4), (0, 4)]), (1, 1, 3, 3)),
+        (Polygon([(0, 0), (4, 0), (4, 4), (0, 4)]), (1, 1, 5, 5)),
+        (Polygon([(0, 0), (4, 0), (4, 4), (0, 4)]), (2, 2, 4, 4)),
+        (Polygon([(0, 0), (4, 0), (4, 4), (0, 4)]), (4, 4, 4, 4)),
+        (Polygon([(0, 0), (4, 0), (4, 4), (0, 4)]), (1, 1, 5, 3)),
+    ],
+)
+def test_read_parquet_bbox_partial_overlap_of_geometry(tmpdir, geometry, bbox):
+    df = GeoDataFrame(data=[[1, 2]], columns=["a", "b"], geometry=[geometry])
+    filename = os.path.join(str(tmpdir), "test.pq")
+    df.to_parquet(filename, write_covering_bbox=True)
+
+    pq_df = read_parquet(filename, bbox=bbox)
+    assert len(pq_df) == 1
+
+
+def test_read_parquet_no_bbox(tmpdir, naturalearth_lowres):
+    # check error message when parquet lacks a bbox column but
+    # want to use bbox kwarg in read_parquet.
+    df = read_file(naturalearth_lowres)
+    filename = os.path.join(str(tmpdir), "test.pq")
+    df.to_parquet(filename)
+    with pytest.raises(ValueError, match="No covering bbox in parquet file."):
+        read_parquet(filename, bbox=(0, 0, 20, 20))
+
+
+def test_read_parquet_no_bbox_partitioned(tmpdir, naturalearth_lowres):
+    # check error message when partitioned parquet data does not have
+    # a bbox column but want to use kwarg to read_parquet.
+    df = read_file(naturalearth_lowres)
+
+    # manually create partitioned dataset
+    basedir = tmpdir / "partitioned_dataset"
+    basedir.mkdir()
+    df[:100].to_parquet(basedir / "data1.parquet")
+    df[100:].to_parquet(basedir / "data2.parquet")
+
+    with pytest.raises(ValueError, match="No covering bbox in parquet file."):
+        read_parquet(basedir, bbox=(0, 0, 20, 20))
+
+
+def test_convert_bbox_to_parquet_filter():
+    # check conversion of bbox to parquet filter expression
+    import pyarrow.compute as pc
+
+    bbox = (0, 0, 25, 35)
+    expected = ~(
+        (pc.field(("bbox", "xmin")) > 25)
+        | (pc.field(("bbox", "ymin")) > 35)
+        | (pc.field(("bbox", "xmax")) < 0)
+        | (pc.field(("bbox", "ymax")) < 0)
+    )
+    assert expected.equals(_convert_bbox_to_parquet_filter(bbox, "bbox"))
+
+
+def test_read_parquet_bbox_column_default_behaviour(tmpdir, naturalearth_lowres):
+    # check that bbox column is not read in by default
+
+    df = read_file(naturalearth_lowres)
+    filename = os.path.join(str(tmpdir), "test.pq")
+    df.to_parquet(filename, write_covering_bbox=True)
+    result1 = read_parquet(filename)
+    assert "bbox" not in result1
+
+    result2 = read_parquet(filename, columns=["name", "geometry"])
+    assert "bbox" not in result2
+    assert list(result2.columns) == ["name", "geometry"]
+
+
+@pytest.mark.parametrize(
+    "filters",
+    [
+        [("gdp_md_est", ">", 20000)],
+        pc.field("gdp_md_est") > 20000,
+    ],
+)
+def test_read_parquet_filters_and_bbox(tmpdir, naturalearth_lowres, filters):
+    df = read_file(naturalearth_lowres)
+    filename = os.path.join(str(tmpdir), "test.pq")
+    df.to_parquet(filename, write_covering_bbox=True)
+
+    result = read_parquet(filename, filters=filters, bbox=(0, 0, 20, 20))
+    assert result["name"].values.tolist() == [
+        "Dem. Rep. Congo",
+        "France",
+        "Nigeria",
+        "Cameroon",
+        "Ghana",
+        "Algeria",
+        "Libya",
+    ]
+
+
+@pytest.mark.parametrize(
+    "filters",
+    [
+        ([("gdp_md_est", ">", 15000), ("gdp_md_est", "<", 16000)]),
+        ((pc.field("gdp_md_est") > 15000) & (pc.field("gdp_md_est") < 16000)),
+    ],
+)
+def test_read_parquet_filters_without_bbox(tmpdir, naturalearth_lowres, filters):
+    df = read_file(naturalearth_lowres)
+    filename = os.path.join(str(tmpdir), "test.pq")
+    df.to_parquet(filename, write_covering_bbox=True)
+
+    result = read_parquet(filename, filters=filters)
+    assert result["name"].values.tolist() == ["Burkina Faso", "Mozambique", "Albania"]