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:
Hey @joooeey, this is a consequence of #2060 where removing the geometry column from a geodataframe will return a dataframe instead. Where you construct an empty geodataframe as you have in your example and the geometry column was never present in the first place is noted specifically as #2130.

What you're seeing is not actually related to `deepcopy` at all, it's just an operation that triggers a call to `_constructor` inside pandas (for example `type(gpd.GeoDataFrame().assign(foo=4))` is a `pd.DataFrame`

Edit: in terms of a workaround for you right now, it might be helpful to know why you're generating empty GeoDataFrames (also, we have a decision to make in future about whether to even allow creating a geodataframe without any geometry, if you've got a use case to potentially support, that would be worth knowing about). 

Depending on how you're using the GeoDataFrame, you may be able to just call `set_geometry` which works on both dataframes and geodataframes (with geopandas imported of course) and will upcast the dataframe if it is downcast by the deepcopy.
I'm just using the null object pattern within a compound object that contains a GeoDataFrame. Usually that's a GeoDataFrame with geographic and other data. For this I have a workaround: Using `gpd.GeoDataFrame(columns=["geometry"])` as the null object.
However, it took me a long time to find out what the problem actually is. I definitely view this as a bug since deepcopy should output the same as the input. I'll elaborate in issue 2130 so this problem doesn't get lost.
On **Python 3.9** and Geopandas 0.9.0, all copy operations results in a `GeoDataFrame`. E.g.:
```
empty = gpd.GeoDataFrame()
copy.deepcopy(empty)  # -> GeoDataFrame
empty.copy(deep=True)  # -> GeoDataFrame
copy.copy(empty)  # -> GeoDataFrame
empty.copy(deep=False)  # -> GeoDataFrame
```

All versions:
<details>

```
SYSTEM INFO
-----------
python     : 3.9.16 (main, Jan 11 2023, 16:05:54)  [GCC 11.2.0]
executable : /home/lukas/anaconda3/envs/gpd_test/bin/python
machine    : Linux-5.4.0-135-generic-x86_64-with-glibc2.31

GEOS, GDAL, PROJ INFO
---------------------
GEOS       : 3.8.0
GEOS lib   : /home/lukas/anaconda3/envs/gpd_test/lib/libgeos_c.so
GDAL       : 3.0.2
GDAL data dir: /home/lukas/anaconda3/envs/gpd_test/share/gdal
PROJ       : 6.2.1
PROJ data dir: /home/lukas/anaconda3/envs/gpd_test/share/proj

PYTHON DEPENDENCIES
-------------------
geopandas  : 0.9.0
pandas     : 1.5.2
fiona      : 1.8.22
numpy      : 1.23.5
shapely    : 1.8.4
rtree      : 0.9.7
pyproj     : 2.6.1.post1
matplotlib : 3.6.2
mapclassify: 2.4.3
geopy      : None
psycopg2   : None
geoalchemy2: None
pyarrow    : None
pygeos     : None
```

</details>


Resolving patch:
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -1459,6 +1459,13 @@ def __setitem__(self, key, value):
     #
     # 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
--- a/geopandas/tests/test_geodataframe.py
+++ b/geopandas/tests/test_geodataframe.py
@@ -542,6 +542,19 @@ def test_copy(self):
         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")]