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).

Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e39da0a..f6ac18b 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 9b81697..887506d 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 632db49..73e8463 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")]