Repository: geopandas/geopandas

Issue:
ENH: check for non-overlapping bounding boxes in overlay and sjoin
In `clip` we have a quick check which immediately returns empty GeoDataFrame if left and right dfs are non-overlapping (their total bounds). We should do the same in `overlay` and `sjoin` to avoid building an expensive spatial index. 

This is the check in `clip`:

https://github.com/geopandas/geopandas/blob/cdb42821ababcf29b6f7f94dd66fa9cb5a8f6071/geopandas/tools/clip.py#L136-L145

See for use case https://github.com/geopandas/geopandas/issues/400#issuecomment-729899659 cc @zedrdave


Discussion:

Resolving patch:
diff --git a/geopandas/tools/overlay.py b/geopandas/tools/overlay.py
--- a/geopandas/tools/overlay.py
+++ b/geopandas/tools/overlay.py
@@ -264,6 +264,24 @@ def overlay(df1, df2, how="intersection", keep_geom_type=None, make_valid=True):
                 "df{} contains mixed geometry types.".format(i + 1)
             )
 
+    box_gdf1 = df1.total_bounds
+    box_gdf2 = df2.total_bounds
+
+    if not (
+        ((box_gdf1[0] <= box_gdf2[2]) and (box_gdf2[0] <= box_gdf1[2]))
+        and ((box_gdf1[1] <= box_gdf2[3]) and (box_gdf2[1] <= box_gdf1[3]))
+    ):
+        return GeoDataFrame(
+            [],
+            columns=list(
+                set(
+                    df1.drop(df1.geometry.name, axis=1).columns.to_list()
+                    + df2.drop(df2.geometry.name, axis=1).columns.to_list()
+                )
+            )
+            + ["geometry"],
+        )
+
     # Computations
     def _make_valid(df):
         df = df.copy()
diff --git a/geopandas/tools/sjoin.py b/geopandas/tools/sjoin.py
--- a/geopandas/tools/sjoin.py
+++ b/geopandas/tools/sjoin.py
@@ -86,6 +86,26 @@ def sjoin(
     """
     _basic_checks(left_df, right_df, how, lsuffix, rsuffix)
 
+    box_left_gdf = left_df.total_bounds
+    box_right_gdf = right_df.total_bounds
+
+    if not (
+        (
+            (box_left_gdf[0] <= box_right_gdf[2])
+            and (box_right_gdf[0] <= box_left_gdf[2])
+        )
+        and (
+            (box_left_gdf[1] <= box_right_gdf[3])
+            and (box_right_gdf[1] <= box_left_gdf[3])
+        )
+    ):
+        copy_df = left_df.copy()
+        copy_df["index_left"] = 0
+        copy_df["index_right"] = 0
+        indices = pd.DataFrame(columns=["_key_left", "_key_right"], dtype=float)
+        copy_df = _frame_join(indices, left_df, right_df, how, lsuffix, rsuffix)
+        return copy_df.iloc[:0]
+
     indices = _geom_predicate_query(left_df, right_df, op)
 
     joined = _frame_join(indices, left_df, right_df, how, lsuffix, rsuffix)


Test patch:
diff --git a/geopandas/tests/test_overlay.py b/geopandas/tests/test_overlay.py
--- a/geopandas/tests/test_overlay.py
+++ b/geopandas/tests/test_overlay.py
@@ -592,3 +592,15 @@ def test_overlap_make_valid(make_valid):
     else:
         with pytest.raises(ValueError, match="1 invalid input geometries"):
             overlay(df1, df_bowtie, make_valid=make_valid)
+
+
+def test_empty_overlay_return_non_duplicated_columns():
+
+    nybb = geopandas.read_file(geopandas.datasets.get_path("nybb"))
+    nybb2 = nybb.copy()
+    nybb2.geometry = nybb2.translate(20000000)
+
+    result = geopandas.overlay(nybb, nybb2)
+
+    assert all(result.columns.isin(nybb.columns))
+    assert len(result.columns) == len(nybb.columns)
diff --git a/geopandas/tools/tests/test_sjoin.py b/geopandas/tools/tests/test_sjoin.py
--- a/geopandas/tools/tests/test_sjoin.py
+++ b/geopandas/tools/tests/test_sjoin.py
@@ -472,6 +472,17 @@ def test_sjoin_no_valid_geoms(self, op):
         assert sjoin(empty, self.pointdf, how="inner", op=op).empty
         assert sjoin(empty, self.pointdf, how="left", op=op).empty
 
+    def test_empty_sjoin_return_duplicated_columns(self):
+
+        nybb = geopandas.read_file(geopandas.datasets.get_path("nybb"))
+        nybb2 = nybb.copy()
+        nybb2.geometry = nybb2.translate(200000)  # to get non-overlapping
+
+        result = geopandas.sjoin(nybb, nybb2)
+
+        assert "BoroCode_right" in result.columns
+        assert "BoroCode_left" in result.columns
+
 
 class TestSpatialJoinNaturalEarth:
     def setup_method(self):