Repository: geopandas/geopandas

Issue:
Name of a GeoSeries lost when assigned as geometry in GeoDataFrame constructor
If I have a named GeoSeries and assign it as a geometry when creating a GeoDataFrame, that name is lost and I get back default 'geometry'. 

I thought there was an issue on this but I haven't found one so opening this one instead.

```py
import geopandas
import shapely

geopandas.GeoDataFrame(geometry=geopandas.GeoSeries([shapely.Point(0, 0)], name="my_name"))
```
```
                  geometry
0  POINT (0.00000 0.00000)
```

I guess we should check for existing name here before defaulting to `self._geometry_column_name` https://github.com/geopandas/geopandas/blob/d3d368641380a452fba21f32aeffd4a1e17e1901/geopandas/geodataframe.py#L316


Discussion:
Hmm, I would have thought we had tests for this. I would also expect the name to be preserved.
>I thought there was an issue on this but I haven't found one so opening this one instead.

This was discussed in #2312 but the main topic and title of the issue is something else. 
I guess the question here is if this is a bug fix or a breaking change? It is not the best thing to issue deprecation warnings for, we'd have to add a flag to the constructor I'd guess?
I expected this to work so in my perception this is a bug. 



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 254db87688..ea7a8a7fcb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -51,7 +51,12 @@ New features and improvements:
 
 Backwards incompatible API changes:
 - The deprecated default value of GeoDataFrame/ GeoSeries `explode(.., index_parts=True)` is now
-  set to false for consistency with pandas (#3174)
+  set to false for consistency with pandas (#3174).
+- The behaviour of `set_geometry` has been changed when passed a (Geo)Series `ser` with a name. 
+  The new active geometry column name in this case will be `ser.name`, if not None, rather than
+  the previous active geometry column name. This means that if the new and old names are
+  different, then both columns will be preserved in the GeoDataFrame. To replicate the previous
+  behaviour, you can instead call `gdf.set_geometry(ser.rename(gdf.active_geometry_name))` (#3237).
 
 Potentially breaking changes:
 
@@ -64,6 +69,8 @@ Bug fixes:
 - Fix `GeoDataFrame.merge()` incorrectly returning a `DataFrame` instead of a
   `GeoDataFrame` when the `suffixes` argument is applied to the active
   geometry column (#2933).
+- Fix bug in `GeoDataFrame` constructor where if `geometry` is given a named 
+  `GeoSeries` the name was not used as the active geometry column name (#3237).
 
 Deprecations and compatibility notes:
 
@@ -87,6 +94,16 @@ Deprecations and compatibility notes:
   for the default pyogrio engine. Currently those are translated to the ``columns`` keyword
   for backwards compatibility, but you should directly use the ``columns`` keyword instead
   to select which columns to read (#3133).
+- The `drop` keyword in `set_geometry` has been deprecated, and in future the `drop=True` 
+  behaviour will be removed. To prepare for this change, you should remove any explicit 
+  `drop=False` calls in your code (the default behaviour already is the same as `drop=False`).
+  To replicate the previous `drop=True` behaviour you should replace 
+  `gdf.set_geometry(new_geo_col, drop=True)` with
+  ```python
+  geo_col_name = gdf.active_geometry_name
+  gdf.set_geometry(new_geo_col).drop(columns=geo_col_name).rename_geometry(geo_col_name)
+  ```
+  (#3237).
 
 ## Version 0.14.3 (Jan 31, 2024)
 
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index 3364f2c6a2..7449b4e9fe 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -243,7 +243,7 @@ def _set_geometry(self, col):
         fget=_get_geometry, fset=_set_geometry, doc="Geometry data for GeoDataFrame"
     )
 
-    def set_geometry(self, col, drop=False, inplace=False, crs=None):
+    def set_geometry(self, col, drop=None, inplace=False, crs=None):
         """
         Set the GeoDataFrame geometry using either an existing column or
         the specified input. By default yields a new object.
@@ -252,9 +252,20 @@ def set_geometry(self, col, drop=False, inplace=False, crs=None):
 
         Parameters
         ----------
-        col : column label or array
+        col : column label or array-like
+            An existing column name or values to set as the new geometry column.
+            If values (array-like, (Geo)Series) are passed, then if they are named
+            (Series) the new geometry column will have the corresponding name,
+            otherwise the existing geometry column will be replaced. If there is
+            no existing geometry column, the new geometry column will use the
+            default name "geometry".
         drop : boolean, default False
-            Delete column to be used as the new geometry
+            When specifying a named Series or an existing column name for `col`,
+            controls if the previous geometry column should be dropped from the
+            result. The default of False keeps both the old and new geometry column.
+
+            .. deprecated:: 1.0.0
+
         inplace : boolean, default False
             Modify the GeoDataFrame in place (do not create a new object)
         crs : pyproj.CRS, optional
@@ -305,15 +316,25 @@ def set_geometry(self, col, drop=False, inplace=False, crs=None):
         else:
             frame = self.copy()
 
-        to_remove = None
         geo_column_name = self._geometry_column_name
+
         if geo_column_name is None:
             geo_column_name = "geometry"
         if isinstance(col, (Series, list, np.ndarray, GeometryArray)):
+            if drop:
+                msg = (
+                    "The `drop` keyword argument is deprecated and has no effect when "
+                    "`col` is an array-like value. You should stop passing `drop` to "
+                    "`set_geometry` when this is the case."
+                )
+                warnings.warn(msg, category=FutureWarning, stacklevel=2)
+            if isinstance(col, Series) and col.name is not None:
+                geo_column_name = col.name
+
             level = col
         elif hasattr(col, "ndim") and col.ndim > 1:
             raise ValueError("Must pass array with one dimension only.")
-        else:
+        else:  # should be a colname
             try:
                 level = frame[col]
             except KeyError:
@@ -324,24 +345,42 @@ def set_geometry(self, col, drop=False, inplace=False, crs=None):
                     "the column name is shared by multiple columns."
                 )
 
+            given_colname_drop_msg = (
+                "The `drop` keyword argument is deprecated and in future the only "
+                "supported behaviour will match drop=False. To silence this "
+                "warning and adopt the future behaviour, stop providing "
+                "`drop` as a keyword to `set_geometry`. To replicate the "
+                "`drop=True` behaviour you should update "
+                "your code to\n`geo_col_name = gdf.active_geometry_name;"
+                " gdf.set_geometry(new_geo_col).drop("
+                "columns=geo_col_name).rename_geometry(geo_col_name)`."
+            )
+
+            if drop is False:  # specifically False, not falsy i.e. None
+                # User supplied False explicitly, but arg is deprecated
+                warnings.warn(
+                    given_colname_drop_msg,
+                    category=FutureWarning,
+                    stacklevel=2,
+                )
             if drop:
-                to_remove = col
+                del frame[col]
+                warnings.warn(
+                    given_colname_drop_msg,
+                    category=FutureWarning,
+                    stacklevel=2,
+                )
             else:
+                # if not dropping, set the active geometry name to the given col name
                 geo_column_name = col
 
-        if to_remove:
-            del frame[to_remove]
-
         if not crs:
             crs = getattr(level, "crs", None)
 
-        if isinstance(level, (GeoSeries, GeometryArray)) and level.crs != crs:
-            # Avoids caching issues/crs sharing issues
-            level = level.copy()
-            level.crs = crs
-
         # Check that we are using a listlike of geometries
         level = _ensure_geometry(level, crs=crs)
+        # ensure_geometry only sets crs on level if it has crs==None
+        level.crs = crs
         # update _geometry_column_name prior to assignment
         # to avoid default is None warning
         frame._geometry_column_name = geo_column_name


Test patch:
diff --git a/geopandas/tests/test_geodataframe.py b/geopandas/tests/test_geodataframe.py
index d6353d189d..3c629914de 100644
--- a/geopandas/tests/test_geodataframe.py
+++ b/geopandas/tests/test_geodataframe.py
@@ -267,6 +267,9 @@ def test_set_geometry(self):
         with pytest.raises(ValueError):
             self.df.set_geometry(self.df)
 
+    def test_set_geometry_crs(self):
+        geom = GeoSeries([Point(x, y) for x, y in zip(range(5), range(5))])
+
         # new crs - setting should default to GeoSeries' crs
         gs = GeoSeries(geom, crs="epsg:3857")
         new_df = self.df.set_geometry(gs)
@@ -958,24 +961,24 @@ def test_estimate_utm_crs(self):
 
     def test_to_wkb(self):
         wkbs0 = [
-            (
+            (  # POINT (0 0)
                 b"\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00"
                 b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
-            ),  # POINT (0 0)
-            (
+            ),
+            (  # POINT (1 1)
                 b"\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00"
                 b"\x00\xf0?\x00\x00\x00\x00\x00\x00\xf0?"
-            ),  # POINT (1 1)
+            ),
         ]
         wkbs1 = [
-            (
+            (  # POINT (2 2)
                 b"\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00"
                 b"\x00\x00@\x00\x00\x00\x00\x00\x00\x00@"
-            ),  # POINT (2 2)
-            (
+            ),
+            (  # POINT (3 3)
                 b"\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00"
                 b"\x00\x08@\x00\x00\x00\x00\x00\x00\x08@"
-            ),  # POINT (3 3)
+            ),
         ]
         gs0 = GeoSeries.from_wkb(wkbs0)
         gs1 = GeoSeries.from_wkb(wkbs1)
@@ -1284,7 +1287,6 @@ def test_column_ordering(self):
         check_geodataframe(gdf)
         gdf.columns == ["geometry", "a"]
 
-    @pytest.mark.xfail
     def test_preserve_series_name(self):
         geoms = [Point(1, 1), Point(2, 2), Point(3, 3)]
         gs = GeoSeries(geoms)
@@ -1510,3 +1512,74 @@ def test_geodataframe_crs_colname():
     assert gdf.crs is None
     assert gdf["crs"].iloc[0] == 1
     assert getattr(gdf, "crs") is None
+
+
+@pytest.fixture
+def nybb2(nybb_filename):
+    yield read_file(nybb_filename).head(2)
+
+
+@pytest.mark.parametrize("geo_col_name", ["geometry", "polygons"])
+def test_set_geometry_supply_colname(nybb2, geo_col_name):
+    if geo_col_name != "geometry":
+        nybb2 = nybb2.rename_geometry(geo_col_name)
+    nybb2["centroid"] = nybb2.geometry.centroid
+    res = nybb2.set_geometry("centroid")
+    assert res.active_geometry_name == "centroid"
+    assert geo_col_name in res.columns
+
+    # Test that drop=False explicitly warns
+    deprecated = "The `drop` keyword argument is deprecated"
+    with pytest.warns(FutureWarning, match=deprecated):
+        res2 = nybb2.set_geometry("centroid", drop=False)
+    assert_geodataframe_equal(res, res2)
+
+    with pytest.warns(FutureWarning, match=deprecated):
+        res3 = nybb2.set_geometry("centroid", drop=True)
+    # drop=True should preserve previous geometry col name (keep old behaviour)
+    assert res3.active_geometry_name == geo_col_name
+    assert "centroid" not in res3.columns
+
+    # Test that alternative suggested without using drop=True is equivalent
+    assert_geodataframe_equal(
+        res3,
+        nybb2.set_geometry("centroid")
+        .drop(columns=geo_col_name)
+        .rename_geometry(geo_col_name),
+    )
+
+
+@pytest.mark.parametrize("geo_col_name", ["geometry", "polygons"])
+def test_set_geometry_supply_arraylike(nybb2, geo_col_name):
+    if geo_col_name != "geometry":
+        nybb2 = nybb2.rename_geometry(geo_col_name)
+    centroids = nybb2.geometry.centroid
+    res = nybb2.set_geometry(centroids)
+    assert res.active_geometry_name == geo_col_name
+    # drop should do nothing if the column already exists
+    match_str = (
+        "The `drop` keyword argument is deprecated and has no effect when "
+        "`col` is an array-like value"
+    )
+    with pytest.warns(
+        FutureWarning,
+        match=match_str,
+    ):
+        res2 = nybb2.set_geometry(centroids, drop=True)
+    assert res2.active_geometry_name == geo_col_name
+
+    centroids = centroids.rename("centroids")
+    res3 = nybb2.set_geometry(centroids)
+    # Should preserve the geoseries name
+    # (and old geometry column should be kept)
+    assert res3.active_geometry_name == "centroids"
+    assert geo_col_name in res3.columns
+
+    # Drop should not remove previous active geometry colname for arraylike inputs
+    with pytest.warns(
+        FutureWarning,
+        match=match_str,
+    ):
+        res4 = nybb2.set_geometry(centroids, drop=True)
+    assert res4.active_geometry_name == "centroids"
+    assert geo_col_name in res4.columns