Repository: geopandas/geopandas

Issue:
BUG: GeoDataFrames with MultiIndex as columns do not support CRS
- [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 master branch of geopandas.

---

#### Code Sample

```python
>>> import pandas as pd
>>> import geopandas
>>> df = pd.DataFrame([[1, 0], [0, 1]], columns=[['location', 'location'], ['x', 'y']])
>>> gdf = geopandas.GeoDataFrame(df, geometry=geopandas.points_from_xy(df['location', 'x'], df['location', 'y']))
>>> print(gdf.crs)
None
>>> gdf = geopandas.GeoDataFrame(df, crs="EPSG:4326", geometry=geopandas.points_from_xy(df['location', 'x'], df['location', 'y']))
>>> print(gdf.crs)
None
>>> gdf = geopandas.GeoDataFrame(df, geometry=geopandas.points_from_xy(df['location', 'x'], df['location', 'y'], crs="EPSG:4326"))
>>> print(gdf.crs)
None
>>> gdf = geopandas.GeoDataFrame(df, crs="EPSG:4326", geometry=geopandas.points_from_xy(df['location', 'x'], df['location', 'y'], crs="EPSG:4326"))
>>> print(gdf.crs)
None
>>> gdf = gdf.set_crs("EPSG:4326")
>>> print(gdf.crs)
None
```

#### Problem description

The first print-statement works as expected because I never specified a CRS, but the following 4 methods of setting the CRS should work. They seem to work for `pd.Index`, but not `pd.MultiIndex`. Since many `geopandas` operations like `sjoin` require a matching CRS, this causes problems for other operations as well. Obviously in this trivial example I don't _have_ to use a MultiIndex, but there are many applications where a MultiIndex is required.

#### Expected Output

I would expect the last 4 print-statements to show:
```python
>>> print(gdf.crs)
EPSG:4326
```

#### Output of ``geopandas.show_versions()``

<details>

SYSTEM INFO
-----------
python     : 3.8.7 (default, Dec 30 2020, 11:40:19)  [Clang 12.0.0 (clang-1200.0.32.28)]
executable : /Users/Adam/.spack/.spack-env/view/bin/python
machine    : macOS-10.15.7-x86_64-i386-64bit

GEOS, GDAL, PROJ INFO
---------------------
GEOS       : 3.8.1
GEOS lib   : /Users/Adam/spack/opt/spack/darwin-catalina-x86_64/apple-clang-12.0.0/geos-3.8.1-vlrmv4vvnmfcvabpu6t4boks5fxtllko/lib/libgeos_c.dylib
GDAL       : 3.2.0
GDAL data dir: /Users/Adam/.spack/.spack-env/view/share/gdal
PROJ       : 7.1.0
PROJ data dir: /Users/Adam/.spack/.spack-env/view/share/proj

PYTHON DEPENDENCIES
-------------------
geopandas  : 0.8.1
pandas     : 1.2.0
fiona      : 1.8.18
numpy      : 1.19.4
shapely    : 1.7.1
rtree      : None
pyproj     : 2.6.0
matplotlib : 3.3.3
mapclassify: 2.4.2
geopy      : 2.1.0
psycopg2   : None
geoalchemy2: None
pyarrow    : None

</details>

P.S. I'm happy to contribute a PR to fix this if someone can point out where the problem might be.

Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 90e028b..a2ea82c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -40,6 +40,7 @@ Bug fixes:
   Pyogrio if provided a URL as input (#2796)
 - Fix `copy()` downcasting GeoDataFrames without an active geometry column to a
   DataFrame (#2775)
+- Fix geometry column name propagation when GeoDataFrame columns are a multiindex (#2088)
 
 Notes on (optional) dependencies:
 
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index c8d80d4..83d7216 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -1444,6 +1444,18 @@ individually so that features may have different properties
         return a GeoDataFrame.
         """
         result = super().__getitem__(key)
+        # Custom logic to avoid waiting for pandas GH51895
+        # result is not geometry dtype for multi-indexes
+        if (
+            pd.api.types.is_scalar(key)
+            and key == ""
+            and isinstance(self.columns, pd.MultiIndex)
+            and isinstance(result, Series)
+            and not is_geometry_type(result)
+        ):
+            loc = self.columns.get_loc(key)
+            # squeeze stops multilevel columns from returning a gdf
+            result = self.iloc[:, loc].squeeze(axis="columns")
         geo_col = self._geometry_column_name
         if isinstance(result, Series) and isinstance(result.dtype, GeometryDtype):
             result.__class__ = GeoSeries
@@ -1452,8 +1464,6 @@ individually so that features may have different properties
                 result.__class__ = GeoDataFrame
                 if geo_col in result:
                     result._geometry_column_name = geo_col
-                else:
-                    result._geometry_column_name = None
             else:
                 result.__class__ = DataFrame
         return result


Test patch:
diff --git a/geopandas/tests/test_geodataframe.py b/geopandas/tests/test_geodataframe.py
index b4f6755..32172f1 100644
--- a/geopandas/tests/test_geodataframe.py
+++ b/geopandas/tests/test_geodataframe.py
@@ -342,7 +342,6 @@ class TestDataFrame:
     def test_get_geometry_invalid(self):
         df = GeoDataFrame()
         df["geom"] = self.df.geometry
-        msg_geo_col_none = "active geometry column to use has not been set. "
         msg_geo_col_missing = "is not present. "
 
         with pytest.raises(AttributeError, match=msg_geo_col_missing):
@@ -350,7 +349,7 @@ class TestDataFrame:
         df2 = self.df.copy()
         df2["geom2"] = df2.geometry
         df2 = df2[["BoroCode", "BoroName", "geom2"]]
-        with pytest.raises(AttributeError, match=msg_geo_col_none):
+        with pytest.raises(AttributeError, match=msg_geo_col_missing):
             df2.geometry
 
         msg_other_geo_cols_present = "There are columns with geometry data type"
@@ -1299,6 +1298,70 @@ class TestConstructor:
         result = gdf[["geometry"]]
         assert_frame_equal(result, gdf if dtype == "geometry" else pd.DataFrame(gdf))
 
+    def test_multiindex_geometry_colname_2_level(self):
+        # GH1763 https://github.com/geopandas/geopandas/issues/1763
+        crs = "EPSG:4326"
+        df = pd.DataFrame(
+            [[1, 0], [0, 1]], columns=[["location", "location"], ["x", "y"]]
+        )
+        x_col = df["location", "x"]
+        y_col = df["location", "y"]
+
+        gdf = GeoDataFrame(df, crs=crs, geometry=points_from_xy(x_col, y_col))
+        assert gdf.crs == crs
+        assert gdf.geometry.crs == crs
+        assert gdf.geometry.dtype == "geometry"
+        assert gdf._geometry_column_name == "geometry"
+        assert gdf.geometry.name == "geometry"
+
+    def test_multiindex_geometry_colname_3_level(self):
+        # GH1763 https://github.com/geopandas/geopandas/issues/1763
+        # Note 3-level case uses different code paths in pandas, it is not redundant
+        crs = "EPSG:4326"
+        df = pd.DataFrame(
+            [[1, 0], [0, 1]],
+            columns=[
+                ["foo", "foo"],
+                ["location", "location"],
+                ["x", "y"],
+            ],
+        )
+
+        x_col = df["foo", "location", "x"]
+        y_col = df["foo", "location", "y"]
+
+        gdf = GeoDataFrame(df, crs=crs, geometry=points_from_xy(x_col, y_col))
+        assert gdf.crs == crs
+        assert gdf.geometry.crs == crs
+        assert gdf.geometry.dtype == "geometry"
+        assert gdf._geometry_column_name == "geometry"
+        assert gdf.geometry.name == "geometry"
+
+    def test_multiindex_geometry_colname_3_level_new_col(self):
+        crs = "EPSG:4326"
+        df = pd.DataFrame(
+            [[1, 0], [0, 1]],
+            columns=[
+                ["foo", "foo"],
+                ["location", "location"],
+                ["x", "y"],
+            ],
+        )
+
+        x_col = df["foo", "location", "x"]
+        y_col = df["foo", "location", "y"]
+        df["geometry"] = GeoSeries.from_xy(x_col, y_col)
+        df2 = df.copy()
+        gdf = df.set_geometry("geometry", crs=crs)
+        assert gdf.crs == crs
+        assert gdf._geometry_column_name == "geometry"
+        assert gdf.geometry.name == "geometry"
+        # test again setting with tuple col name
+        gdf = df2.set_geometry(("geometry", "", ""), crs=crs)
+        assert gdf.crs == crs
+        assert gdf._geometry_column_name == ("geometry", "", "")
+        assert gdf.geometry.name == ("geometry", "", "")
+
 
 def test_geodataframe_crs():
     gdf = GeoDataFrame(columns=["geometry"])
diff --git a/geopandas/tests/test_op_output_types.py b/geopandas/tests/test_op_output_types.py
index ea2ed50..365d362 100644
--- a/geopandas/tests/test_op_output_types.py
+++ b/geopandas/tests/test_op_output_types.py
@@ -120,8 +120,8 @@ def test_getitem(df):
     assert_object(df[["value1", "value2"]], pd.DataFrame)
     assert_object(df[[geo_name, "geometry2"]], GeoDataFrame, geo_name)
     assert_object(df[[geo_name]], GeoDataFrame, geo_name)
-    assert_obj_no_active_geo_col(df[["geometry2", "value1"]], GeoDataFrame)
-    assert_obj_no_active_geo_col(df[["geometry2"]], GeoDataFrame)
+    assert_obj_no_active_geo_col(df[["geometry2", "value1"]], GeoDataFrame, geo_name)
+    assert_obj_no_active_geo_col(df[["geometry2"]], GeoDataFrame, geo_name)
     assert_object(df[["value1"]], pd.DataFrame)
     # Series
     assert_object(df[geo_name], GeoSeries, geo_name)
@@ -134,13 +134,10 @@ def test_loc(df):
     assert_object(df.loc[:, ["value1", "value2"]], pd.DataFrame)
     assert_object(df.loc[:, [geo_name, "geometry2"]], GeoDataFrame, geo_name)
     assert_object(df.loc[:, [geo_name]], GeoDataFrame, geo_name)
-    # These two are inconsistent with getitem, active geom col dropped,
-    # but other geometry columns present
     assert_obj_no_active_geo_col(
         df.loc[:, ["geometry2", "value1"]], GeoDataFrame, geo_name
     )
     assert_obj_no_active_geo_col(df.loc[:, ["geometry2"]], GeoDataFrame, geo_name)
-    # #####
     assert_object(df.loc[:, ["value1"]], pd.DataFrame)
     # Series
     assert_object(df.loc[:, geo_name], GeoSeries, geo_name)
@@ -153,11 +150,8 @@ def test_iloc(df):
     assert_object(df.iloc[:, 0:2], pd.DataFrame)
     assert_object(df.iloc[:, 2:4], GeoDataFrame, geo_name)
     assert_object(df.iloc[:, [2]], GeoDataFrame, geo_name)
-    # These two are inconsistent with getitem, active geom col dropped,
-    # but other geometry columns present
     assert_obj_no_active_geo_col(df.iloc[:, [3, 0]], GeoDataFrame, geo_name)
     assert_obj_no_active_geo_col(df.iloc[:, [3]], GeoDataFrame, geo_name)
-    # #####
     assert_object(df.iloc[:, [0]], pd.DataFrame)
     # Series
     assert_object(df.iloc[:, 2], GeoSeries, geo_name)
@@ -189,15 +183,12 @@ def test_reindex(df):
     assert_object(df.reindex(columns=[geo_name, "geometry2"]), GeoDataFrame, geo_name)
     assert_object(df.reindex(columns=[geo_name]), GeoDataFrame, geo_name)
     assert_object(df.reindex(columns=["new_col", geo_name]), GeoDataFrame, geo_name)
-    # These two are inconsistent with getitem, active geom col dropped,
-    # but other geometry columns present
     assert_obj_no_active_geo_col(
         df.reindex(columns=["geometry2", "value1"]), GeoDataFrame, geo_name
     )
     assert_obj_no_active_geo_col(
         df.reindex(columns=["geometry2"]), GeoDataFrame, geo_name
     )
-    # #####
     assert_object(df.reindex(columns=["value1"]), pd.DataFrame)
 
     # reindexing the rows always preserves the GeoDataFrame
@@ -216,15 +207,12 @@ def test_drop(df):
     assert_object(df.drop(columns=["value1", "value2"]), GeoDataFrame, geo_name)
     cols = ["value1", "value2", "geometry2"]
     assert_object(df.drop(columns=cols), GeoDataFrame, geo_name)
-    # These two are inconsistent with getitem, active geom col dropped,
-    # but other geometry columns present
     assert_obj_no_active_geo_col(
         df.drop(columns=[geo_name, "value2"]), GeoDataFrame, geo_name
     )
     assert_obj_no_active_geo_col(
         df.drop(columns=["value1", "value2", geo_name]), GeoDataFrame, geo_name
     )
-    # #####
     assert_object(df.drop(columns=["geometry2", "value2", geo_name]), pd.DataFrame)
 
 
@@ -238,10 +226,10 @@ def test_apply(df):
     assert_object(df[["value1", "value2"]].apply(identity), pd.DataFrame)
     assert_object(df[[geo_name, "geometry2"]].apply(identity), GeoDataFrame, geo_name)
     assert_object(df[[geo_name]].apply(identity), GeoDataFrame, geo_name)
-    expected_geo_col_name = None if compat.PANDAS_GE_14 else "geometry"
-    assert_obj_no_active_geo_col(
-        df[["geometry2", "value1"]].apply(identity), GeoDataFrame, expected_geo_col_name
-    )
+    expected_geo_col_name = geo_name if compat.PANDAS_GE_14 else "geometry"
+
+    res = df[["geometry2", "value1"]].apply(identity)
+    assert_obj_no_active_geo_col(res, GeoDataFrame, expected_geo_col_name)
     assert_obj_no_active_geo_col(
         df[["geometry2"]].apply(identity), GeoDataFrame, expected_geo_col_name
     )
@@ -272,12 +260,13 @@ def test_apply(df):
 
 
 def test_apply_axis1_secondary_geo_cols(df):
+    geo_name = df.geometry.name if compat.PANDAS_GE_14 else "geometry"
+
     def identity(x):
         return x
 
-    expected_geo_col_name = None if compat.PANDAS_GE_14 else "geometry"
     assert_obj_no_active_geo_col(
-        df[["geometry2"]].apply(identity, axis=1), GeoDataFrame, expected_geo_col_name
+        df[["geometry2"]].apply(identity, axis=1), GeoDataFrame, geo_name
     )