Repository: geopandas/geopandas
Pull request #3206 (2024-03-02 22:24:54)

DOC: add migration guide for fiona to pyogrio
As mentioned in the community meeting... a first step in documenting some differences between Fiona and Pyogrio.

martinfleis: @theroggy can we try to finish and merge this before the alpha release hits so we can point people at the migration guide when needed?
theroggy: > @theroggy can we try to finish and merge this before the alpha release hits so we can point people at the migration guide when needed?

OK, I'll apply the feedback this weekend...
theroggy: > Thanks @theroggy for starting on this!
> 
> You probably know better than me, is there also a difference with the handling of nulls worth mentioning? (from memory pyogrio will handle writing nullable ints, but fiona would turn these into floats? but this may only be with use_arrow=True) I recall checking something about this on an issue, but I can't find it now.

I'm not aware (anymore) of a difference regarding this...
theroggy: The docstring tests are failing on a pyarrow issue... but I don't import pyarrow in the new docs, and don't really understand what is going wrong?
martinfleis: > but I don't import pyarrow in the new docs, and don't really understand what is going wrong?

Ignore that - https://github.com/apache/arrow/issues/41058

Patch:
diff --git a/.github/workflows/versioneer.yml b/.github/workflows/versioneer.yml
index 38283c44..26858177 100644
--- a/.github/workflows/versioneer.yml
+++ b/.github/workflows/versioneer.yml
@@ -12,6 +12,7 @@ on:
 jobs:
   versioneer:
     runs-on: ubuntu-latest
+    if: github.repository == 'geopandas/geopandas'
     steps:
       - uses: actions/checkout@v4
       - uses: actions/setup-python@v5
@@ -19,10 +20,10 @@ jobs:
         run: |
           pip install versioneer
           versioneer install
-      - name: Blacken code
-        uses: psf/black@stable
-        with:
-          options: "--verbose"
+      - name: Run pre-commit
+        run: |
+          pip install pre-commit
+          pre-commit run --all-files
       - name: Upload artifact
         uses: actions/upload-artifact@v4
         with:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 929e5aff..124ed9a9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,6 @@
 # Changelog
 
-## Development version
+## Version 1.0.0-alpha1 (Apr 13, 2024)
 
 Notes on dependencies:
 
@@ -48,10 +48,18 @@ 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).
+- Added `show_bbox`, `drop_id` and `to_wgs84` arguments to allow further customization of
+  `GeoSeries.to_json` (#3226)
 
 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 +72,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:
 
@@ -92,6 +102,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 (#3237). 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)
+  ```
 
 ## Version 0.14.3 (Jan 31, 2024)
 
diff --git a/doc/environment.yml b/doc/environment.yml
index dd0733a5..eea05d5e 100644
--- a/doc/environment.yml
+++ b/doc/environment.yml
@@ -7,6 +7,7 @@ dependencies:
   - shapely
   - fiona
   - pyproj
+  - pyogrio
   - geopy
   - matplotlib
   - mapclassify
diff --git a/doc/source/docs/advanced_guide.rst b/doc/source/docs/advanced_guide.rst
index f6e54206..89738d67 100644
--- a/doc/source/docs/advanced_guide.rst
+++ b/doc/source/docs/advanced_guide.rst
@@ -19,3 +19,4 @@ specification in the :doc:`API reference <reference>`.
   user_guide/missing_empty
   user_guide/reproject_fiona
   user_guide/pygeos_to_shapely
+  user_guide/fiona_to_pyogrio
diff --git a/doc/source/docs/reference/io.rst b/doc/source/docs/reference/io.rst
index f5d77aed..8c5a8ac9 100644
--- a/doc/source/docs/reference/io.rst
+++ b/doc/source/docs/reference/io.rst
@@ -8,6 +8,7 @@ GIS vector files
 .. autosummary::
    :toctree: api/
 
+   list_layers
    read_file
    GeoDataFrame.to_file
 
diff --git a/doc/source/docs/user_guide/fiona_to_pyogrio.rst b/doc/source/docs/user_guide/fiona_to_pyogrio.rst
new file mode 100644
index 00000000..97869489
--- /dev/null
+++ b/doc/source/docs/user_guide/fiona_to_pyogrio.rst
@@ -0,0 +1,72 @@
+.. currentmodule:: geopandas
+
+.. ipython:: python
+   :suppress:
+
+   import geopandas
+
+Migration from the Fiona to the Pyogrio read/write engine
+=========================================================
+
+Since version 0.11, GeoPandas started supporting two engines to read and write files:
+`Fiona <https://fiona.readthedocs.io>`__ and
+`Pyogrio <https://pyogrio.readthedocs.io>`__.
+
+It became possible to choose the engine using the ``engine=`` parameter in
+:func:`geopandas.read_file` and :func:`geopandas.GeoDataFrame.to_file`. It became also
+possible to change the default engine globally with:
+
+.. code-block:: python
+
+    geopandas.options.io_engine = "pyogrio"
+
+For Geopandas versions <1.0, GeoPandas defaulted to use Fiona. Starting from GeoPandas
+version 1.0, the global default has changed from Fiona to Pyogrio.
+
+The main reason for this change is performance. Pyogrio is optimized for the use case
+relevant for GeoPandas: reading and writing in bulk. Because of this, in many cases
+speedups >5-20x can be observed.
+
+This guide outlines the (known) functional differences between both, so you can account
+for them when switching to Pyogrio.
+
+
+Write an attribute table to a file
+----------------------------------
+
+Using the Fiona engine, it was possible to write an attribute table (a table without
+geometry column) to a file using the ``schema`` parameter to specify that the "geometry"
+column of a GeoDataFrame should be ignored.
+
+With Pyogrio you can write an attribute table by using :func:`pyogrio.write_dataframe`
+and passing a pandas DataFrame to it:
+
+.. code-block:: python
+
+    >>> import pyogrio
+    >>> df = pd.DataFrame({"data_column": [1, 2, 3]})
+    >>> pyogrio.write_dataframe(df, "test_attribute_table.gpkg")
+
+
+No support for ``schema`` parameter to write files
+--------------------------------------------------
+
+Pyogrio does not support specifying the `schema` parameter to write files. This means
+it is not possible to specify the types of attributes being written explicitly.
+
+
+Writing EMPTY geometries
+------------------------
+
+Pyogrio writes EMPTY and None geometries as such to e.g. GPKG files, Fiona writes both
+as None.
+
+.. ipython:: python
+
+    import shapely
+    
+    gdf = geopandas.GeoDataFrame(geometry=[shapely.Polygon(), None], crs=31370)
+    gdf.to_file("test_fiona.gpkg", engine="fiona")
+    gdf.to_file("test_pyogrio.gpkg", engine="pyogrio")
+    geopandas.read_file("test_fiona.gpkg").head()
+    geopandas.read_file("test_pyogrio.gpkg").head()
diff --git a/doc/source/docs/user_guide/io.rst b/doc/source/docs/user_guide/io.rst
index 73f613c7..2e659a39 100644
--- a/doc/source/docs/user_guide/io.rst
+++ b/doc/source/docs/user_guide/io.rst
@@ -32,6 +32,9 @@ the ``layer`` keyword::
 
     countries_gdf = geopandas.read_file("package.gpkg", layer='countries')
 
+If you have a file with multiple layers, you can list them using
+:func:`geopandas.list_layers`. Note that this function requires Pyogrio.
+
 GeoPandas can also load resources directly from
 a web URL, for example for GeoJSON files from `geojson.xyz <http://geojson.xyz/>`_::
 
diff --git a/geopandas/__init__.py b/geopandas/__init__.py
index bbe1b445..42994e56 100644
--- a/geopandas/__init__.py
+++ b/geopandas/__init__.py
@@ -5,6 +5,7 @@ from geopandas.geodataframe import GeoDataFrame
 from geopandas.array import points_from_xy
 
 from geopandas.io.file import _read_file as read_file
+from geopandas.io.file import _list_layers as list_layers
 from geopandas.io.arrow import _read_parquet as read_parquet
 from geopandas.io.arrow import _read_feather as read_feather
 from geopandas.io.sql import _read_postgis as read_postgis
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index 3364f2c6..7449b4e9 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -243,7 +243,7 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
         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 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
 
         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 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
         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 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
                     "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
diff --git a/geopandas/geoseries.py b/geopandas/geoseries.py
index a9523d9d..51a655a7 100644
--- a/geopandas/geoseries.py
+++ b/geopandas/geoseries.py
@@ -1,6 +1,5 @@
 from __future__ import annotations
 
-import json
 import typing
 from typing import Optional, Any, Callable, Dict
 import warnings
@@ -1190,12 +1189,31 @@ class GeoSeries(GeoPandasBase, Series):
         """
         return self.values.estimate_utm_crs(datum_name)
 
-    def to_json(self, **kwargs) -> str:
+    def to_json(
+        self,
+        show_bbox: bool = True,
+        drop_id: bool = False,
+        to_wgs84: bool = False,
+        **kwargs,
+    ) -> str:
         """
         Returns a GeoJSON string representation of the GeoSeries.
 
         Parameters
         ----------
+        show_bbox : bool, optional, default: True
+            Include bbox (bounds) in the geojson
+        drop_id : bool, default: False
+            Whether to retain the index of the GeoSeries as the id property
+            in the generated GeoJSON. Default is False, but may want True
+            if the index is just arbitrary row numbers.
+        to_wgs84: bool, optional, default: False
+            If the CRS is set on the active geometry column it is exported as
+            WGS84 (EPSG:4326) to meet the `2016 GeoJSON specification
+            <https://tools.ietf.org/html/rfc7946>`_.
+            Set to True to force re-projection and set to False to ignore CRS. False by
+            default.
+
         *kwargs* that will be passed to json.dumps().
 
         Returns
@@ -1224,7 +1242,9 @@ e": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [3
         --------
         GeoSeries.to_file : write GeoSeries to file
         """
-        return json.dumps(self.__geo_interface__, **kwargs)
+        return self.to_frame("geometry").to_json(
+            na="null", show_bbox=show_bbox, drop_id=drop_id, to_wgs84=to_wgs84, **kwargs
+        )
 
     def to_wkb(self, hex: bool = False, **kwargs) -> Series:
         """
diff --git a/geopandas/io/file.py b/geopandas/io/file.py
index f4d8262b..aff36857 100644
--- a/geopandas/io/file.py
+++ b/geopandas/io/file.py
@@ -796,3 +796,34 @@ def _geometry_types(df):
         geom_types = geom_types[0]
 
     return geom_types
+
+
+def _list_layers(filename) -> pd.DataFrame:
+    """List layers available in a file.
+
+    Provides an overview of layers available in a file or URL together with their
+    geometry types. When supported by the data source, this includes both spatial and
+    non-spatial layers. Non-spatial layers are indicated by the ``"geometry_type"``
+    column being ``None``. GeoPandas will not read such layers but they can be read into
+    a pd.DataFrame using :func:`pyogrio.read_dataframe`.
+
+    Parameters
+    ----------
+    filename : str, path object or file-like object
+        Either the absolute or relative path to the file or URL to
+        be opened, or any object with a read() method (such as an open file
+        or StringIO)
+
+    Returns
+    -------
+    pandas.DataFrame
+        A DataFrame with columns "name" and "geometry_type" and one row per layer.
+    """
+    _import_pyogrio()
+    _check_pyogrio("list_layers")
+
+    import pyogrio
+
+    return pd.DataFrame(
+        pyogrio.list_layers(filename), columns=["name", "geometry_type"]
+    )
diff --git a/geopandas/tools/sjoin.py b/geopandas/tools/sjoin.py
index 9ac00d7f..20bbbc7f 100644
--- a/geopandas/tools/sjoin.py
+++ b/geopandas/tools/sjoin.py
@@ -196,6 +196,9 @@ def _geom_predicate_query(left_df, right_df, predicate, distance):
         # within is implemented as the inverse of contains
         # flip back the results
         r_idx, l_idx = l_idx, r_idx
+        indexer = np.lexsort((r_idx, l_idx))
+        l_idx = l_idx[indexer]
+        r_idx = r_idx[indexer]
 
     return l_idx, r_idx
 
@@ -319,14 +322,6 @@ def _adjust_indexers(indices, distances, original_length, how, predicate):
     """
     # the indices represent an inner join, no adjustment needed
     if how == "inner":
-        if predicate == "within":
-            # except for the within predicate, where we switched to contains
-            # with swapped left/right -> need to re-sort to have consistent result
-            l_idx, r_idx = indices
-            indexer = np.lexsort((r_idx, l_idx))
-            indices = l_idx[indexer], r_idx[indexer]
-            if distances is not None:
-                distances = distances[indexer]
         return indices, distances
 
     l_idx, r_idx = indices


Test patch:
diff --git a/geopandas/io/tests/test_file.py b/geopandas/io/tests/test_file.py
index cd1475fd..efd09ad5 100644
--- a/geopandas/io/tests/test_file.py
+++ b/geopandas/io/tests/test_file.py
@@ -1304,3 +1304,24 @@ def test_option_io_engine(nybb_filename):
     finally:
         fiona.supported_drivers["ESRI Shapefile"] = orig
         geopandas.options.io_engine = None
+
+
+@PYOGRIO_MARK
+def test_list_layers(df_points, tmpdir):
+    tempfilename = os.path.join(str(tmpdir), "dataset.gpkg")
+    df_points.to_file(tempfilename, layer="original")
+    df_points.set_geometry(df_points.buffer(1)).to_file(tempfilename, layer="buffered")
+    df_points.set_geometry(df_points.buffer(2).boundary).to_file(
+        tempfilename, layer="boundary"
+    )
+    pyogrio.write_dataframe(
+        df_points[["value1", "value2"]], tempfilename, layer="non-spatial"
+    )
+    layers = geopandas.list_layers(tempfilename)
+    expected = pd.DataFrame(
+        {
+            "name": ["original", "buffered", "boundary", "non-spatial"],
+            "geometry_type": ["Point", "Polygon", "LineString", None],
+        }
+    )
+    assert_frame_equal(layers, expected)
diff --git a/geopandas/tests/test_geodataframe.py b/geopandas/tests/test_geodataframe.py
index d6353d18..c0c7141a 100644
--- a/geopandas/tests/test_geodataframe.py
+++ b/geopandas/tests/test_geodataframe.py
@@ -249,7 +249,6 @@ class TestDataFrame:
         with pytest.raises(ValueError, match=msg):
             self.df.rename_geometry("Shape_Area", inplace=True)
 
-    @pytest.mark.skipif(not compat.HAS_PYPROJ, reason="Requires pyproj")
     def test_set_geometry(self):
         geom = GeoSeries([Point(x, y) for x, y in zip(range(5), range(5))])
         original_geom = self.df.geometry
@@ -267,6 +266,10 @@ class TestDataFrame:
         with pytest.raises(ValueError):
             self.df.set_geometry(self.df)
 
+    @pytest.mark.skipif(not compat.HAS_PYPROJ, reason="Requires pyproj")
+    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 @@ class TestDataFrame:
 
     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 @@ class TestConstructor:
         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
diff --git a/geopandas/tests/test_geoseries.py b/geopandas/tests/test_geoseries.py
index 99b8336b..9ee56150 100644
--- a/geopandas/tests/test_geoseries.py
+++ b/geopandas/tests/test_geoseries.py
@@ -55,6 +55,9 @@ class TestSeries:
         self.l1 = LineString([(0, 0), (0, 1), (1, 1)])
         self.l2 = LineString([(0, 0), (1, 0), (1, 1), (0, 1)])
         self.g5 = GeoSeries([self.l1, self.l2])
+        self.esb3857 = Point(-8235939.130493107, 4975301.253789809)
+        self.sol3857 = Point(-8242607.167991625, 4966620.938285081)
+        self.landmarks3857 = GeoSeries([self.esb3857, self.sol3857], crs="epsg:3857")
 
     def teardown_method(self):
         shutil.rmtree(self.tempdir)
@@ -201,9 +204,62 @@ class TestSeries:
         Test whether GeoSeries.to_json works and returns an actual json file.
         """
         json_str = self.g3.to_json()
-        json.loads(json_str)
+        data = json.loads(json_str)
+        assert "id" in data["features"][0].keys()
+        assert "bbox" in data["features"][0].keys()
         # TODO : verify the output is a valid GeoJSON.
 
+    def test_to_json_drop_id(self):
+        """
+        Test whether GeoSeries.to_json works when drop_id is True.
+        """
+        json_str = self.g3.to_json(drop_id=True)
+        data = json.loads(json_str)
+        assert "id" not in data["features"][0].keys()
+
+    def test_to_json_no_bbox(self):
+        """
+        Test whether GeoSeries.to_json works when show_bbox is False.
+        """
+        json_str = self.g3.to_json(show_bbox=False)
+        data = json.loads(json_str)
+        assert "bbox" not in data["features"][0].keys()
+
+    def test_to_json_no_bbox_drop_id(self):
+        """
+        Test whether GeoSeries.to_json works when show_bbox is False
+        and drop_id is True.
+        """
+        json_str = self.g3.to_json(show_bbox=False, drop_id=True)
+        data = json.loads(json_str)
+        assert "id" not in data["features"][0].keys()
+        assert "bbox" not in data["features"][0].keys()
+
+    @pytest.mark.skipif(not compat.HAS_PYPROJ, reason="Requires pyproj")
+    def test_to_json_wgs84(self):
+        """
+        Test whether the wgs84 conversion works as intended.
+        """
+        text = self.landmarks3857.to_json(to_wgs84=True)
+        data = json.loads(text)
+        assert data["type"] == "FeatureCollection"
+        assert "id" in data["features"][0].keys()
+        coord1 = data["features"][0]["geometry"]["coordinates"]
+        coord2 = data["features"][1]["geometry"]["coordinates"]
+        np.testing.assert_allclose(coord1, self.esb.coords[0])
+        np.testing.assert_allclose(coord2, self.sol.coords[0])
+
+    def test_to_json_wgs84_false(self):
+        """
+        Ensure no conversion to wgs84
+        """
+        text = self.landmarks3857.to_json()
+        data = json.loads(text)
+        coord1 = data["features"][0]["geometry"]["coordinates"]
+        coord2 = data["features"][1]["geometry"]["coordinates"]
+        assert coord1 == [-8235939.130493107, 4975301.253789809]
+        assert coord2 == [-8242607.167991625, 4966620.938285081]
+
     def test_representative_point(self):
         assert np.all(self.g1.contains(self.g1.representative_point()))
         assert np.all(self.g2.contains(self.g2.representative_point()))
diff --git a/geopandas/tools/tests/test_sjoin.py b/geopandas/tools/tests/test_sjoin.py
index 59419fc3..b6d00347 100644
--- a/geopandas/tools/tests/test_sjoin.py
+++ b/geopandas/tools/tests/test_sjoin.py
@@ -5,14 +5,21 @@ import numpy as np
 import pandas as pd
 import shapely
 
-from shapely.geometry import Point, Polygon, GeometryCollection
+from shapely.geometry import Point, Polygon, GeometryCollection, box
 
 import geopandas
 import geopandas._compat as compat
-from geopandas import GeoDataFrame, GeoSeries, read_file, sjoin, sjoin_nearest
+from geopandas import (
+    GeoDataFrame,
+    GeoSeries,
+    read_file,
+    sjoin,
+    sjoin_nearest,
+    points_from_xy,
+)
 from geopandas.testing import assert_geodataframe_equal, assert_geoseries_equal
 
-from pandas.testing import assert_frame_equal, assert_series_equal
+from pandas.testing import assert_frame_equal, assert_series_equal, assert_index_equal
 import pytest
 
 
@@ -384,6 +391,39 @@ class TestSpatialJoin:
         joined = sjoin(left, right, how=how, predicate="dwithin", distance=distance)
         assert_frame_equal(expected_gdf.sort_index(), joined.sort_index())
 
+    # GH3239
+    @pytest.mark.parametrize(
+        "predicate",
+        [
+            "contains",
+            "contains_properly",
+            "covered_by",
+            "covers",
+            "crosses",
+            "intersects",
+            "touches",
+            "within",
+        ],
+    )
+    def test_sjoin_left_order(self, predicate):
+        # a set of points in random order -> that order should be preserved
+        # with a left join
+        pts = GeoDataFrame(
+            geometry=points_from_xy([0.1, 0.4, 0.3, 0.7], [0.8, 0.6, 0.9, 0.1])
+        )
+        polys = GeoDataFrame(
+            {"id": [1, 2, 3, 4]},
+            geometry=[
+                box(0, 0, 0.5, 0.5),
+                box(0, 0.5, 0.5, 1),
+                box(0.5, 0, 1, 0.5),
+                box(0.5, 0.5, 1, 1),
+            ],
+        )
+
+        joined = sjoin(pts, polys, predicate=predicate, how="left")
+        assert_index_equal(joined.index, pts.index)
+
 
 class TestIndexNames:
     @pytest.mark.parametrize("how", ["inner", "left", "right"])