Repository: geopandas/geopandas

Issue:
BUG: copy of empty GeoDataFrame becomes Pandas DataFrame
- [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.

- [x] (optional) I have confirmed this bug exists on the main branch of geopandas.

---


#### Code Sample, a copy-pastable example

```python
import copy
import geopandas as gpd

empty = gpd.GeoDataFrame()
print("original plain:", type(empty))  # GeoDataFrame
print("copied plain:", type(copy.deepcopy(empty)))  # DataFrame !!!!!!!!!!!!!!!!!!

geom = gpd.GeoDataFrame(columns=["geometry"])
print("original with geometry:", type(geom))  # GeoDataFrame
print("copied with geometry:", type(copy.deepcopy(geom)))  # GeoDataFrame
```

#### Problem description

When I [deepcopy](https://docs.python.org/3/library/copy.html) a geopandas.GeoDataFrame without a "geometry" column, the copy becomes a pandas.DataFrame. I looked on the main branches on Github and neither Pandas nor Geopandas override `__deepcopy__`. So I have no idea why this happens.

Stackoverflow users have suggested the workaround of using `GeoDataFrame.copy(deep=True)` but that has three drawbacks:
- It's not a true deep copy. For object columns, individual elements are still passed by reference [instead of being copied](https://stackoverflow.com/questions/66318160/truly-deep-copying-pandas-dataframes).
- It's not applicable when the GeoDataFrame itself is part of a compound object.
- It doesn't work with my versions (see show_versions below).

#### Expected Output

Copying a GeoDataFrame should result in another GeoDataFrame, not a DataFrame.

#### Output of ``geopandas.show_versions()``

<details>

SYSTEM INFO
-----------
python     : 3.10.8 | packaged by conda-forge | (main, Nov 22 2022, 08:16:33) [MSC v.1929 64 bit (AMD64)]
executable : C:\Users\user\anaconda3\envs\rqa\python.exe
machine    : Windows-10-10.0.19045-SP0

GEOS, GDAL, PROJ INFO
---------------------
GEOS       : 3.11.1
GEOS lib   : None
GDAL       : None
GDAL data dir: None
PROJ       : 9.1.0
PROJ data dir: C:\Users\user\anaconda3\envs\rqa\Library\share\proj

PYTHON DEPENDENCIES
-------------------
geopandas  : 0.12.2
numpy      : 1.23.5
pandas     : 1.5.2
pyproj     : 3.4.1
shapely    : 2.0.0
fiona      : None
geoalchemy2: None
geopy      : None
matplotlib : 3.6.2
mapclassify: 2.4.3
pygeos     : None
pyogrio    : None
psycopg2   : None
pyarrow    : None
rtree      : 1.0.1

</details>

#### Source

I cross-posted some content from [StackOverflow](https://stackoverflow.com/questions/75173357/deep-copy-of-geodataframe-becomes-pandas-dataframe).

Discussion:
jorisvandenbossche: One additional corner case to consider: no columns, but with non-zero number of rows. Also such a case currently looses the class type:

```
In [3]: df = geopandas.GeoDataFrame(np.empty((10,0)))

In [4]: df
Out[4]: 
Empty GeoDataFrame
Columns: []
Index: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

In [5]: df.shape
Out[5]: (10, 0)

In [6]: df.copy()
Out[6]: 
Empty DataFrame
Columns: []
Index: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
```
jorisvandenbossche: Generally, I think I am fine with this special case for empty in `_constructor`, but we should maybe still consider overriding `copy()` anyway? 
Because it is still _allowed_ to explicitly create a GeoDataFrame without geometry column (even though we won't return it anymore ourselves as return value for operations), and also for that kind of dataframes the `copy()` will change class. If we want to keep to the rule that "copy" should never change the type, we might still need a custom override for that:

```
In [1]: df = geopandas.GeoDataFrame(pd.DataFrame({'a': [1, 2, 3]}))

In [2]: type(df)
Out[2]: geopandas.geodataframe.GeoDataFrame

In [3]: df._geometry_column_name
Out[3]: 'geometry'

In [4]: type(df.copy())
Out[4]: pandas.core.frame.DataFrame
```
m-richards: > Generally, I think I am fine with this special case for empty in `_constructor`, but we should maybe still consider overriding `copy()` anyway? Because it is still _allowed_ to explicitly create a GeoDataFrame without geometry column (even though we won't return it anymore ourselves as return value for operations), and also for that kind of dataframes the `copy()` will change class. If we want to keep to the rule that "copy" should never change the type, we might still need a custom override for that:


That does make sense, just not sure how to do it. In my first post is what I originally tried to override copy, this didn't pass through df.attrs (and I think something else but can't remember what now). 

I guess we could also alter `gdf.__class__` dynamically (and make sure geometry name and crs are preserved, but this feels like a hack. I will have a look and see.


jorisvandenbossche: > this didn't pass through df.attrs 

Sidenote: that seems something we should try to fix anyway for the `GeoDataFrame(..)` constructor



> I guess we could also alter `gdf.__class__` dynamically (and make sure geometry name and crs are preserved, but this feels like a hack. I will have a look and see.

It's not necessarily a hack (or it can be the "right" hack). Because actually using `GeoDataFrame(..)` inside `copy()` might not be the proper solution, as that will copy the dataframe again now (although, if we only do this for empty geodataframes, that probably doesn't matter much). 
It might be useful to have some internal helper method that converts a DataFrame to a GeoDataFrame with minimal overhead (without copying)
jorisvandenbossche: > There's no test failure but this snippet fails

And that snippet works on the main branch, so this PR is breaking that snippet?
jorisvandenbossche: And that's because the `GeoDataFrame(gdf)` constructor doesn't preserve `attrs`, and you now use this constructor in `copy()`.

Now, I was assuming this is a bug in our constructor, but it seems also the pandas.DataFrame constructor doesn't preserve attrs:

```
In [8]: df = pd.DataFrame({'a':[1,2,3]})
   ...: df.attrs['foo'] = 'bar'
   ...: 

In [9]: pd.DataFrame(df).attrs
Out[9]: {}

```
m-richards: 



> And that's because the `GeoDataFrame(gdf)` constructor doesn't preserve `attrs`, and you now use this constructor in `copy()` (and assigning the `__class__` instead would solve this?).

Yep, just had a look and you are right, using `__class__` instead makes a difference


> Now, I was assuming this is a bug in our constructor, but it seems also the pandas.DataFrame constructor doesn't preserve attrs:
> 
> ```
> In [8]: df = pd.DataFrame({'a':[1,2,3]})
>    ...: df.attrs['foo'] = 'bar'
>    ...: 
> 
> In [9]: pd.DataFrame(df).attrs
> Out[9]: {}
> ```

This is interesting, I was wondering how our attr preservation works at all then - given `_geodataframe_constructor_with_fallback` always passes through the GeoDataFrame constructor. Looking at our tests, attrs are dropped (seem to also be dropped with blockmanager inputs) but they are restored by `__finalize__`. So I suppose if pandas calls finalize everywhere it needs to, this won't materialize?

In the context of `copy` then, should the overload call finalize? - it calls finalize as part of the call to `super()` so I've just preserved `_geometry_column_name` explicitly. Technically this may also not be required as changing `__class__` sets `_geometry_column_name` to the class variable value (currently "geometry"), which should always be what the geometry column is set to in the case where the downcasting happens.


jorisvandenbossche: > In the context of `copy` then, should the overload call finalize?

Ah, yes, that might also work

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e39da0a7..f6ac18b0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,6 +35,8 @@ Bug fixes:
 - Fix `to_parquet`/`to_feather` to use correct WKB flavor for 3D geometries (#2654)
 - Fix `read_file` to avoid reading all file bytes prior to calling Fiona or
   Pyogrio if provided a URL as input (#2796)
+- Fix `copy()` downcasting GeoDataFrames without an active geometry column to a 
+  DataFrame (#2775)
 
 Notes on (optional) dependencies:
 
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index 9b816974..887506d3 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -1459,6 +1459,13 @@ individually so that features may have different properties
     #
     # Implement pandas methods
     #
+    @doc(pd.DataFrame)
+    def copy(self, deep=True):
+        copied = super().copy(deep=deep)
+        if type(copied) is pd.DataFrame:
+            copied.__class__ = GeoDataFrame
+            copied._geometry_column_name = self._geometry_column_name
+        return copied
 
     def merge(self, *args, **kwargs):
         r"""Merge two ``GeoDataFrame`` objects with a database-style join.


Test patch:
diff --git a/geopandas/tests/test_geodataframe.py b/geopandas/tests/test_geodataframe.py
index 632db49e..73e8463e 100644
--- a/geopandas/tests/test_geodataframe.py
+++ b/geopandas/tests/test_geodataframe.py
@@ -542,6 +542,19 @@ class TestDataFrame:
         assert type(df2) is GeoDataFrame
         assert self.df.crs == df2.crs
 
+    def test_empty_copy(self):
+        # https://github.com/geopandas/geopandas/issues/2765
+        df = GeoDataFrame()
+        df2 = df.copy()
+        assert type(df2) is GeoDataFrame
+        df3 = df.copy(deep=True)
+        assert type(df3) is GeoDataFrame
+
+    def test_no_geom_copy(self):
+        df = GeoDataFrame(pd.DataFrame({"a": [1, 2, 3]}))
+        assert type(df) is GeoDataFrame
+        assert type(df.copy()) is GeoDataFrame
+
     def test_bool_index(self):
         # Find boros with 'B' in their name
         df = self.df[self.df["BoroName"].str.contains("B")]