Repository: geopandas/geopandas

Issue:
BUG: Cannot read GDB file using a geometry filter (mask)
- [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.

---

**Note**: Please read [this guide](https://matthewrocklin.com/minimal-bug-reports) detailing how to provide the necessary information for us to reproduce your bug.

#### Code Sample, a copy-pastable example

```python
gdf = gpd.read_file(
    example.gdb,
    driver='FileGDB',
    layer='example_layer',
    mask=mask_gdf,
```

#### Problem description

When running the above code using Geopandas v1.0.1, I got the following error: `ValueError: Must pass either crs or epsg.`.
The code above, however, works with v0.14.4

#### Expected Output

#### Output of ``geopandas.show_versions()``

<details>

SYSTEM INFO
-----------
python     : 3.10.12 (main, Sep 11 2024, 15:47:36) [GCC 11.4.0]
executable : /usr/bin/python3
machine    : Linux-6.1.85+-x86_64-with-glibc2.35

GEOS, GDAL, PROJ INFO
---------------------
GEOS       : 3.11.4
GEOS lib   : None
GDAL       : 3.9.1
GDAL data dir: /usr/local/lib/python3.10/dist-packages/pyogrio/gdal_data/
PROJ       : 9.4.1
PROJ data dir: /usr/local/lib/python3.10/dist-packages/pyproj/proj_dir/share/proj

PYTHON DEPENDENCIES
-------------------
geopandas  : 1.0.1
numpy      : 1.26.4
pandas     : 2.2.2
pyproj     : 3.7.0
shapely    : 2.0.6
pyogrio    : 0.10.0
geoalchemy2: None
geopy      : 2.4.1
matplotlib : 3.7.1
mapclassify: None
fiona      : 1.10.1
psycopg    : None
psycopg2   : 2.9.9 (dt dec pq3 ext lo64)
pyarrow    : 16.1.0

</details>

Discussion:
@martinfleis: Can you show the full traceback (error message) please?@shiyuning: Here it is:

---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-5-bb0d5cb569b5> in <cell line: 11>()
      9 
     10 print(boundary)
---> 11 _soil = gpd.read_file(
     12     GSSURGO(state),
     13     driver='FileGDB',

5 frames
/usr/local/lib/python3.10/dist-packages/geopandas/io/file.py in _read_file(filename, bbox, mask, columns, rows, engine, **kwargs)
    292 
    293     if engine == "pyogrio":
--> 294         return _read_file_pyogrio(
    295             filename, bbox=bbox, mask=mask, columns=columns, rows=rows, **kwargs
    296         )

/usr/local/lib/python3.10/dist-packages/geopandas/io/file.py in _read_file_pyogrio(path_or_bytes, bbox, mask, rows, **kwargs)
    499                 path_or_bytes.seek(0)
    500 
--> 501             mask = shapely.unary_union(mask.to_crs(crs).geometry.values)
    502         elif isinstance(mask, BaseGeometry):
    503             mask = shapely.unary_union(mask)

/usr/local/lib/python3.10/dist-packages/geopandas/geodataframe.py in to_crs(self, crs, epsg, inplace)
   1699         else:
   1700             df = self.copy()
-> 1701         geom = df.geometry.to_crs(crs=crs, epsg=epsg)
   1702         df.geometry = geom
   1703         if not inplace:

/usr/local/lib/python3.10/dist-packages/geopandas/geoseries.py in to_crs(self, crs, epsg)
   1205         """
   1206         return GeoSeries(
-> 1207             self.values.to_crs(crs=crs, epsg=epsg), index=self.index, name=self.name
   1208         )
   1209 

/usr/local/lib/python3.10/dist-packages/geopandas/_compat.py in wrapper(*args, **kwargs)
     88                 f"\nImporting pyproj resulted in: {pyproj_import_error}"
     89             )
---> 90         return func(*args, **kwargs)
     91 
     92     return wrapper

/usr/local/lib/python3.10/dist-packages/geopandas/array.py in to_crs(self, crs, epsg)
   1009             crs = CRS.from_epsg(epsg)
   1010         else:
-> 1011             raise ValueError("Must pass either crs or epsg.")
   1012 
   1013         # skip if the input CRS and output CRS are the exact same

ValueError: Must pass either crs or epsg.
@brendan-ward: This error happens when the source dataset does not have a CRS defined and you pass a mask as a GeoDataFrame, because we read the CRS from the source dataset and attempt to transform the mask to match it.

Can you confirm that the source file does not have a CRS defined?

```Python
from pyogrio import read_info

print(read_info(example.gdb).get('crs'))
# should be None for above errror
```

While we can check if the CRS is `None` and then not try to match the mask to it (provided the CRS of the mask is also `None`), that's about all we can do here; if the mask has a CRS but the source dataset does not, we'd have to (or should) raise an error about not being able to align them.

In this case, it looks like the mask GeoDataFrame has a CRS defined, or you'd get a different error.

To avoid this error, just pass in the mask as a Shapely geometry:

```Python
import shapely

gdf = gpd.read_file(
    example.gdb,
    driver='FileGDB',
    layer='example_layer',
    mask=shapely.union_all(mask_gdf.geometry.values),
```

Note that this only works if both the mask and source dataset are in the same coordinate system, or lack thereof; otherwise their coordinates will not necessarily line up.


@shiyuning: Yes, @brendan-ward , you are absolutely right. The source file does not have a CRS defined, and  your suggestion worked perfectly. Thank you very much for your help! I will close the issue since it is resolved.@jorisvandenbossche: Reopening this, because I think we can still provide an error message to essentially say to the user what has been discussed above ("specified mask has a CRS but the source dataset has not, pass the mask GeoDataFrame without a CRS as well or as a shapely Polygon")@iamabhinav-dev: I would love to work on this as I am a total beginner to open source. can you please assign it to me? thanks@martinfleis: @iamabhinav-dev this is already being resolved in #3464@RemoteCMeijer: First time contributor to the project, so I might be wrong, but I see that #3464 has been merged. Should this issue not be closed? 

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 313e16d6..9ca7beea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,8 @@ New features and improvements:
 - Add ``grid_size`` parameter to ``union_all`` and ``dissolve`` (#3445).
 - `GeoDataFrame.plot` now supports `pd.Index` as an input for the `column` keyword (#3463).
 - Avoid change of the plot aspect when plotting missing values (#3438).
+- A warning message is raised in `read_file` when a GeoDataFrame or GeoSeries mask
+  and/or the source dataset is missing a defined CRS. (#3464)
 - GeoDataFrame no longer hard-codes the class internally, allowing easier subclassing (#3505).
 - Improve performance of `overlay` with `how=identity` (#3504).
 - Fix ambiguous error when GeoDataFrame is initialised with a column called "crs" (#3502).
diff --git a/geopandas/io/file.py b/geopandas/io/file.py
index 20f06296..985a4df0 100644
--- a/geopandas/io/file.py
+++ b/geopandas/io/file.py
@@ -379,7 +379,8 @@ def _read_file_fiona(
 
     with fiona_env():
         with reader(path_or_bytes, **kwargs) as features:
-            crs = features.crs_wkt
+            crs = features.crs_wkt  # returns "" if empty
+            crs = crs or None
             # attempt to get EPSG code
             try:
                 # fiona 1.9+
@@ -402,7 +403,11 @@ def _read_file_fiona(
                 assert len(bbox) == 4
             # handle loading the mask
             elif isinstance(mask, GeoDataFrame | GeoSeries):
-                mask = mapping(mask.to_crs(crs).union_all())
+                if crs is not None and mask.crs is not None:
+                    mask = mask.to_crs(crs)
+                else:
+                    _warn_missing_crs_of_dataframe_and_mask(crs, mask)
+                mask = mapping(mask.union_all())
             elif isinstance(mask, BaseGeometry):
                 mask = mapping(mask)
 
@@ -515,10 +520,15 @@ def _read_file_pyogrio(path_or_bytes, bbox=None, mask=None, rows=None, **kwargs)
         # NOTE: mask cannot be used at same time as bbox keyword
         if isinstance(mask, GeoDataFrame | GeoSeries):
             crs = pyogrio.read_info(path_or_bytes, layer=kwargs.get("layer")).get("crs")
+            if crs is not None and mask.crs is not None:
+                mask = mask.to_crs(crs)
+            else:
+                _warn_missing_crs_of_dataframe_and_mask(crs, mask)
+
             if isinstance(path_or_bytes, IOBase):
                 path_or_bytes.seek(0)
 
-            mask = shapely.unary_union(mask.to_crs(crs).geometry.values)
+            mask = shapely.unary_union(mask.geometry.values)
         elif isinstance(mask, BaseGeometry):
             mask = shapely.unary_union(mask)
         elif isinstance(mask, dict) or hasattr(mask, "__geo_interface__"):
@@ -567,6 +577,25 @@ def _read_file_pyogrio(path_or_bytes, bbox=None, mask=None, rows=None, **kwargs)
     return pyogrio.read_dataframe(path_or_bytes, bbox=bbox, **kwargs)
 
 
+def _warn_missing_crs_of_dataframe_and_mask(source_dataset_crs, mask):
+    """
+    Warn if one, or both, of the source dataset or mask does not
+    have a crs.
+    """
+    if (source_dataset_crs is None) and (mask.crs is None):
+        msg = "There is no CRS defined in the source dataset nor mask. "
+    elif (source_dataset_crs is None) and (mask.crs is not None):
+        msg = "There is no CRS defined in the source dataset. "
+    else:  # crs not None and mask.crs is None
+        msg = "There is no CRS defined in the mask. "
+    msg += (
+        "This may lead to a misalignment of the mask and the "
+        "source dataset, leading to incorrect masking. Ensure "
+        "both inputs share the same CRS."
+    )
+    warnings.warn(msg, UserWarning, stacklevel=3)
+
+
 def _detect_driver(path):
     """
     Attempt to auto-detect driver based on the extension


Test patch:
diff --git a/geopandas/io/tests/test_file.py b/geopandas/io/tests/test_file.py
index e2a63702..3354501b 100644
--- a/geopandas/io/tests/test_file.py
+++ b/geopandas/io/tests/test_file.py
@@ -17,7 +17,7 @@ from pandas.api.types import is_datetime64_any_dtype
 from shapely.geometry import Point, Polygon, box, mapping
 
 import geopandas
-from geopandas import GeoDataFrame, read_file
+from geopandas import GeoDataFrame, GeoSeries, points_from_xy, read_file
 from geopandas._compat import HAS_PYPROJ, PANDAS_GE_30
 from geopandas.io.file import _EXTENSION_TO_DRIVER, _detect_driver
 
@@ -702,6 +702,84 @@ def test_read_text_file_fsspec(file_path, engine):
         assert isinstance(gdf, geopandas.GeoDataFrame)
 
 
+@pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not installed")
+def test_read_file_crs_warning_messages(engine):
+    """
+    Testing warning messages when one of, or both of, the
+    file or mask is missing a crs.
+    """
+    file_with_crs = os.path.join(tempfile.mkdtemp(), "file_with_crs.shp")
+    file_without_crs = os.path.join(tempfile.mkdtemp(), "file_without_crs.shp")
+
+    df = pd.DataFrame(
+        {
+            "Latitude": [0, 1, 2, 3, 4],
+            "Longitude": [0, 1, 2, 3, 4],
+        }
+    )
+
+    gdf_with_crs = GeoDataFrame(
+        df, geometry=points_from_xy(df.Longitude, df.Latitude), crs="EPSG:4326"
+    )
+    gdf_without_crs = GeoDataFrame(
+        df, geometry=points_from_xy(df.Longitude, df.Latitude)
+    )
+
+    gdf_with_crs.to_file(file_with_crs)
+    gdf_without_crs.to_file(file_without_crs)
+
+    with pytest.warns(UserWarning, match="""There is no CRS defined in the mask."""):
+        data = read_file(file_with_crs, mask=gdf_without_crs, engine=engine)
+        assert_geodataframe_equal(data, gdf_with_crs)
+
+    with pytest.warns(
+        UserWarning, match="""There is no CRS defined in the source dataset."""
+    ):
+        data = read_file(file_without_crs, mask=gdf_with_crs, engine=engine)
+        assert_geodataframe_equal(data, gdf_without_crs)
+
+    with pytest.warns(
+        UserWarning,
+        match="""There is no CRS defined in the source dataset nor mask.""",
+    ):
+        data = read_file(file_without_crs, mask=gdf_without_crs, engine=engine)
+        assert_geodataframe_equal(data, gdf_without_crs)
+
+    with warnings.catch_warnings():
+        warnings.simplefilter("error")
+        data = read_file(file_with_crs, mask=gdf_with_crs, engine=engine)
+        assert_geodataframe_equal(data, gdf_with_crs)
+
+    # Check with GeoSeries as a mask
+    series_with_crs = GeoSeries(
+        points_from_xy(df.Longitude, df.Latitude), crs="EPSG:4326"
+    )
+
+    series_without_crs = GeoSeries(points_from_xy(df.Longitude, df.Latitude))
+
+    with pytest.warns(UserWarning, match="""There is no CRS defined in the mask."""):
+        data = read_file(file_with_crs, mask=series_without_crs, engine=engine)
+        assert_geodataframe_equal(data, gdf_with_crs)
+
+    with pytest.warns(
+        UserWarning, match="""There is no CRS defined in the source dataset."""
+    ):
+        data = read_file(file_without_crs, mask=series_with_crs, engine=engine)
+        assert_geodataframe_equal(data, gdf_without_crs)
+
+    with pytest.warns(
+        UserWarning,
+        match="""There is no CRS defined in the source dataset nor mask.""",
+    ):
+        data = read_file(file_without_crs, mask=series_without_crs, engine=engine)
+        assert_geodataframe_equal(data, gdf_without_crs)
+
+    with warnings.catch_warnings():
+        warnings.simplefilter("error")
+        data = read_file(file_with_crs, mask=series_with_crs, engine=engine)
+        assert_geodataframe_equal(data, gdf_with_crs)
+
+
 def test_infer_zipped_file(engine, nybb_filename):
     # Remove the zip scheme so that the test for a zipped file can
     # check it and add it back.