martinfleis: Good job! Changes looking good, tests cover both options being fixed, CI is happy. Merging. Thanks @ian-r-rose!
diff --git a/geopandas/tools/sjoin.py b/geopandas/tools/sjoin.py
index d7157405..0473f099 100644
--- a/geopandas/tools/sjoin.py
+++ b/geopandas/tools/sjoin.py
@@ -75,14 +75,15 @@ def sjoin(
         )
 
     # Attempt to re-use spatial indexes, otherwise generate the spatial index
-    # for the longer dataframe
+    # for the longer dataframe. If we are joining to an empty dataframe,
+    # don't bother generating the index.
     if right_df._sindex_generated or (
         not left_df._sindex_generated and right_df.shape[0] > left_df.shape[0]
     ):
-        tree_idx = right_df.sindex
+        tree_idx = right_df.sindex if len(left_df) > 0 else None
         tree_idx_right = True
     else:
-        tree_idx = left_df.sindex
+        tree_idx = left_df.sindex if len(right_df) > 0 else None
         tree_idx_right = False
 
     # the rtree spatial index only allows limited (numeric) index types, but an
@@ -120,8 +121,10 @@ def sjoin(
 
     r_idx = np.empty((0, 0))
     l_idx = np.empty((0, 0))
-    # get rtree spatial index
-    if tree_idx_right:
+    # get rtree spatial index. If tree_idx does not exist, it is due to either a
+    # failure to generate the index (e.g., if the column is empty), or the
+    # other dataframe is empty so it wasn't necessary to generate it.
+    if tree_idx_right and tree_idx:
         idxmatch = left_df.geometry.apply(lambda x: x.bounds).apply(
             lambda x: list(tree_idx.intersection(x)) if not x == () else []
         )
@@ -130,7 +133,7 @@ def sjoin(
         if idxmatch.shape[0] > 0:
             r_idx = np.concatenate(idxmatch.values)
             l_idx = np.concatenate([[i] * len(v) for i, v in idxmatch.iteritems()])
-    else:
+    elif not tree_idx_right and tree_idx:
         # tree_idx_df == 'left'
         idxmatch = right_df.geometry.apply(lambda x: x.bounds).apply(
             lambda x: list(tree_idx.intersection(x)) if not x == () else []

BUG: sjoin fails for empty dataframe
```
import geopandas as gpd

gdf = gpd.read_file(gpd.datasets.get_path('nybb'))
gpd.sjoin(gdf, gdf.head(0))

---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-23-a647daaec265> in <module>
----> 1 gpd.sjoin(gdf.head(0), gdf)

~/model/.venv/lib/python3.7/site-packages/geopandas/tools/sjoin.py in sjoin(left_df, right_df, how, op, lsuffix, rsuffix)
    126             lambda x: list(tree_idx.intersection(x)) if not x == () else []
    127         )
--> 128         idxmatch = idxmatch[idxmatch.apply(len) > 0]
    129         # indexes of overlapping boundaries
    130         if idxmatch.shape[0] > 0:

~/model/.venv/lib/python3.7/site-packages/pandas/core/ops/common.py in new_method(self, other)
     62         other = item_from_zerodim(other)
     63
---> 64         return method(self, other)
     65
     66     return new_method

~/model/.venv/lib/python3.7/site-packages/pandas/core/ops/__init__.py in wrapper(self, other)
    524         rvalues = extract_array(other, extract_numpy=True)
    525
--> 526         res_values = comparison_op(lvalues, rvalues, op)
    527
    528         return _construct_result(self, res_values, index=self.index, name=res_name)

~/model/.venv/lib/python3.7/site-packages/pandas/core/ops/array_ops.py in comparison_op(left, right, op)
    235
    236     if should_extension_dispatch(lvalues, rvalues):
--> 237         res_values = dispatch_to_extension_op(op, lvalues, rvalues)
    238
    239     elif is_scalar(rvalues) and isna(rvalues):

~/model/.venv/lib/python3.7/site-packages/pandas/core/ops/dispatch.py in dispatch_to_extension_op(op, left, right)
    123     # The op calls will raise TypeError if the op is not defined
    124     # on the ExtensionArray
--> 125     res_values = op(left, right)
    126     return res_values

TypeError: '>' not supported between instances of 'GeometryArray' and 'int'
```
Looks like the existing checks handle an empty geometry but not a totally empty row; would just wrapping this line in `if not idxmatch.empty:` be sufficient...?
diff --git a/geopandas/tools/tests/test_sjoin.py b/geopandas/tools/tests/test_sjoin.py
index 46309452..168f8983 100644
--- a/geopandas/tools/tests/test_sjoin.py
+++ b/geopandas/tools/tests/test_sjoin.py
@@ -186,7 +186,7 @@ class TestSpatialJoin:
         assert_frame_equal(res, exp)
 
     def test_empty_join(self):
-        # Check empty joins
+        # Check joins resulting in empty gdfs.
         polygons = geopandas.GeoDataFrame(
             {
                 "col2": [1, 2],
@@ -204,6 +204,32 @@ class TestSpatialJoin:
         empty = sjoin(not_in, polygons, how="inner", op="intersects")
         assert empty.empty
 
+    @pytest.mark.parametrize("op", ["intersects", "contains", "within"])
+    @pytest.mark.parametrize(
+        "empty",
+        [
+            GeoDataFrame(geometry=[GeometryCollection(), GeometryCollection()]),
+            GeoDataFrame(geometry=GeoSeries()),
+        ],
+    )
+    def test_join_with_empty(self, op, empty):
+        # Check joins with empty geometry columns/dataframes.
+        polygons = geopandas.GeoDataFrame(
+            {
+                "col2": [1, 2],
+                "geometry": [
+                    Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]),
+                    Polygon([(1, 0), (2, 0), (2, 1), (1, 1)]),
+                ],
+            }
+        )
+        result = sjoin(empty, polygons, how="left", op=op)
+        assert result.index_right.isnull().all()
+        result = sjoin(empty, polygons, how="right", op=op)
+        assert result.index_left.isnull().all()
+        result = sjoin(empty, polygons, how="inner", op=op)
+        assert result.empty
+
     @pytest.mark.parametrize("dfs", ["default-index", "string-index"], indirect=True)
     def test_sjoin_invalid_args(self, dfs):
         index, df1, df2, expected = dfs

pip install -e .[dev]
parse_log_pytest
requirements.txt
pytest black flake8 descartes matplotlib rtree
apt-get update apt-get install -y gcc
3.9
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
attrs==25.3.0
black==25.1.0
certifi==2025.1.31
click==8.1.8
click-plugins==1.1.1
cligj==0.7.2
contourpy==1.3.0
cycler==0.12.1
Cython==3.0.12
descartes==1.1.0
exceptiongroup==1.2.2
fiona==1.10.1
flake8==7.2.0
fonttools==4.56.0
-e git+https://github.com/geopandas/geopandas.git@a5c14c83514767aa3449d464c9e4af922cefbd28#egg=geopandas
importlib_metadata==8.6.1
importlib_resources==6.5.2
iniconfig==2.1.0
kiwisolver==1.4.7
matplotlib==3.9.4
mccabe==0.7.0
mypy-extensions==1.0.0
numpy==2.0.2
packaging==24.2
pandas==2.2.3
pathspec==0.12.1
pillow==11.1.0
platformdirs==4.3.7
pluggy==1.5.0
pycodestyle==2.13.0
pyflakes==3.3.2
pyparsing==3.2.3
pyproj==3.6.1
pytest==8.3.5
python-dateutil==2.9.0.post0
pytz==2025.2
rtree==1.4.0
shapely==2.0.7
six==1.17.0
tomli==2.2.1
typing_extensions==4.13.0
tzdata==2025.2
zipp==3.21.0

name: geopandas
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - attrs==25.3.0
      - black==25.1.0
      - certifi==2025.1.31
      - click==8.1.8
      - click-plugins==1.1.1
      - cligj==0.7.2
      - contourpy==1.3.0
      - cycler==0.12.1
      - cython==3.0.12
      - descartes==1.1.0
      - exceptiongroup==1.2.2
      - fiona==1.10.1
      - flake8==7.2.0
      - fonttools==4.56.0
      - importlib-metadata==8.6.1
      - importlib-resources==6.5.2
      - iniconfig==2.1.0
      - kiwisolver==1.4.7
      - matplotlib==3.9.4
      - mccabe==0.7.0
      - mypy-extensions==1.0.0
      - numpy==2.0.2
      - packaging==24.2
      - pandas==2.2.3
      - pathspec==0.12.1
      - pillow==11.1.0
      - platformdirs==4.3.7
      - pluggy==1.5.0
      - pycodestyle==2.13.0
      - pyflakes==3.3.2
      - pyparsing==3.2.3
      - pyproj==3.6.1
      - pytest==8.3.5
      - python-dateutil==2.9.0.post0
      - pytz==2025.2
      - rtree==1.4.0
      - shapely==2.0.7
      - six==1.17.0
      - tomli==2.2.1
      - typing-extensions==4.13.0
      - tzdata==2025.2
      - zipp==3.21.0
prefix: /opt/conda/envs/geopandas

geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_join_with_empty[empty1-intersects] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_join_with_empty[empty1-contains] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_join_with_empty[empty1-within]
geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_crs_mismatch[default-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_crs_mismatch[string-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_inner[intersects-default-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_inner[intersects-string-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_inner[intersects-named-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_inner[intersects-multi-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_inner[intersects-named-multi-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_inner[contains-default-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_inner[contains-string-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_inner[contains-named-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_inner[contains-multi-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_inner[contains-named-multi-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_inner[within-default-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_inner[within-string-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_inner[within-named-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_inner[within-multi-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_inner[within-named-multi-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_left[intersects-default-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_left[intersects-string-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_left[intersects-named-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_left[intersects-multi-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_left[intersects-named-multi-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_left[contains-default-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_left[contains-string-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_left[contains-named-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_left[contains-multi-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_left[contains-named-multi-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_left[within-default-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_left[within-string-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_left[within-named-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_left[within-multi-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_left[within-named-multi-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_empty_join geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_join_with_empty[empty0-intersects] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_join_with_empty[empty0-contains] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_join_with_empty[empty0-within] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_right[intersects-default-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_right[intersects-string-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_right[intersects-named-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_right[intersects-multi-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_right[intersects-named-multi-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_right[contains-default-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_right[contains-string-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_right[contains-named-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_right[contains-multi-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_right[contains-named-multi-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_right[within-default-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_right[within-string-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_right[within-named-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_right[within-multi-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_right[within-named-multi-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoinNYBB::test_geometry_name geopandas/tools/tests/test_sjoin.py::TestSpatialJoinNYBB::test_sjoin_left geopandas/tools/tests/test_sjoin.py::TestSpatialJoinNYBB::test_sjoin_right geopandas/tools/tests/test_sjoin.py::TestSpatialJoinNYBB::test_sjoin_inner geopandas/tools/tests/test_sjoin.py::TestSpatialJoinNYBB::test_sjoin_op geopandas/tools/tests/test_sjoin.py::TestSpatialJoinNYBB::test_sjoin_duplicate_column_name geopandas/tools/tests/test_sjoin.py::TestSpatialJoinNYBB::test_sjoin_named_index[left] geopandas/tools/tests/test_sjoin.py::TestSpatialJoinNYBB::test_sjoin_named_index[right] geopandas/tools/tests/test_sjoin.py::TestSpatialJoinNYBB::test_sjoin_named_index[inner] geopandas/tools/tests/test_sjoin.py::TestSpatialJoinNYBB::test_sjoin_values geopandas/tools/tests/test_sjoin.py::TestSpatialJoinNYBB::test_sjoin_empty_geometries geopandas/tools/tests/test_sjoin.py::TestSpatialJoinNaturalEarth::test_sjoin_inner
geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_sjoin_invalid_args[default-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoin::test_sjoin_invalid_args[string-index] geopandas/tools/tests/test_sjoin.py::TestSpatialJoinNYBB::test_sjoin_bad_op
geopandas/tools/sjoin.py
geopandas/tools/sjoin.py