Repository: geopandas/geopandas

Issue:
ENH: preserve relative order in `clip`
- [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.

- [ ] (optional) I have confirmed this bug exists on the main branch of geopandas.

---

I wasn't sure if this behavior is intended so I left the BUG label.

#### Code Sample

```python
>>> import geopandas as gpd
>>> import numpy as np
>>> import shapely
>>>
>>> z = np.array([5, 2, 4, 0, 3, 1])
>>> gdf = gpd.GeoSeries.from_xy(z, z).to_frame("geometry")
>>> bbox = shapely.geometry.box(0, 0, 2, 2)
>>> gdf
                  geometry
0  POINT (5.00000 5.00000)
1  POINT (2.00000 2.00000)
2  POINT (4.00000 4.00000)
3  POINT (0.00000 0.00000)
4  POINT (3.00000 3.00000)
5  POINT (1.00000 1.00000)

>>> gdf.clip(bbox)
                  geometry
3  POINT (0.00000 0.00000)
5  POINT (1.00000 1.00000)
1  POINT (2.00000 2.00000)
```

#### Problem description

Clipping a `GeoDataFrame` produces a result that has been shuffled. It looks like the result is sorted based on a spatial index. This is not noted in the documentation and was not what I expected to happen.

#### Expected Output

I expected clip to maintain the relative order in the result or for this behavior to be documented.

#### Output of ``geopandas.show_versions()``

<details>

SYSTEM INFO
-----------
python     : 3.9.16 | packaged by conda-forge | (main, Feb  1 2023, 21:39:03)  [GCC 11.3.0]
executable : /home/fred/anaconda3/envs/rstools/bin/python3.9
machine    : Linux-5.15.0-73-generic-x86_64-with-glibc2.35

GEOS, GDAL, PROJ INFO
---------------------
GEOS       : 3.11.2
GEOS lib   : None
GDAL       : 3.7.0
GDAL data dir: /home/fred/anaconda3/envs/rstools/share/gdal
PROJ       : 9.2.1
PROJ data dir: /home/fred/anaconda3/envs/rstools/share/proj

PYTHON DEPENDENCIES
-------------------
geopandas  : 0.13.2
numpy      : 1.24.3
pandas     : 2.0.2
pyproj     : 3.6.0
shapely    : 2.0.1
fiona      : 1.9.4
geoalchemy2: None
geopy      : None
matplotlib : 3.7.1
mapclassify: 2.5.0
pygeos     : 0.14
pyogrio    : 0.6.0
psycopg2   : None
pyarrow    : 12.0.0
rtree      : 1.0.1

</details>



Discussion:
Thanks for the report. It is not necessarily a bug but it may be nice to preserve the order. The result of the query on spatial index is not sorted and we don't do anything to sort it. We may consider that, though. It just may not be needed for a lot of use cases and would add unnecessary step. Sorting of a large array can be expensive.

```py
>>> gdf.sindex.query(bbox, predicate="intersects")
array([3, 5, 1])
```

The solution would be to sort the query output here https://github.com/geopandas/geopandas/blob/5dd0c963faa854ffbc42f68459dc13097b3f2e56/geopandas/tools/clip.py#L50
sindex seems to already have a sort function in it (technically not preserving the order if the data being fed is disordered).

Would it be worthwhile exposing the sort option as a kwarg in clip?

https://github.com/geopandas/geopandas/blob/5dd0c963faa854ffbc42f68459dc13097b3f2e56/geopandas/sindex.py#L47
> Would it be worthwhile exposing the sort option as a kwarg in clip?

If it solves the issue then it is a fairly minimal fix we should go for.


Thanks @martinfleis 

I've raised a PR which exposes the option to sort when clipping. Doesn't 100% address the request as they requested preservation of the input order, but I imagine sorting would achieve the same outcome 9/10 times.



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 124ed9a9f8..947dc05918 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -48,6 +48,8 @@ New features and improvements:
   the specified columns (#3101).
 - Added support to ``read_file`` for the ``mask`` keyword for the pyogrio engine (#3062).
 - Added support to ``read_file`` for the ``columns`` keyword for the fiona engine (#3133).
+- Add `sort` keyword to `clip` method for GeoSeries and GeoDataFrame to allow optional
+  preservation of the original order of observations. (#3233)
 - Added `show_bbox`, `drop_id` and `to_wgs84` arguments to allow further customization of
   `GeoSeries.to_json` (#3226)
 
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index d7885fae28..f25187bf88 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -2311,7 +2311,7 @@ def sjoin_nearest(
             exclusive=exclusive,
         )
 
-    def clip(self, mask, keep_geom_type=False):
+    def clip(self, mask, keep_geom_type=False, sort=False):
         """Clip points, lines, or polygon geometries to the mask extent.
 
         Both layers must be in the same Coordinate Reference System (CRS).
@@ -2334,6 +2334,10 @@ def clip(self, mask, keep_geom_type=False):
             If True, return only geometries of original type in case of intersection
             resulting in multiple geometry types or GeometryCollections.
             If False, return all resulting geometries (potentially mixed types).
+        sort : boolean, default False
+            If True, the order of rows in the clipped GeoDataFrame will be preserved at
+            small performance cost. If False the order of rows in the clipped
+            GeoDataFrame will be random.
 
         Returns
         -------
@@ -2364,7 +2368,7 @@ def clip(self, mask, keep_geom_type=False):
         >>> nws_groceries.shape
         (7, 8)
         """
-        return geopandas.clip(self, mask=mask, keep_geom_type=keep_geom_type)
+        return geopandas.clip(self, mask=mask, keep_geom_type=keep_geom_type, sort=sort)
 
     def overlay(self, right, how="intersection", keep_geom_type=None, make_valid=True):
         """Perform spatial overlay between GeoDataFrames.
diff --git a/geopandas/geoseries.py b/geopandas/geoseries.py
index 51a655a78a..60de699029 100644
--- a/geopandas/geoseries.py
+++ b/geopandas/geoseries.py
@@ -1306,7 +1306,7 @@ def to_wkt(self, **kwargs) -> Series:
         """
         return Series(to_wkt(self.array, **kwargs), index=self.index)
 
-    def clip(self, mask, keep_geom_type: bool = False) -> GeoSeries:
+    def clip(self, mask, keep_geom_type: bool = False, sort=False) -> GeoSeries:
         """Clip points, lines, or polygon geometries to the mask extent.
 
         Both layers must be in the same Coordinate Reference System (CRS).
@@ -1329,6 +1329,10 @@ def clip(self, mask, keep_geom_type: bool = False) -> GeoSeries:
             If True, return only geometries of original type in case of intersection
             resulting in multiple geometry types or GeometryCollections.
             If False, return all resulting geometries (potentially mixed-types).
+        sort : boolean, default False
+            If True, the order of rows in the clipped GeoSeries will be preserved
+            at small performance cost.
+            If False the order of rows in the clipped GeoSeries will be random.
 
         Returns
         -------
@@ -1359,4 +1363,4 @@ def clip(self, mask, keep_geom_type: bool = False) -> GeoSeries:
         >>> nws_groceries.shape
         (7,)
         """
-        return geopandas.clip(self, mask=mask, keep_geom_type=keep_geom_type)
+        return geopandas.clip(self, mask=mask, keep_geom_type=keep_geom_type, sort=sort)
diff --git a/geopandas/tools/clip.py b/geopandas/tools/clip.py
index 728ff0d7c5..267f5d517a 100644
--- a/geopandas/tools/clip.py
+++ b/geopandas/tools/clip.py
@@ -22,7 +22,7 @@ def _mask_is_list_like_rectangle(mask):
     )
 
 
-def _clip_gdf_with_mask(gdf, mask):
+def _clip_gdf_with_mask(gdf, mask, sort=False):
     """Clip geometry to the polygon/rectangle extent.
 
     Clip an input GeoDataFrame to the polygon extent of the polygon
@@ -36,6 +36,10 @@ def _clip_gdf_with_mask(gdf, mask):
     mask : (Multi)Polygon, list-like
         Reference polygon/rectangle for clipping.
 
+    sort : boolean, default False
+        If True, the results will be sorted in ascending order using the
+        geometries' indexes as the primary key.
+
     Returns
     -------
     GeoDataFrame
@@ -48,7 +52,9 @@ def _clip_gdf_with_mask(gdf, mask):
     else:
         intersection_polygon = mask
 
-    gdf_sub = gdf.iloc[gdf.sindex.query(intersection_polygon, predicate="intersects")]
+    gdf_sub = gdf.iloc[
+        gdf.sindex.query(intersection_polygon, predicate="intersects", sort=sort)
+    ]
 
     # For performance reasons points don't need to be intersected with poly
     non_point_mask = gdf_sub.geom_type != "Point"
@@ -82,7 +88,7 @@ def _clip_gdf_with_mask(gdf, mask):
     return clipped
 
 
-def clip(gdf, mask, keep_geom_type=False):
+def clip(gdf, mask, keep_geom_type=False, sort=False):
     """Clip points, lines, or polygon geometries to the mask extent.
 
     Both layers must be in the same Coordinate Reference System (CRS).
@@ -113,6 +119,9 @@ def clip(gdf, mask, keep_geom_type=False):
         If True, return only geometries of original type in case of intersection
         resulting in multiple geometry types or GeometryCollections.
         If False, return all resulting geometries (potentially mixed-types).
+    sort : boolean, default False
+        If True, the results will be sorted in ascending order using the
+        geometries' indexes as the primary key.
 
     Returns
     -------
@@ -190,7 +199,7 @@ def clip(gdf, mask, keep_geom_type=False):
     else:
         combined_mask = mask
 
-    clipped = _clip_gdf_with_mask(gdf, combined_mask)
+    clipped = _clip_gdf_with_mask(gdf, combined_mask, sort=sort)
 
     if keep_geom_type:
         geomcoll_concat = (clipped.geom_type == "GeometryCollection").any()


Test patch:
diff --git a/geopandas/tests/test_geodataframe.py b/geopandas/tests/test_geodataframe.py
index 67754248ce..4db468bdc6 100644
--- a/geopandas/tests/test_geodataframe.py
+++ b/geopandas/tests/test_geodataframe.py
@@ -1066,6 +1066,29 @@ def test_clip(self, naturalearth_cities, naturalearth_lowres):
         result = left.clip(south_america)
         assert_geodataframe_equal(result, expected)
 
+    def test_clip_sorting(self, naturalearth_cities, naturalearth_lowres):
+        """
+        Test sorting of geodataframe when clipping.
+        """
+        cities = read_file(naturalearth_cities)
+        world = read_file(naturalearth_lowres)
+        south_america = world[world["continent"] == "South America"]
+
+        unsorted_clipped_cities = geopandas.clip(cities, south_america, sort=False)
+        sorted_clipped_cities = geopandas.clip(cities, south_america, sort=True)
+
+        expected_sorted_index = pd.Index(
+            [55, 59, 62, 88, 101, 114, 122, 169, 181, 189, 210, 230, 236, 238, 239]
+        )
+
+        assert not (
+            sorted(unsorted_clipped_cities.index) == unsorted_clipped_cities.index
+        ).all()
+        assert (
+            sorted(sorted_clipped_cities.index) == sorted_clipped_cities.index
+        ).all()
+        assert_index_equal(expected_sorted_index, sorted_clipped_cities.index)
+
     def test_overlay(self, dfs, how):
         """
         Basic test for availability of the GeoDataFrame method. Other
diff --git a/geopandas/tests/test_geoseries.py b/geopandas/tests/test_geoseries.py
index 9ee5615025..df9d203a34 100644
--- a/geopandas/tests/test_geoseries.py
+++ b/geopandas/tests/test_geoseries.py
@@ -436,6 +436,29 @@ def test_clip(self, naturalearth_lowres, naturalearth_cities):
         result = left.geometry.clip(south_america)
         assert_geoseries_equal(result, expected)
 
+    def test_clip_sorting(self, naturalearth_cities, naturalearth_lowres):
+        """
+        Test sorting of geodseries when clipping.
+        """
+        cities = read_file(naturalearth_cities)
+        world = read_file(naturalearth_lowres)
+        south_america = world[world["continent"] == "South America"]
+
+        unsorted_clipped_cities = clip(cities, south_america, sort=False)
+        sorted_clipped_cities = clip(cities, south_america, sort=True)
+
+        expected_sorted_index = pd.Index(
+            [55, 59, 62, 88, 101, 114, 122, 169, 181, 189, 210, 230, 236, 238, 239]
+        )
+
+        assert not (
+            sorted(unsorted_clipped_cities.index) == unsorted_clipped_cities.index
+        ).all()
+        assert (
+            sorted(sorted_clipped_cities.index) == sorted_clipped_cities.index
+        ).all()
+        assert_index_equal(expected_sorted_index, sorted_clipped_cities.index)
+
     def test_from_xy_points(self):
         x = self.landmarks.x.values
         y = self.landmarks.y.values
diff --git a/geopandas/tools/tests/test_clip.py b/geopandas/tools/tests/test_clip.py
index 4092d67ae3..36d63f11c0 100644
--- a/geopandas/tools/tests/test_clip.py
+++ b/geopandas/tools/tests/test_clip.py
@@ -1,6 +1,7 @@
 """Tests for the clip module."""
 
 import numpy as np
+import pandas as pd
 
 import shapely
 from shapely.geometry import (
@@ -18,6 +19,7 @@
 from geopandas._compat import HAS_PYPROJ
 
 from geopandas.testing import assert_geodataframe_equal, assert_geoseries_equal
+from pandas.testing import assert_index_equal
 import pytest
 
 from geopandas.tools.clip import _mask_is_list_like_rectangle
@@ -42,6 +44,14 @@ def point_gdf():
     return gdf
 
 
+@pytest.fixture
+def point_gdf2():
+    """Create a point GeoDataFrame."""
+    pts = np.array([[5, 5], [2, 2], [4, 4], [0, 0], [3, 3], [1, 1]])
+    gdf = GeoDataFrame([Point(xy) for xy in pts], columns=["geometry"], crs="EPSG:3857")
+    return gdf
+
+
 @pytest.fixture
 def pointsoutside_nooverlap_gdf():
     """Create a point GeoDataFrame. Its points are all outside the single
@@ -460,3 +470,16 @@ def test_clip_empty_mask(buffered_locations, mask):
     )
     clipped = clip(buffered_locations.geometry, mask)
     assert_geoseries_equal(clipped, GeoSeries([], crs="EPSG:3857"))
+
+
+def test_clip_sorting(point_gdf2):
+    """Test the sorting kwarg in clip"""
+    bbox = shapely.geometry.box(0, 0, 2, 2)
+    unsorted_clipped_gdf = point_gdf2.clip(bbox)
+    sorted_clipped_gdf = point_gdf2.clip(bbox, sort=True)
+
+    expected_sorted_index = pd.Index([1, 3, 5])
+
+    assert not (sorted(unsorted_clipped_gdf.index) == unsorted_clipped_gdf.index).all()
+    assert (sorted(sorted_clipped_gdf.index) == sorted_clipped_gdf.index).all()
+    assert_index_equal(expected_sorted_index, sorted_clipped_gdf.index)