Repository: geopandas/geopandas

Issue:
BUG: gdf.isna() with empty dataframe gives geometry dtype instead of bool dtype
From https://github.com/geopandas/geopandas/pull/3416#issuecomment-2322881896:

Reproducer:

```python
>>> geopandas.GeoDataFrame({"geometry": []}).isna().dtypes
geometry    geometry
dtype: object

# with differently named geometry column, it is fine
>>> geopandas.GeoDataFrame({"a": []}, geometry="a").isna().dtypes
a    bool
dtype: object
```

The reason for this is that we try to recreate a geodataframe, if there is a geometry column, at the end of methods, through:

https://github.com/geopandas/geopandas/blob/dc855b59608898d013e7bf5e9c2822b9635690a8/geopandas/geodataframe.py#L27-L38


In this case, it is the `df = GeoDataFrame(*args, **kwargs)` line that turns the bool dtype in the empty dataframe back to a geometry dtype. 

Maybe to fix such case, we could make the GeoDataFrame(..) constructor a bit stricter, i.e. we generally try to parse the column named "geometry" to a geometry array. But if the array has a specific dtype like bool, we should probably fail on that (even when the array is empty)


Discussion:

Patch:
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index d9125b1ddf..732efa911f 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -178,19 +178,28 @@ def __init__(self, data=None, *args, geometry=None, crs=None, **kwargs):
                 )
 
             # only if we have actual geometry values -> call set_geometry
-            try:
-                if (
-                    hasattr(self["geometry"].values, "crs")
-                    and self["geometry"].values.crs
-                    and crs
-                    and not self["geometry"].values.crs == crs
-                ):
-                    raise ValueError(crs_mismatch_error)
-                self["geometry"] = _ensure_geometry(self["geometry"].values, crs)
-            except TypeError:
-                pass
-            else:
-                geometry = "geometry"
+            if (
+                hasattr(self["geometry"].values, "crs")
+                and self["geometry"].values.crs
+                and crs
+                and not self["geometry"].values.crs == crs
+            ):
+                raise ValueError(crs_mismatch_error)
+            # If "geometry" is potentially coercible to geometry, we try and convert it
+            geom_dtype = self["geometry"].dtype
+            if (
+                geom_dtype == "geometry"  # noqa: PLR1714
+                or geom_dtype == "object"
+                # special case for geometry = [], has float dtype
+                or (len(self) == 0 and geom_dtype == "float")
+            ):
+                try:
+                    self["geometry"] = _ensure_geometry(self["geometry"].values, crs)
+                except TypeError:
+                    pass
+                else:
+                    # feed through to call set geometry below
+                    geometry = "geometry"
 
         if geometry is not None:
             if (


Test patch:
diff --git a/geopandas/tests/test_geodataframe.py b/geopandas/tests/test_geodataframe.py
index a123f66d54..383cb1b3b7 100644
--- a/geopandas/tests/test_geodataframe.py
+++ b/geopandas/tests/test_geodataframe.py
@@ -602,6 +602,14 @@ def test_no_geom_copy(self):
         assert type(df) is GeoDataFrame
         assert type(df.copy()) is GeoDataFrame
 
+    def test_empty(self):
+        df = GeoDataFrame({"geometry": []})
+        assert df.geometry.dtype == "geometry"
+        df = GeoDataFrame({"a": []}, geometry="a")
+        assert df.geometry.dtype == "geometry"
+        df = GeoDataFrame(geometry=[])
+        assert df.geometry.dtype == "geometry"
+
     def test_bool_index(self):
         # Find boros with 'B' in their name
         df = self.df[self.df["BoroName"].str.contains("B")]
diff --git a/geopandas/tests/test_pandas_methods.py b/geopandas/tests/test_pandas_methods.py
index 83d4c76703..d43f12752d 100644
--- a/geopandas/tests/test_pandas_methods.py
+++ b/geopandas/tests/test_pandas_methods.py
@@ -841,6 +841,19 @@ def test_pivot(df):
     assert_geodataframe_equal(result, expected)
 
 
+def test_isna_empty_dtypes():
+    # https://github.com/geopandas/geopandas/issues/3417
+    # should not auto coerce isna to geometry dtype
+    expected = pd.DataFrame({"geometry": []}).isna()
+    actual = GeoDataFrame({"geometry": []}).isna()
+    assert_frame_equal(expected, actual)
+
+    # different geometry col name
+    expected = pd.DataFrame({"a": []}).isna()
+    actual = GeoDataFrame({"a": []}, geometry="a").isna()
+    assert_frame_equal(expected, actual)
+
+
 def test_preserve_attrs(df):
     # https://github.com/geopandas/geopandas/issues/1654
     df.attrs["name"] = "my_name"