Repository: geopandas/geopandas

Issue:
ENH: publicise `wkt` and `wkb` io functions as GeoSeries methods
We have currently `to_wkb`, `to_wkt` and `from_wkb`, `from_wkt` on array level, but those are not part of a public API as a method on GeoSeries.

Optimally, all should be directly accessible as `GeoSeries.to_wkt()` etc.

It should be really simple to implement.

Discussion:
martinfleis: Thanks a lot for this. I guess that my `good first issue` tag on that issue wasn't the best fit :).

Re your questions from #1680:

> Should `GeoSeries.to_wkt()` / `.to_wkb()` return a pandas Series?

Yes.

> How should `from_wkt` and `from_wkb` be integrated into the API? I could see them being useful as class methods on GeoSeries that would create a GeoSeries from a list or array of WKTs/WKBs. Or I could see them being useful as additional kwargs on the GeoDataFrame constructor; they could work similarly to the geometry kwarg, where a user could specify a wkt or wkb column name or pass in a list of WKTs/WKBs which would be set as the geometry. Or maybe both?

This may require more thought. We have currently only one function of a similar nature - `geopandas.points_from_xy` which stands a bit out of general API. It feels ideal to have these functions exposed as `GeoSeries.from_wkt()` rather than top-level API as the one for points. 

I am less certain about `wkt` and `wkb` kwargs in the `GeoDataFrame` constructor. It can be handy, but I'd like to avoid unnecessary complexity. Also, if you pass `wkt`, you lose your original wkt strings (they are overwritten by geometry). That is an alteration of data users should do themselves. I would keep GeoSeries methods and document their usage in cases when your DataFrame comes with wkt and skip GeoDataFrame stuff. But others may have a different opinion. I am not particularly strongly against it.
johnflavin: > `wkt` and `wkb` kwargs in the GeoDataFrame constructor... can be handy, but I'd like to avoid unnecessary complexity.

I agree with this framing. The `wkt` and `wkb` keyword args are a tradeoff between convenience for users and complexity in the `geopandas` code. I lean more toward favoring convenience for users, but I understand that's an opinion and others might feel differently.

> Also, if you pass `wkt`, you lose your original wkt strings (they are overwritten by geometry). That is an alteration of data users should do themselves.

This is a good point that I hadn't considered. And any ways I can think of right now to avoid overwriting the wkb strings introduce a lot more complexity than the convenience would be worth.

Of course, users could get around this behavior if it isn't what they want. But that's bad because 1. they have to know it is happening first, and if it is unexpected they won't know by definition, and 2. I'm making users jump through hoops to _not_ have their data get manipulated in ways they don't want.

Yeah, this has convinced me to not have the `wkt` and `wkb` keyword args. It's a shame, because I was really looking forward to that convenience. But there are some bad drawbacks.
johnflavin: > I would keep GeoSeries methods and document their usage in cases when your DataFrame comes with wkt and skip GeoDataFrame stuff.

Where would it be best to document this? 
martinfleis: > > I would keep GeoSeries methods and document their usage in cases when your DataFrame comes with wkt and skip GeoDataFrame stuff.
> 
> Where would it be best to document this?

We have this example https://geopandas.readthedocs.io/en/latest/gallery/create_geopandas_from_pandas.html which covers WKT. The example itself will soon become Jupyter notebook instead of the current .py file (that is my to-do list for December), so I'd say document it properly in docstrings which go to API now and I'll deal with the example later.
johnflavin: I have removed the `wkb` and `wkt` kwargs from `GeoDataFrame.__init__`.

At the same time, I added the ability to pass a `Series` to `GeoSeries.from_wkb/t`. I thought that added some convenience in that you can do this:
```python
df = pd.DataFrame({'wkt': ['POINT (0 0)'], ...}, index=<could be anything>)
gdf = geopandas.GeoDataFrame(df, geometry=GeoSeries.from_wkt(df['wkt']))
```
To me that makes it simpler to get a `GeoDataFrame` set up from a `DataFrame` with encoded geometries. But that also added a bit of complexity about what to do with the `index` kwarg vs. the index on the `Series`. I opted to raise a `ValueError` if they don't match.

If that complexity isn't worth it, I can revert those changes. But it makes it a little harder to go from wkt df -> gdf; you have to explicitly get the `values` from the wkt `Series`, and if the index is not default you have to set that explicitly too:
```python
df = pd.DataFrame({'wkt': ['POINT (0 0)'], ...}, index=<could be anything>)
gdf = geopandas.GeoDataFrame(df, geometry=GeoSeries.from_wkt(df['wkt'].values, index=df['wkt'].index))
```
At that point you're better off just using `geometry=df['wkt'].apply(shapely.wkt.loads)`. It's less efficient but the syntax is simpler.
johnflavin: Thanks for the review!

I've implemented the requested changes. Test failure seems unrelated.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 945054df..a4d8218e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,128 @@
 Changelog
 =========
 
+Version 0.9.0 (February ??, 2021)
+---------------------------------
+
+Many documentation improvements and a restyled and restructured website with
+a new logo (#1564, #1579, #1617, #1668, #1731, #1750, #1757, #1759).
+
+New features and improvements:
+
+- The `geopandas.read_file` function now accepts more general
+  file-like objects (e.g. `fsspec` open file objects). It will now also
+  automatically recognize zipped files (#1535).
+- The `GeoDataFrame.plot()` method now provides access to the pandas plotting
+  functionality for the non-geometry columns, either using the `kind` keyword
+  or the accessor method (e.g. `gdf.plot(kind="bar")` or `gdf.plot.bar()`)
+  (#1465).
+- New `from_wkt()`, `from_wkb()`, `to_wkt()`, `to_wkb()` methods for
+  GeoSeries to construct a GeoSeries from geometries in WKT or WKB
+  representation, or to convert a GeoSeries to a pandas Seriew with WKT or WKB
+  values (#1710).
+- New `GeoSeries.z` attribute to access the z-coordinates of Point geometries
+  (similar to the existing `.x` and `.y` attributes) (#1773).
+- The `to_crs()` method now handles missing values (#1618).
+- Support for pandas' new `.attrs` functionality (#1658).
+- The `dissolve()` method now allows dissolving by no column (`by=None`) to
+  create a union of all geometries (single-row GeoDataFrame) (#1568).
+- New `estimate_utm_crs()` method on GeoSeries/GeoDataFrame to determine the
+  UTM CRS based on the bounds (#1646).
+- `GeoDataFrame.from_dict()` now accepts `geometry` and `crs` keywords
+  (#1619).
+- `GeoDataFrame.to_postgis()` and `geopandas.read_postgis()` now supports
+  both sqlalchemy engine and connection objects (#1638).
+- The `GeoDataFrame.explode()` method now allows exploding based on a
+  non-geometry column, using the pandas implementation (#1720).
+- Performance improvement in `GeoDataFrame/GeoSeries.explode()` when using
+  the PyGEOS backend (#1693).
+- The binary operation and predicate methods (eg `intersection()`,
+  `intersects()`) have a new `align` keyword which allows optionally not
+  aligning on the index before performing the operation with `align=False`
+  (#1668).
+- The `GeoDataFrame.dissolve()` method now supports all relevant keywords of
+  `groupby()`, i.e. the `level`, `sort`, `observed` and `dropna` keywords
+  (#1845).
+- The `geopandas.overlay()` function now accepts `make_valid=False` to skip
+  the step to ensure the input geometries are valid using `buffer(0)` (#1802).
+- The `GeoDataFrame.to_json()` method gained a `drop_id` keyword to
+  optionally not write the GeoDataFrame's index as the "id" field in the
+  resulting JSON (#1637).
+- A new `aspect` keyword in the plotting methods to optionally allow retaining
+  the original aspect (#1512)
+- A new `interval` keyword in the `legend_kwds` group of the `plot()` method
+  to control the appearance of the legend labels when using a classification
+  scheme (#1605).
+- The spatial index of a GeoSeries (accessed with the `sindex` attribute) is
+  now stored on the underlying array. This ensures that the spatial index is
+  preserved in more operations where possible, and that multiple geometry
+  columns of a GeoDataFrame can each have a spatial index (#1444).
+- Addition of a `has_sindex` attribute on the GeoSeries/GeoDataFrame to check
+  if a spatial index has already been initialized (#1627).
+- The `geopandas.testing.assert_geoseries_equal()` and `assert_geodataframe_equal()`
+  testing utilities now have a `normalize` keyword (False by default) to
+  normalize geometries before comparing for equality (#1826). Those functions
+  now also give a more informative error message when failing (#1808).
+
+Deprecations and compatibility notes:
+
+- The `is_ring` attribute currently returns True for Polygons. In the future,
+  this will be False (#1631). In addition, start to check it for LineStrings
+  and LinearRings (instead of always returning False).
+- The deprecated `objects` keyword in the `intersection()` method of the
+  `GeoDataFrame/GeoSeries.sindex` spatial index object has been removed
+  (#1444).
+
+Bug fixes:
+
+- Fix regression in the `plot()` method raising an error with empty
+  geometries (#1702, #1828).
+- Fix `geopandas.overlay()` to preserve geometries of the correct type which
+  are nested withing a GeometryCollection as a result of the overlay
+  operation (#1582). In addition, a warning will now be raised if geometries
+  of different type are dropped from the result (#1554).
+- Fix the repr of an empty GeoSeries to not show spurious warnings (#1673).
+- Fix the `.crs` for empty GeoDataFrames (#1560).
+- Fix `geopandas.clip` to preserve the correct geometry column name (#1566). 
+- Fix bug in `plot()` method when using `legend_kwds` with multiple subplots
+  (#1583)
+- Fix spurious warning with `missing_kwds` keyword of the `plot()` method
+  when there are no areas with missing data (#1600).
+- Fix the `plot()` method to correctly align values passed to the `column`
+  keyword as a pandas Series (#1670).
+- Fix bug in plotting MultiPoints when passing values to determine the color
+  (#1694)
+- The `rename_geometry()` method now raises a more informative error message
+  when a duplicate column name is used (#1602).
+- Fix `explode()` method to preserve the CRS (#1655)
+- Fix the `GeoSeries.apply()` method to again accept the `convert_dtype`
+  keyword to be consistent with pandas (#1636).
+- Fix `GeoDataFrame.apply()` to preserve the CRS when possible (#1848).
+- Fix bug in containment test as `geom in geoseries` (#1753).
+- The `shift()` method of a GeoSeries/GeoDataFrame now preserves the CRS
+  (#1744).
+- The PostGIS IO functionality now quotes table names to ensure it works with
+  case-sensitive names (#1825).
+- Fix the `GeoSeries` constructor without passing data but only an index (#1798).
+
+Notes on (optional) dependencies:
+
+- GeoPandas 0.9.0 dropped support for Python 3.5. Further, the minimum
+  required versions are pandas 0.24, numpy 1.15 and shapely 1.6 and fiona 1.8.
+- The `descartes` package is no longer required for plotting polygons. This
+  functionality is now included by default in GeoPandas itself, when
+  matplotlib is available (#1677).
+- Fiona is now only imported when used in `read_file`/`to_file`. This means
+  you can now force geopandas to install without fiona installed (although it
+  is still a default requirement) (#1775).
+- Compatibility with the upcoming Shapely 1.8 (#1659, #1662, #1819).
+
+
+Version 0.8.2 (January 25, 2021)
+--------------------------------
+
+Small bug-fix release for compatibility with PyGEOS 0.9.
+
 
 Version 0.8.1 (July 15, 2020)
 -----------------------------
diff --git a/doc/source/docs/reference/sindex.rst b/doc/source/docs/reference/sindex.rst
index ffed1c98..3201106d 100644
--- a/doc/source/docs/reference/sindex.rst
+++ b/doc/source/docs/reference/sindex.rst
@@ -18,22 +18,13 @@ Constructor
 
     GeoSeries.sindex
 
-PyGEOS STRtree
---------------
-.. currentmodule:: geopandas.sindex.PyGEOSSTRTreeIndex
-.. autosummary::
-   :toctree: api/
+Spatial Index object
+--------------------
 
-    intersection
-    is_empty
-    query
-    query_bulk
-    size
-    valid_query_predicates
+The spatial index object returned from :attr:`GeoSeries.sindex` has the following
+methods:
 
-rtree Rtree
------------
-.. currentmodule:: geopandas.sindex.RTreeIndex
+.. currentmodule:: geopandas.sindex.SpatialIndex
 .. autosummary::
    :toctree: api/
 
@@ -44,7 +35,11 @@ rtree Rtree
     size
     valid_query_predicates
 
-Furthermore, the ``rtree``-based spatial index offers full capability of
+The concrete implementations currently available are
+``geopandas.sindex.PyGEOSSTRTreeIndex`` and ``geopandas.sindex.RTreeIndex``.
+
+In addition to the methods listed above, the ``rtree``-based spatial index
+(``geopandas.sindex.RTreeIndex``) offers the full capability of
 ``rtree.index.Index`` - see the full API in the `rtree documentation`_.
 
 .. _rtree documentation: https://rtree.readthedocs.io/en/stable/class.html
diff --git a/doc/source/gallery/create_geopandas_from_pandas.ipynb b/doc/source/gallery/create_geopandas_from_pandas.ipynb
index 2c8ff665..17547e44 100644
--- a/doc/source/gallery/create_geopandas_from_pandas.ipynb
+++ b/doc/source/gallery/create_geopandas_from_pandas.ipynb
@@ -159,7 +159,7 @@
    "source": [
     "from shapely import wkt\n",
     "\n",
-    "df['Coordinates'] = df['Coordinates'].apply(wkt.loads)"
+    "df['Coordinates'] = geopandas.GeoSeries.from_wkt(df['Coordinates'])"
    ]
   },
   {
@@ -225,4 +225,4 @@
  },
  "nbformat": 4,
  "nbformat_minor": 4
-}
+}
\ No newline at end of file
diff --git a/environment-dev.yml b/environment-dev.yml
new file mode 100644
index 00000000..a116f56c
--- /dev/null
+++ b/environment-dev.yml
@@ -0,0 +1,32 @@
+name: geopandas-dev
+channels:
+    - conda-forge
+dependencies:
+    # required
+    - fiona>=1.8
+    - pandas>=0.24
+    - pyproj>=2.2.0
+    - shapely>=1.6
+
+    # geodatabase access
+    - psycopg2>=2.5.1
+    - SQLAlchemy>=0.8.3
+
+    # geocoding
+    - geopy
+
+    # plotting
+    - matplotlib>=2.2
+    - mapclassify
+
+    # testing
+    - pytest>=3.1.0
+    - pytest-cov
+    - codecov
+
+    # spatial access methods
+    - rtree>=0.8
+
+    # styling
+    - black
+    - pre-commit
diff --git a/environment.yml b/environment.yml
index a116f56c..e6fe56ed 100644
--- a/environment.yml
+++ b/environment.yml
@@ -1,32 +1,20 @@
-name: geopandas-dev
+name: geopandas
 channels:
-    - conda-forge
+  - conda-forge
 dependencies:
-    # required
-    - fiona>=1.8
-    - pandas>=0.24
-    - pyproj>=2.2.0
-    - shapely>=1.6
-
-    # geodatabase access
-    - psycopg2>=2.5.1
-    - SQLAlchemy>=0.8.3
-
-    # geocoding
-    - geopy
-
-    # plotting
-    - matplotlib>=2.2
-    - mapclassify
-
-    # testing
-    - pytest>=3.1.0
-    - pytest-cov
-    - codecov
-
-    # spatial access methods
-    - rtree>=0.8
-
-    # styling
-    - black
-    - pre-commit
+  - geopandas
+  - shapely
+  - fiona
+  - pyproj
+  - pygeos
+  - rtree
+  - mapclassify
+  - libpysal
+  - matplotlib
+  - geopy
+  - cartopy
+  - pyepsg
+  - contextily
+  - rasterio
+  - geoplot
+  - folium
diff --git a/geopandas/_vectorized.py b/geopandas/_vectorized.py
index b96ddacf..3184c94b 100644
--- a/geopandas/_vectorized.py
+++ b/geopandas/_vectorized.py
@@ -177,9 +177,9 @@ def from_wkb(data):
     return aout
 
 
-def to_wkb(data, hex=False):
+def to_wkb(data, hex=False, **kwargs):
     if compat.USE_PYGEOS:
-        return pygeos.to_wkb(data, hex=hex)
+        return pygeos.to_wkb(data, hex=hex, **kwargs)
     else:
         if hex:
             out = [geom.wkb_hex if geom is not None else None for geom in data]
diff --git a/geopandas/array.py b/geopandas/array.py
index a25e01ba..5164eb22 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -194,13 +194,13 @@ def from_wkb(data, crs=None):
     return GeometryArray(vectorized.from_wkb(data), crs=crs)
 
 
-def to_wkb(geoms, hex=False):
+def to_wkb(geoms, hex=False, **kwargs):
     """
     Convert GeometryArray to a numpy object array of WKB objects.
     """
     if not isinstance(geoms, GeometryArray):
         raise ValueError("'geoms' must be a GeometryArray")
-    return vectorized.to_wkb(geoms.data, hex=hex)
+    return vectorized.to_wkb(geoms.data, hex=hex, **kwargs)
 
 
 def from_wkt(data, crs=None):
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index d3d18d77..eeea2d66 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -11,9 +11,9 @@ from shapely.geometry.base import BaseGeometry
 
 from pyproj import CRS
 
-from geopandas.array import GeometryArray, from_shapely, GeometryDtype
+from geopandas.array import GeometryArray, GeometryDtype, from_shapely, to_wkb, to_wkt
 from geopandas.base import GeoPandasBase, is_geometry_type
-from geopandas.geoseries import GeoSeries
+from geopandas.geoseries import GeoSeries, inherit_doc
 import geopandas.io
 from geopandas.plotting import plot_dataframe
 from . import _compat as compat
@@ -79,6 +79,18 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
     geometry    geometry
     dtype: object
 
+    Constructing GeoDataFrame from a pandas DataFrame with a column of WKT geometries:
+
+    >>> import pandas as pd
+    >>> d = {'col1': ['name1', 'name2'], 'wkt': ['POINT (1 2)', 'POINT (2 1)']}
+    >>> df = pd.DataFrame(d)
+    >>> gs = geopandas.GeoSeries.from_wkt(df['wkt'])
+    >>> gdf = geopandas.GeoDataFrame(df, geometry=gs, crs="EPSG:4326")
+    >>> gdf
+        col1          wkt                 geometry
+    0  name1  POINT (1 2)  POINT (1.00000 2.00000)
+    1  name2  POINT (2 1)  POINT (2.00000 1.00000)
+
     See also
     --------
     GeoSeries : Series object designed to store shapely geometry objects
@@ -863,6 +875,57 @@ box': (2.0, 1.0, 2.0, 1.0)}], 'bbox': (1.0, 1.0, 2.0, 2.0)}
 
         return geo
 
+    def to_wkb(self, hex=False, **kwargs):
+        """
+        Encode all geometry columns in the GeoDataFrame to WKB.
+
+        Parameters
+        ----------
+        hex : bool
+            If true, export the WKB as a hexadecimal string.
+            The default is to return a binary bytes object.
+        kwargs
+            Additional keyword args will be passed to
+            :func:`pygeos.to_wkb` if pygeos is installed.
+
+        Returns
+        -------
+        DataFrame
+            geometry columns are encoded to WKB
+        """
+
+        df = DataFrame(self.copy())
+
+        # Encode all geometry columns to WKB
+        for col in df.columns[df.dtypes == "geometry"]:
+            df[col] = to_wkb(df[col].values, hex=hex, **kwargs)
+
+        return df
+
+    def to_wkt(self, **kwargs):
+        """
+        Encode all geometry columns in the GeoDataFrame to WKT.
+
+        Parameters
+        ----------
+        kwargs
+            Keyword args will be passed to :func:`pygeos.to_wkt`
+            if pygeos is installed.
+
+        Returns
+        -------
+        DataFrame
+            geometry columns are encoded to WKT
+        """
+
+        df = DataFrame(self.copy())
+
+        # Encode all geometry columns to WKT
+        for col in df.columns[df.dtypes == "geometry"]:
+            df[col] = to_wkt(df[col].values, **kwargs)
+
+        return df
+
     def to_parquet(self, path, index=None, compression="snappy", **kwargs):
         """Write a GeoDataFrame to the Parquet format.
 
@@ -1293,6 +1356,16 @@ box': (2.0, 1.0, 2.0, 1.0)}], 'bbox': (1.0, 1.0, 2.0, 2.0)}
             result.__class__ = DataFrame
         return result
 
+    @inherit_doc(pd.DataFrame)
+    def apply(self, func, axis=0, raw=False, result_type=None, args=(), **kwargs):
+        result = super().apply(
+            func, axis=axis, raw=raw, result_type=result_type, args=args, **kwargs
+        )
+        if isinstance(result, GeoDataFrame):
+            if self.crs is not None and result.crs is None:
+                result.set_crs(self.crs, inplace=True)
+        return result
+
     @property
     def _constructor(self):
         return GeoDataFrame
@@ -1312,7 +1385,16 @@ box': (2.0, 1.0, 2.0, 1.0)}], 'bbox': (1.0, 1.0, 2.0, 2.0)}
 
         return self
 
-    def dissolve(self, by=None, aggfunc="first", as_index=True):
+    def dissolve(
+        self,
+        by=None,
+        aggfunc="first",
+        as_index=True,
+        level=None,
+        sort=True,
+        observed=False,
+        dropna=True,
+    ):
         """
         Dissolve geometries within `groupby` into single observation.
         This is accomplished by applying the `unary_union` method
@@ -1331,6 +1413,33 @@ box': (2.0, 1.0, 2.0, 1.0)}], 'bbox': (1.0, 1.0, 2.0, 2.0)}
             with each group. Passed to pandas `groupby.agg` method.
         as_index : boolean, default True
             If true, groupby columns become index of result.
+        level : int or str or sequence of int or sequence of str, default None
+            If the axis is a MultiIndex (hierarchical), group by a
+            particular level or levels.
+
+            .. versionadded:: 0.9.0
+        sort : bool, default True
+            Sort group keys. Get better performance by turning this off.
+            Note this does not influence the order of observations within
+            each group. Groupby preserves the order of rows within each group.
+
+            .. versionadded:: 0.9.0
+        observed : bool, default False
+            This only applies if any of the groupers are Categoricals.
+            If True: only show observed values for categorical groupers.
+            If False: show all values for categorical groupers.
+
+            .. versionadded:: 0.9.0
+        dropna : bool, default True
+            If True, and if group keys contain NA values, NA values
+            together with row/column will be dropped. If False, NA
+            values will also be treated as the key in groups.
+
+            This parameter is not supported for pandas < 1.1.0.
+            A warning will be emitted for earlier pandas versions
+            if a non-default value is given for this parameter.
+
+            .. versionadded:: 0.9.0
 
         Returns
         -------
@@ -1363,19 +1472,28 @@ box': (2.0, 1.0, 2.0, 1.0)}], 'bbox': (1.0, 1.0, 2.0, 2.0)}
 
         """
 
-        if by is None:
+        if by is None and level is None:
             by = np.zeros(len(self), dtype="int64")
 
+        groupby_kwargs = dict(
+            by=by, level=level, sort=sort, observed=observed, dropna=dropna
+        )
+        if not compat.PANDAS_GE_11:
+            groupby_kwargs.pop("dropna")
+
+            if not dropna:  # If they passed a non-default dropna value
+                warnings.warn("dropna kwarg is not supported for pandas < 1.1.0")
+
         # Process non-spatial component
         data = self.drop(labels=self.geometry.name, axis=1)
-        aggregated_data = data.groupby(by=by).agg(aggfunc)
+        aggregated_data = data.groupby(**groupby_kwargs).agg(aggfunc)
 
         # Process spatial component
         def merge_geometries(block):
             merged_geom = block.unary_union
             return merged_geom
 
-        g = self.groupby(by=by, group_keys=False)[self.geometry.name].agg(
+        g = self.groupby(group_keys=False, **groupby_kwargs)[self.geometry.name].agg(
             merge_geometries
         )
 
diff --git a/geopandas/geoseries.py b/geopandas/geoseries.py
index 8444d978..c60d1c80 100644
--- a/geopandas/geoseries.py
+++ b/geopandas/geoseries.py
@@ -12,7 +12,14 @@ from shapely.geometry.base import BaseGeometry
 from geopandas.base import GeoPandasBase, _delegate_property
 from geopandas.plotting import plot_series
 
-from .array import GeometryDtype, from_shapely
+from .array import (
+    GeometryDtype,
+    from_shapely,
+    from_wkb,
+    from_wkt,
+    to_wkb,
+    to_wkt,
+)
 from .base import is_geometry_type
 from . import _compat as compat
 
@@ -360,6 +367,97 @@ class GeoSeries(GeoPandasBase, Series):
 
         return GeoSeries(df.geometry, crs=df.crs)
 
+    @classmethod
+    def from_wkb(cls, data, index=None, crs=None, **kwargs):
+        """
+        Alternate constructor to create a ``GeoSeries``
+        from a list or array of WKB objects
+
+        Parameters
+        ----------
+        data : array-like or Series
+            Series, list or array of WKB objects
+        index : array-like or Index
+            The index for the GeoSeries.
+        crs : value, optional
+            Coordinate Reference System of the geometry objects. Can be anything
+            accepted by
+            :meth:`pyproj.CRS.from_user_input() <pyproj.crs.CRS.from_user_input>`,
+            such as an authority string (eg "EPSG:4326") or a WKT string.
+        kwargs
+            Additional arguments passed to the Series constructor,
+            e.g. ``name``.
+
+        Returns
+        -------
+        GeoSeries
+
+        See Also
+        --------
+        GeoSeries.from_wkt
+
+        """
+        return cls._from_wkb_or_wkb(from_wkb, data, index=index, crs=crs, **kwargs)
+
+    @classmethod
+    def from_wkt(cls, data, index=None, crs=None, **kwargs):
+        """
+        Alternate constructor to create a ``GeoSeries``
+        from a list or array of WKT objects
+
+        Parameters
+        ----------
+        data : array-like, Series
+            Series, list, or array of WKT objects
+        index : array-like or Index
+            The index for the GeoSeries.
+        crs : value, optional
+            Coordinate Reference System of the geometry objects. Can be anything
+            accepted by
+            :meth:`pyproj.CRS.from_user_input() <pyproj.crs.CRS.from_user_input>`,
+            such as an authority string (eg "EPSG:4326") or a WKT string.
+        kwargs
+            Additional arguments passed to the Series constructor,
+            e.g. ``name``.
+
+        Returns
+        -------
+        GeoSeries
+
+        See Also
+        --------
+        GeoSeries.from_wkb
+
+        Examples
+        --------
+
+        >>> wkts = [
+        ... 'POINT (1 1)',
+        ... 'POINT (2 2)',
+        ... 'POINT (3 3)',
+        ... ]
+        >>> s = geopandas.GeoSeries.from_wkt(wkts)
+        >>> s
+        0    POINT (1.00000 1.00000)
+        1    POINT (2.00000 2.00000)
+        2    POINT (3.00000 3.00000)
+        dtype: geometry
+        """
+        return cls._from_wkb_or_wkb(from_wkt, data, index=index, crs=crs, **kwargs)
+
+    @classmethod
+    def _from_wkb_or_wkb(
+        cls, from_wkb_or_wkt_function, data, index=None, crs=None, **kwargs
+    ):
+        """Create a GeoSeries from either WKT or WKB values"""
+        if isinstance(data, Series):
+            if index is not None:
+                data = data.reindex(index)
+            else:
+                index = data.index
+            data = data.values
+        return cls(from_wkb_or_wkt_function(data, crs=crs), index=index, **kwargs)
+
     @property
     def __geo_interface__(self):
         """Returns a ``GeoSeries`` as a python feature collection.
@@ -1006,6 +1104,67 @@ e": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [3
         """
         return json.dumps(self.__geo_interface__, **kwargs)
 
+    def to_wkb(self, hex=False, **kwargs):
+        """
+        Convert GeoSeries geometries to WKB
+
+        Parameters
+        ----------
+        hex : bool
+            If true, export the WKB as a hexadecimal string.
+            The default is to return a binary bytes object.
+        kwargs
+            Additional keyword args will be passed to
+            :func:`pygeos.to_wkb` if pygeos is installed.
+
+        Returns
+        -------
+        Series
+            WKB representations of the geometries
+
+        See also
+        --------
+        GeoSeries.to_wkt
+        """
+        return Series(to_wkb(self.array, hex=hex, **kwargs), index=self.index)
+
+    def to_wkt(self, **kwargs):
+        """
+        Convert GeoSeries geometries to WKT
+
+        Parameters
+        ----------
+        kwargs
+            Keyword args will be passed to :func:`pygeos.to_wkt`
+            if pygeos is installed.
+
+        Returns
+        -------
+        Series
+            WKT representations of the geometries
+
+        Examples
+        --------
+        >>> from shapely.geometry import Point
+        >>> s = geopandas.GeoSeries([Point(1, 1), Point(2, 2), Point(3, 3)])
+        >>> s
+        0    POINT (1.00000 1.00000)
+        1    POINT (2.00000 2.00000)
+        2    POINT (3.00000 3.00000)
+        dtype: geometry
+
+        >>> s.to_wkt()
+        0    POINT (1 1)
+        1    POINT (2 2)
+        2    POINT (3 3)
+        dtype: object
+
+        See also
+        --------
+        GeoSeries.to_wkb
+        """
+        return Series(to_wkt(self.array, **kwargs), index=self.index)
+
     #
     # Implement standard operators for GeoSeries
     #
diff --git a/geopandas/io/arrow.py b/geopandas/io/arrow.py
index 014d0da0..6aa225f1 100644
--- a/geopandas/io/arrow.py
+++ b/geopandas/io/arrow.py
@@ -5,7 +5,7 @@ import warnings
 from pandas import DataFrame
 
 from geopandas._compat import import_optional_dependency
-from geopandas.array import from_wkb, to_wkb
+from geopandas.array import from_wkb
 from geopandas import GeoDataFrame
 import geopandas
 
@@ -76,28 +76,6 @@ def _encode_metadata(metadata):
     return json.dumps(metadata).encode("utf-8")
 
 
-def _encode_wkb(df):
-    """Encode all geometry columns in the GeoDataFrame to WKB.
-
-    Parameters
-    ----------
-    df : GeoDataFrame
-
-    Returns
-    -------
-    DataFrame
-        geometry columns are encoded to WKB
-    """
-
-    df = DataFrame(df.copy())
-
-    # Encode all geometry columns to WKB
-    for col in df.columns[df.dtypes == "geometry"]:
-        df[col] = to_wkb(df[col].values)
-
-    return df
-
-
 def _decode_metadata(metadata_str):
     """Decode a UTF-8 encoded JSON string to dict
 
@@ -208,7 +186,7 @@ def _geopandas_to_arrow(df, index=None):
     # create geo metadata before altering incoming data frame
     geo_metadata = _create_metadata(df)
 
-    df = _encode_wkb(df)
+    df = df.to_wkb()
 
     table = Table.from_pandas(df, preserve_index=index)
 
diff --git a/geopandas/sindex.py b/geopandas/sindex.py
index 03eab7c9..80f00596 100644
--- a/geopandas/sindex.py
+++ b/geopandas/sindex.py
@@ -1,3 +1,6 @@
+from textwrap import dedent
+import warnings
+
 from shapely.geometry.base import BaseGeometry
 import pandas as pd
 import numpy as np
@@ -21,22 +24,288 @@ def _get_sindex_class():
     )
 
 
+class BaseSpatialIndex:
+    @property
+    def valid_query_predicates(self):
+        """Returns valid predicates for this spatial index.
+
+        Returns
+        -------
+        set
+            Set of valid predicates for this spatial index.
+
+        Examples
+        --------
+        >>> from shapely.geometry import Point
+        >>> s = geopandas.GeoSeries([Point(0, 0), Point(1, 1)])
+        >>> s.sindex.valid_query_predicates  # doctest: +SKIP
+        {'contains', 'crosses', 'intersects', 'within', 'touches', \
+'overlaps', None, 'covers', 'contains_properly'}
+        """
+        raise NotImplementedError
+
+    def query(self, geometry, predicate=None, sort=False):
+        """Return the index of all geometries in the tree with extents that
+        intersect the envelope of the input geometry.
+
+        When using the ``rtree`` package, this is not a vectorized function.
+        If speed is important, please use PyGEOS.
+
+        Parameters
+        ----------
+        geometry : shapely geometry
+            A single shapely geometry to query against the spatial index.
+        predicate : {None, 'intersects', 'within', 'contains', \
+'overlaps', 'crosses', 'touches'}, optional
+            If predicate is provided, the input geometry is
+            tested using the predicate function against each item
+            in the tree whose extent intersects the envelope of the
+            input geometry: predicate(input_geometry, tree_geometry).
+            If possible, prepared geometries are used to help
+            speed up the predicate operation.
+        sort : bool, default False
+            If True, the results will be sorted in ascending order.
+            If False, results are often sorted but there is no guarantee.
+
+        Returns
+        -------
+        matches : ndarray of shape (n_results, )
+            Integer indices for matching geometries from the spatial index.
+
+        Examples
+        --------
+        >>> from shapely.geometry import Point, box
+        >>> s = geopandas.GeoSeries(geopandas.points_from_xy(range(10), range(10)))
+        >>> s
+        0    POINT (0.00000 0.00000)
+        1    POINT (1.00000 1.00000)
+        2    POINT (2.00000 2.00000)
+        3    POINT (3.00000 3.00000)
+        4    POINT (4.00000 4.00000)
+        5    POINT (5.00000 5.00000)
+        6    POINT (6.00000 6.00000)
+        7    POINT (7.00000 7.00000)
+        8    POINT (8.00000 8.00000)
+        9    POINT (9.00000 9.00000)
+        dtype: geometry
+
+        >>> s.sindex.query(box(1, 1, 3, 3))
+        array([1, 2, 3])
+
+        >>> s.sindex.query(box(1, 1, 3, 3), predicate="contains")
+        array([2])
+        """
+        raise NotImplementedError
+
+    def query_bulk(self, geometry, predicate=None, sort=False):
+        """
+        Returns all combinations of each input geometry and geometries in
+        the tree where the envelope of each input geometry intersects with
+        the envelope of a tree geometry.
+
+        In the context of a spatial join, input geometries are the “left”
+        geometries that determine the order of the results, and tree geometries
+        are “right” geometries that are joined against the left geometries.
+        This effectively performs an inner join, where only those combinations
+        of geometries that can be joined based on envelope overlap or optional
+        predicate are returned.
+
+        When using the ``rtree`` package, this is not a vectorized function
+        and may be slow. If speed is important, please use PyGEOS.
+
+        Parameters
+        ----------
+        geometry : {GeoSeries, GeometryArray, numpy.array of PyGEOS geometries}
+            Accepts GeoPandas geometry iterables (GeoSeries, GeometryArray)
+            or a numpy array of PyGEOS geometries.
+        predicate : {None, 'intersects', 'within', 'contains', 'overlaps', \
+'crosses', 'touches'}, optional
+            If predicate is provided, the input geometries are tested using
+            the predicate function against each item in the tree whose extent
+            intersects the envelope of the each input geometry:
+            predicate(input_geometry, tree_geometry).  If possible, prepared
+            geometries are used to help speed up the predicate operation.
+        sort : bool, default False
+            If True, results sorted lexicographically using
+            geometry's indexes as the primary key and the sindex's indexes as the
+            secondary key. If False, no additional sorting is applied.
+
+        Returns
+        -------
+        ndarray with shape (2, n)
+            The first subarray contains input geometry integer indexes.
+            The second subarray contains tree geometry integer indexes.
+
+        Examples
+        --------
+        >>> from shapely.geometry import Point, box
+        >>> s = geopandas.GeoSeries(geopandas.points_from_xy(range(10), range(10)))
+        >>> s
+        0    POINT (0.00000 0.00000)
+        1    POINT (1.00000 1.00000)
+        2    POINT (2.00000 2.00000)
+        3    POINT (3.00000 3.00000)
+        4    POINT (4.00000 4.00000)
+        5    POINT (5.00000 5.00000)
+        6    POINT (6.00000 6.00000)
+        7    POINT (7.00000 7.00000)
+        8    POINT (8.00000 8.00000)
+        9    POINT (9.00000 9.00000)
+        dtype: geometry
+        >>> s2 = geopandas.GeoSeries([box(2, 2, 4, 4), box(5, 5, 6, 6)])
+        >>> s2
+        0    POLYGON ((4.00000 2.00000, 4.00000 4.00000, 2....
+        1    POLYGON ((6.00000 5.00000, 6.00000 6.00000, 5....
+        dtype: geometry
+
+        >>> s.sindex.query_bulk(s2)
+        array([[0, 0, 0, 1, 1],
+                [2, 3, 4, 5, 6]])
+
+        >>> s.sindex.query_bulk(s2, predicate="contains")
+        array([[0],
+                [3]])
+        """
+        raise NotImplementedError
+
+    def intersection(self, coordinates):
+        """Compatibility wrapper for rtree.index.Index.intersection,
+        use ``query`` intead.
+
+        Parameters
+        ----------
+        coordinates : sequence or array
+            Sequence of the form (min_x, min_y, max_x, max_y)
+            to query a rectangle or (x, y) to query a point.
+
+        Examples
+        --------
+        >>> from shapely.geometry import Point, box
+        >>> s = geopandas.GeoSeries(geopandas.points_from_xy(range(10), range(10)))
+        >>> s
+        0    POINT (0.00000 0.00000)
+        1    POINT (1.00000 1.00000)
+        2    POINT (2.00000 2.00000)
+        3    POINT (3.00000 3.00000)
+        4    POINT (4.00000 4.00000)
+        5    POINT (5.00000 5.00000)
+        6    POINT (6.00000 6.00000)
+        7    POINT (7.00000 7.00000)
+        8    POINT (8.00000 8.00000)
+        9    POINT (9.00000 9.00000)
+        dtype: geometry
+
+        >>> s.sindex.intersection(box(1, 1, 3, 3).bounds)
+        array([1, 2, 3])
+
+        Alternatively, you can use ``query``:
+
+        >>> s.sindex.query(box(1, 1, 3, 3))
+        array([1, 2, 3])
+
+        """
+        raise NotImplementedError
+
+    @property
+    def size(self):
+        """Size of the spatial index
+
+        Number of leaves (input geometries) in the index.
+
+        Examples
+        --------
+        >>> from shapely.geometry import Point
+        >>> s = geopandas.GeoSeries(geopandas.points_from_xy(range(10), range(10)))
+        >>> s
+        0    POINT (0.00000 0.00000)
+        1    POINT (1.00000 1.00000)
+        2    POINT (2.00000 2.00000)
+        3    POINT (3.00000 3.00000)
+        4    POINT (4.00000 4.00000)
+        5    POINT (5.00000 5.00000)
+        6    POINT (6.00000 6.00000)
+        7    POINT (7.00000 7.00000)
+        8    POINT (8.00000 8.00000)
+        9    POINT (9.00000 9.00000)
+        dtype: geometry
+
+        >>> s.sindex.size
+        10
+        """
+        raise NotImplementedError
+
+    @property
+    def is_empty(self):
+        """Check if the spatial index is empty
+
+        Examples
+        --------
+        >>> from shapely.geometry import Point
+        >>> s = geopandas.GeoSeries(geopandas.points_from_xy(range(10), range(10)))
+        >>> s
+        0    POINT (0.00000 0.00000)
+        1    POINT (1.00000 1.00000)
+        2    POINT (2.00000 2.00000)
+        3    POINT (3.00000 3.00000)
+        4    POINT (4.00000 4.00000)
+        5    POINT (5.00000 5.00000)
+        6    POINT (6.00000 6.00000)
+        7    POINT (7.00000 7.00000)
+        8    POINT (8.00000 8.00000)
+        9    POINT (9.00000 9.00000)
+        dtype: geometry
+
+        >>> s.sindex.is_empty
+        False
+
+        >>> s2 = geopandas.GeoSeries()
+        >>> s2.sindex.is_empty
+        True
+        """
+        raise NotImplementedError
+
+
+def doc(docstring):
+    """
+    A decorator take docstring from passed object and it to decorated one.
+    """
+
+    def decorator(decorated):
+        decorated.__doc__ = dedent(docstring.__doc__ or "")
+        return decorated
+
+    return decorator
+
+
 if compat.HAS_RTREE:
 
     import rtree.index  # noqa
     from rtree.core import RTreeError  # noqa
     from shapely.prepared import prep  # noqa
 
-    class SpatialIndex(rtree.index.Index):
+    class SpatialIndex(rtree.index.Index, BaseSpatialIndex):
         """Original rtree wrapper, kept for backwards compatibility."""
 
         def __init__(self, *args):
-            super().__init__(self, *args)
+            warnings.warn(
+                "Directly using SpatialIndex is deprecated, and the class will be "
+                "removed in a future version. Access the spatial index through the "
+                "`GeoSeries.sindex` attribute, or use `rtree.index.Index` directly.",
+                FutureWarning,
+                stacklevel=2,
+            )
+            super().__init__(*args)
 
+        @doc(BaseSpatialIndex.intersection)
+        def intersection(self, coordinates, *args, **kwargs):
+            return super().intersection(coordinates, *args, **kwargs)
+
+        @doc(BaseSpatialIndex.size)
         @property
         def size(self):
             return len(self.leaves()[0][1])
 
+        @doc(BaseSpatialIndex.is_empty)
         @property
         def is_empty(self):
             if len(self.leaves()) > 1:
@@ -74,23 +343,9 @@ if compat.HAS_RTREE:
                 [None] * self.geometries.size, dtype=object
             )
 
+        @doc(BaseSpatialIndex.valid_query_predicates)
         @property
         def valid_query_predicates(self):
-            """Returns valid predicates for this spatial index.
-
-            Returns
-            -------
-            set
-                Set of valid predicates for this spatial index.
-
-            Examples
-            --------
-            >>> from shapely.geometry import Point
-            >>> s = geopandas.GeoSeries([Point(0, 0), Point(1, 1)])
-            >>> s.sindex.valid_query_predicates  # doctest: +SKIP
-            {'contains', 'crosses', 'intersects', 'within', 'touches', \
-'overlaps', None, 'covers', 'contains_properly'}
-            """
             return {
                 None,
                 "intersects",
@@ -103,60 +358,8 @@ if compat.HAS_RTREE:
                 "contains_properly",
             }
 
+        @doc(BaseSpatialIndex.query)
         def query(self, geometry, predicate=None, sort=False):
-            """Return the index of all geometries in the tree with extents that
-            intersect the envelope of the input geometry.
-
-            Compatibility layer for pygeos-based ``sindex.query``.
-
-            This is not a vectorized function, if speed is important,
-            please use PyGEOS.
-
-            Parameters
-            ----------
-            geometry : shapely geometry
-                A single shapely geometry to query against the spatial index.
-            predicate : {None, 'intersects', 'within', 'contains', \
-'overlaps', 'crosses', 'touches'}, optional
-                If predicate is provided, the input geometry is
-                tested using the predicate function against each item
-                in the tree whose extent intersects the envelope of the
-                input geometry: predicate(input_geometry, tree_geometry).
-                If possible, prepared geometries are used to help
-                speed up the predicate operation.
-            sort : bool, default False
-                If True, the results will be sorted in ascending order.
-                If False, results are often sorted but there is no guarantee.
-
-            Returns
-            -------
-            matches : ndarray of shape (n_results, )
-                Integer indices for matching geometries from the spatial index.
-
-            Examples
-            --------
-            >>> from shapely.geometry import Point, box
-            >>> s = geopandas.GeoSeries(geopandas.points_from_xy(range(10), range(10)))
-            >>> s
-            0    POINT (0.00000 0.00000)
-            1    POINT (1.00000 1.00000)
-            2    POINT (2.00000 2.00000)
-            3    POINT (3.00000 3.00000)
-            4    POINT (4.00000 4.00000)
-            5    POINT (5.00000 5.00000)
-            6    POINT (6.00000 6.00000)
-            7    POINT (7.00000 7.00000)
-            8    POINT (8.00000 8.00000)
-            9    POINT (9.00000 9.00000)
-            dtype: geometry
-
-            >>> s.sindex.query(box(1, 1, 3, 3))
-            array([1, 2, 3])
-
-            >>> s.sindex.query(box(1, 1, 3, 3), predicate="contains")
-            array([2])
-            """
-
             # handle invalid predicates
             if predicate not in self.valid_query_predicates:
                 raise ValueError(
@@ -235,71 +438,8 @@ if compat.HAS_RTREE:
             # unsorted
             return np.array(tree_idx, dtype=np.intp)
 
+        @doc(BaseSpatialIndex.query_bulk)
         def query_bulk(self, geometry, predicate=None, sort=False):
-            """
-            Returns all combinations of each input geometry and geometries in
-            the tree where the envelope of each input geometry intersects with
-            the envelope of a tree geometry.
-
-            Compatibility layer for pygeos-based ``sindex.query_bulk``.
-
-            Iterates over ``geometry`` and queries index.
-            This operation is not vectorized and may be slow.
-            Use PyGEOS with ``query_bulk`` for speed.
-
-            Parameters
-            ----------
-            geometry : {GeoSeries, GeometryArray, numpy.array of PyGEOS geometries}
-                Accepts GeoPandas geometry iterables (GeoSeries, GeometryArray)
-                or a numpy array of PyGEOS geometries.
-            predicate : {None, 'intersects', 'within', 'contains', 'overlaps', \
-'crosses', 'touches'}, optional
-                If predicate is provided, the input geometries are tested using
-                the predicate function against each item in the tree whose extent
-                intersects the envelope of the each input geometry:
-                predicate(input_geometry, tree_geometry).  If possible, prepared
-                geometries are used to help speed up the predicate operation.
-            sort : bool, default False
-                If True, results sorted lexicographically using
-                geometry's indexes as the primary key and the sindex's indexes as the
-                secondary key. If False, no additional sorting is applied.
-
-            Returns
-            -------
-            ndarray with shape (2, n)
-                The first subarray contains input geometry integer indexes.
-                The second subarray contains tree geometry integer indexes.
-
-            Examples
-            --------
-            >>> from shapely.geometry import Point, box
-            >>> s = geopandas.GeoSeries(geopandas.points_from_xy(range(10), range(10)))
-            >>> s
-            0    POINT (0.00000 0.00000)
-            1    POINT (1.00000 1.00000)
-            2    POINT (2.00000 2.00000)
-            3    POINT (3.00000 3.00000)
-            4    POINT (4.00000 4.00000)
-            5    POINT (5.00000 5.00000)
-            6    POINT (6.00000 6.00000)
-            7    POINT (7.00000 7.00000)
-            8    POINT (8.00000 8.00000)
-            9    POINT (9.00000 9.00000)
-            dtype: geometry
-            >>> s2 = geopandas.GeoSeries([box(2, 2, 4, 4), box(5, 5, 6, 6)])
-            >>> s2
-            0    POLYGON ((4.00000 2.00000, 4.00000 4.00000, 2....
-            1    POLYGON ((6.00000 5.00000, 6.00000 6.00000, 5....
-            dtype: geometry
-
-            >>> s.sindex.query_bulk(s2)
-            array([[0, 0, 0, 1, 1],
-                   [2, 3, 4, 5, 6]])
-
-            >>> s.sindex.query_bulk(s2, predicate="contains")
-            array([[0],
-                   [3]])
-            """
             # Iterates over geometry, applying func.
             tree_index = []
             input_geometry_index = []
@@ -310,69 +450,13 @@ if compat.HAS_RTREE:
                 input_geometry_index.extend([i] * len(res))
             return np.vstack([input_geometry_index, tree_index])
 
+        @doc(BaseSpatialIndex.intersection)
         def intersection(self, coordinates):
-            """Wrapper for rtree.index.Index.intersection.
-
-            Parameters
-            ----------
-            coordinates : sequence or array
-                Sequence of the form (min_x, min_y, max_x, max_y)
-                to query a rectangle or (x, y) to query a point.
-
-            Examples
-            --------
-            >>> from shapely.geometry import Point, box
-            >>> s = geopandas.GeoSeries(geopandas.points_from_xy(range(10), range(10)))
-            >>> s
-            0    POINT (0.00000 0.00000)
-            1    POINT (1.00000 1.00000)
-            2    POINT (2.00000 2.00000)
-            3    POINT (3.00000 3.00000)
-            4    POINT (4.00000 4.00000)
-            5    POINT (5.00000 5.00000)
-            6    POINT (6.00000 6.00000)
-            7    POINT (7.00000 7.00000)
-            8    POINT (8.00000 8.00000)
-            9    POINT (9.00000 9.00000)
-            dtype: geometry
-
-            >>> s.sindex.intersection(box(1, 1, 3, 3).bounds)
-            array([1, 2, 3])
-
-            Alternatively, you can use ``query``:
-
-            >>> s.sindex.query(box(1, 1, 3, 3))
-            array([1, 2, 3])
-
-            """
             return super().intersection(coordinates, objects=False)
 
+        @doc(BaseSpatialIndex.size)
         @property
         def size(self):
-            """Size of the spatial index
-
-            Number of leaves (input geometries) in the index.
-
-            Examples
-            --------
-            >>> from shapely.geometry import Point
-            >>> s = geopandas.GeoSeries(geopandas.points_from_xy(range(10), range(10)))
-            >>> s
-            0    POINT (0.00000 0.00000)
-            1    POINT (1.00000 1.00000)
-            2    POINT (2.00000 2.00000)
-            3    POINT (3.00000 3.00000)
-            4    POINT (4.00000 4.00000)
-            5    POINT (5.00000 5.00000)
-            6    POINT (6.00000 6.00000)
-            7    POINT (7.00000 7.00000)
-            8    POINT (8.00000 8.00000)
-            9    POINT (9.00000 9.00000)
-            dtype: geometry
-
-            >>> s.sindex.size
-            10
-            """
             if hasattr(self, "_size"):
                 size = self._size
             else:
@@ -383,34 +467,9 @@ if compat.HAS_RTREE:
                 self._size = size
             return size
 
+        @doc(BaseSpatialIndex.is_empty)
         @property
         def is_empty(self):
-            """Check if the spatial index is empty
-
-            Examples
-            --------
-            >>> from shapely.geometry import Point
-            >>> s = geopandas.GeoSeries(geopandas.points_from_xy(range(10), range(10)))
-            >>> s
-            0    POINT (0.00000 0.00000)
-            1    POINT (1.00000 1.00000)
-            2    POINT (2.00000 2.00000)
-            3    POINT (3.00000 3.00000)
-            4    POINT (4.00000 4.00000)
-            5    POINT (5.00000 5.00000)
-            6    POINT (6.00000 6.00000)
-            7    POINT (7.00000 7.00000)
-            8    POINT (8.00000 8.00000)
-            9    POINT (9.00000 9.00000)
-            dtype: geometry
-
-            >>> s.sindex.is_empty
-            False
-
-            >>> s2 = geopandas.GeoSeries()
-            >>> s2.sindex.is_empty
-            True
-            """
             return self.geometries.size == 0 or self.size == 0
 
         def __len__(self):
@@ -464,61 +523,8 @@ if compat.HAS_PYGEOS:
             """
             return pygeos.strtree.VALID_PREDICATES | set([None])
 
+        @doc(BaseSpatialIndex.query)
         def query(self, geometry, predicate=None, sort=False):
-            """
-            Return the index of all geometries in the tree with extents
-            that intersect the envelope of the input geometry.
-
-            Wrapper for pygeos.STRtree.query.
-
-            This also ensures a deterministic (sorted) order for the results.
-
-            Parameters
-            ----------
-            geometry : single PyGEOS or shapely geometry
-            predicate : {None, 'intersects', 'within', 'contains', \
-'overlaps', 'crosses', 'touches'}, optional
-                If predicate is provided, the input geometry is tested
-                using the predicate function against each item in the
-                tree whose extent intersects the envelope of the input
-                geometry: predicate(input_geometry, tree_geometry).
-            sort : bool, default False
-                If True, the results will be sorted in ascending order.
-                If False, results are often sorted but there is no guarantee.
-
-            Returns
-            -------
-            matches : ndarray of shape (n_results, )
-                Integer indices for matching geometries from the spatial index.
-
-            Notes
-            -----
-            See PyGEOS.strtree documentation for more information.
-
-            Examples
-            --------
-            >>> from shapely.geometry import Point, box
-            >>> s = geopandas.GeoSeries(geopandas.points_from_xy(range(10), range(10)))
-            >>> s
-            0    POINT (0.00000 0.00000)
-            1    POINT (1.00000 1.00000)
-            2    POINT (2.00000 2.00000)
-            3    POINT (3.00000 3.00000)
-            4    POINT (4.00000 4.00000)
-            5    POINT (5.00000 5.00000)
-            6    POINT (6.00000 6.00000)
-            7    POINT (7.00000 7.00000)
-            8    POINT (8.00000 8.00000)
-            9    POINT (9.00000 9.00000)
-            dtype: geometry
-
-            >>> s.sindex.query(box(1, 1, 3, 3))
-            array([1, 2, 3])
-
-            >>> s.sindex.query(box(1, 1, 3, 3), predicate="contains")
-            array([2])
-            """
-
             if predicate not in self.valid_query_predicates:
                 raise ValueError(
                     "Got `predicate` = `{}`; ".format(predicate)
@@ -537,81 +543,8 @@ if compat.HAS_PYGEOS:
 
             return matches
 
+        @doc(BaseSpatialIndex.query_bulk)
         def query_bulk(self, geometry, predicate=None, sort=False):
-            """
-            Returns all combinations of each input geometry and geometries in
-            the tree where the envelope of each input geometry intersects with
-            the envelope of a tree geometry.
-
-            Wrapper to expose underlaying pygeos objects to pygeos.query_bulk.
-
-            In the context of a spatial join, input geometries are the “left”
-            geometries that determine the order of the results, and tree geometries
-            are “right” geometries that are joined against the left geometries.
-            This effectively performs an inner join, where only those combinations
-            of geometries that can be joined based on envelope overlap or optional
-            predicate are returned.
-
-            This also allows a deterministic (sorted) order for the results.
-
-
-            Parameters
-            ----------
-            geometry : {GeoSeries, GeometryArray, numpy.array of PyGEOS geometries}
-                Accepts GeoPandas geometry iterables (GeoSeries, GeometryArray)
-                or a numpy array of PyGEOS geometries.
-            predicate : {None, 'intersects', 'within', 'contains', \
-'overlaps', 'crosses', 'touches'}, optional
-                If predicate is provided, the input geometry is tested
-                using the predicate function against each item in the
-                index whose extent intersects the envelope of the input geometry:
-                predicate(input_geometry, tree_geometry).
-            sort : bool, default False
-                If True, results sorted lexicographically using
-                geometry's indexes as the primary key and the sindex's indexes as the
-                secondary key. If False, no additional sorting is applied.
-
-            Returns
-            -------
-            ndarray with shape (2, n)
-                The first subarray contains input geometry integer indexes.
-                The second subarray contains tree geometry integer indexes.
-
-            Notes
-            -----
-            See PyGEOS.strtree documentation for more information.
-
-            Examples
-            --------
-            >>> from shapely.geometry import Point, box
-            >>> s = geopandas.GeoSeries(geopandas.points_from_xy(range(10), range(10)))
-            >>> s
-            0    POINT (0.00000 0.00000)
-            1    POINT (1.00000 1.00000)
-            2    POINT (2.00000 2.00000)
-            3    POINT (3.00000 3.00000)
-            4    POINT (4.00000 4.00000)
-            5    POINT (5.00000 5.00000)
-            6    POINT (6.00000 6.00000)
-            7    POINT (7.00000 7.00000)
-            8    POINT (8.00000 8.00000)
-            9    POINT (9.00000 9.00000)
-            dtype: geometry
-            >>> s2 = geopandas.GeoSeries([box(2, 2, 4, 4), box(5, 5, 6, 6)])
-            >>> s2
-            0    POLYGON ((4.00000 2.00000, 4.00000 4.00000, 2....
-            1    POLYGON ((6.00000 5.00000, 6.00000 6.00000, 5....
-            dtype: geometry
-
-            >>> s.sindex.query_bulk(s2)
-            array([[0, 0, 0, 1, 1],
-                   [2, 3, 4, 5, 6]])
-
-            >>> s.sindex.query_bulk(s2, predicate="contains")
-            array([[0],
-                   [3]])
-            """
-
             if predicate not in self.valid_query_predicates:
                 raise ValueError(
                     "Got `predicate` = `{}`, `predicate` must be one of {}".format(
@@ -635,41 +568,8 @@ if compat.HAS_PYGEOS:
 
             return res
 
+        @doc(BaseSpatialIndex.intersection)
         def intersection(self, coordinates):
-            """Wrapper for pygeos.query that uses the RTree API.
-
-            Compatibility wrapper, use ``query`` instead.
-
-            Parameters
-            ----------
-            coordinates : sequence or array
-                Sequence of the form (min_x, min_y, max_x, max_y)
-                to query a rectangle or (x, y) to query a point.
-
-            Examples
-            --------
-            >>> from shapely.geometry import Point, box
-            >>> s = geopandas.GeoSeries(geopandas.points_from_xy(range(10), range(10)))
-            >>> s
-            0    POINT (0.00000 0.00000)
-            1    POINT (1.00000 1.00000)
-            2    POINT (2.00000 2.00000)
-            3    POINT (3.00000 3.00000)
-            4    POINT (4.00000 4.00000)
-            5    POINT (5.00000 5.00000)
-            6    POINT (6.00000 6.00000)
-            7    POINT (7.00000 7.00000)
-            8    POINT (8.00000 8.00000)
-            9    POINT (9.00000 9.00000)
-            dtype: geometry
-
-            >>> s.sindex.query(box(1, 1, 3, 3))
-            array([1, 2, 3])
-
-            >>> s.sindex.intersection(box(1, 1, 3, 3).bounds)
-            array([1, 2, 3])
-
-            """
             # convert bounds to geometry
             # the old API uses tuples of bound, but pygeos uses geometries
             try:
@@ -698,60 +598,12 @@ if compat.HAS_PYGEOS:
 
             return indexes
 
+        @doc(BaseSpatialIndex.size)
         @property
         def size(self):
-            """Size of the spatial index
-
-            Number of leaves (input geometries) in the index.
-
-            Examples
-            --------
-            >>> from shapely.geometry import Point
-            >>> s = geopandas.GeoSeries(geopandas.points_from_xy(range(10), range(10)))
-            >>> s
-            0    POINT (0.00000 0.00000)
-            1    POINT (1.00000 1.00000)
-            2    POINT (2.00000 2.00000)
-            3    POINT (3.00000 3.00000)
-            4    POINT (4.00000 4.00000)
-            5    POINT (5.00000 5.00000)
-            6    POINT (6.00000 6.00000)
-            7    POINT (7.00000 7.00000)
-            8    POINT (8.00000 8.00000)
-            9    POINT (9.00000 9.00000)
-            dtype: geometry
-
-            >>> s.sindex.size
-            10
-            """
             return len(self)
 
+        @doc(BaseSpatialIndex.is_empty)
         @property
         def is_empty(self):
-            """Check if the spatial index is empty
-
-            Examples
-            --------
-            >>> from shapely.geometry import Point
-            >>> s = geopandas.GeoSeries(geopandas.points_from_xy(range(10), range(10)))
-            >>> s
-            0    POINT (0.00000 0.00000)
-            1    POINT (1.00000 1.00000)
-            2    POINT (2.00000 2.00000)
-            3    POINT (3.00000 3.00000)
-            4    POINT (4.00000 4.00000)
-            5    POINT (5.00000 5.00000)
-            6    POINT (6.00000 6.00000)
-            7    POINT (7.00000 7.00000)
-            8    POINT (8.00000 8.00000)
-            9    POINT (9.00000 9.00000)
-            dtype: geometry
-
-            >>> s.sindex.is_empty
-            False
-
-            >>> s2 = geopandas.GeoSeries()
-            >>> s2.sindex.is_empty
-            True
-            """
             return len(self) == 0
diff --git a/geopandas/tools/overlay.py b/geopandas/tools/overlay.py
index 9aebbb39..5b95c922 100644
--- a/geopandas/tools/overlay.py
+++ b/geopandas/tools/overlay.py
@@ -137,7 +137,7 @@ def _overlay_union(df1, df2):
     return dfunion.reindex(columns=columns)
 
 
-def overlay(df1, df2, how="intersection", keep_geom_type=None):
+def overlay(df1, df2, how="intersection", keep_geom_type=None, make_valid=True):
     """Perform spatial overlay between two GeoDataFrames.
 
     Currently only supports data GeoDataFrames with uniform geometry types,
@@ -159,6 +159,9 @@ def overlay(df1, df2, how="intersection", keep_geom_type=None):
         if False, return all resulting geometries. Default is None,
         which will set keep_geom_type to True but warn upon dropping
         geometries.
+    make_valid : bool, default True
+        If True, any invalid input geometries are corrected with a call to `buffer(0)`,
+        if False, a `ValueError` is raised if any input geometries are invalid.
 
     Returns
     -------
@@ -179,9 +182,9 @@ def overlay(df1, df2, how="intersection", keep_geom_type=None):
     >>> geopandas.overlay(df1, df2, how='union')
        df1_data  df2_data                                           geometry
     0       1.0       1.0  POLYGON ((1.00000 2.00000, 2.00000 2.00000, 2....
-    1       2.0       1.0  POLYGON ((2.00000 2.00000, 2.00000 3.00000, 3....
+    1       2.0       1.0  POLYGON ((3.00000 2.00000, 2.00000 2.00000, 2....
     2       2.0       2.0  POLYGON ((3.00000 4.00000, 4.00000 4.00000, 4....
-    3       1.0       NaN  POLYGON ((0.00000 0.00000, 0.00000 2.00000, 1....
+    3       1.0       NaN  POLYGON ((2.00000 1.00000, 2.00000 0.00000, 0....
     4       2.0       NaN  MULTIPOLYGON (((3.00000 3.00000, 4.00000 3.000...
     5       NaN       1.0  MULTIPOLYGON (((2.00000 2.00000, 3.00000 2.000...
     6       NaN       2.0  POLYGON ((3.00000 4.00000, 3.00000 5.00000, 5....
@@ -189,27 +192,27 @@ def overlay(df1, df2, how="intersection", keep_geom_type=None):
     >>> geopandas.overlay(df1, df2, how='intersection')
        df1_data  df2_data                                           geometry
     0         1         1  POLYGON ((1.00000 2.00000, 2.00000 2.00000, 2....
-    1         2         1  POLYGON ((2.00000 2.00000, 2.00000 3.00000, 3....
+    1         2         1  POLYGON ((3.00000 2.00000, 2.00000 2.00000, 2....
     2         2         2  POLYGON ((3.00000 4.00000, 4.00000 4.00000, 4....
 
     >>> geopandas.overlay(df1, df2, how='symmetric_difference')
        df1_data  df2_data                                           geometry
-    0       1.0       NaN  POLYGON ((0.00000 0.00000, 0.00000 2.00000, 1....
+    0       1.0       NaN  POLYGON ((2.00000 1.00000, 2.00000 0.00000, 0....
     1       2.0       NaN  MULTIPOLYGON (((3.00000 3.00000, 4.00000 3.000...
     2       NaN       1.0  MULTIPOLYGON (((2.00000 2.00000, 3.00000 2.000...
     3       NaN       2.0  POLYGON ((3.00000 4.00000, 3.00000 5.00000, 5....
 
     >>> geopandas.overlay(df1, df2, how='difference')
                                                 geometry  df1_data
-    0  POLYGON ((0.00000 0.00000, 0.00000 2.00000, 1....         1
+    0  POLYGON ((2.00000 1.00000, 2.00000 0.00000, 0....         1
     1  MULTIPOLYGON (((2.00000 3.00000, 2.00000 4.000...         2
 
     >>> geopandas.overlay(df1, df2, how='identity')
        df1_data  df2_data                                           geometry
     0       1.0       1.0  POLYGON ((1.00000 2.00000, 2.00000 2.00000, 2....
-    1       2.0       1.0  POLYGON ((2.00000 2.00000, 2.00000 3.00000, 3....
+    1       2.0       1.0  POLYGON ((3.00000 2.00000, 2.00000 2.00000, 2....
     2       2.0       2.0  POLYGON ((3.00000 4.00000, 4.00000 4.00000, 4....
-    3       1.0       NaN  POLYGON ((0.00000 0.00000, 0.00000 2.00000, 1....
+    3       1.0       NaN  POLYGON ((2.00000 1.00000, 2.00000 0.00000, 0....
     4       2.0       NaN  MULTIPOLYGON (((3.00000 3.00000, 4.00000 3.000...
 
     See also
@@ -262,12 +265,24 @@ def overlay(df1, df2, how="intersection", keep_geom_type=None):
             )
 
     # Computations
-    df1 = df1.copy()
-    df2 = df2.copy()
-    if df1.geom_type.isin(polys).all():
-        df1[df1._geometry_column_name] = df1.geometry.buffer(0)
-    if df2.geom_type.isin(polys).all():
-        df2[df2._geometry_column_name] = df2.geometry.buffer(0)
+    def _make_valid(df):
+        df = df.copy()
+        if df.geom_type.isin(polys).all():
+            mask = ~df.geometry.is_valid
+            col = df._geometry_column_name
+            if make_valid:
+                df.loc[mask, col] = df.loc[mask, col].buffer(0)
+            elif mask.any():
+                raise ValueError(
+                    "You have passed make_valid=False along with "
+                    f"{mask.sum()} invalid input geometries. "
+                    "Use make_valid=True or make sure that all geometries "
+                    "are valid before using overlay."
+                )
+        return df
+
+    df1 = _make_valid(df1)
+    df2 = _make_valid(df2)
 
     with warnings.catch_warnings():  # CRS checked above, supress array-level warning
         warnings.filterwarnings("ignore", message="CRS mismatch between the CRS")


Test patch:
diff --git a/geopandas/io/tests/test_arrow.py b/geopandas/io/tests/test_arrow.py
index 86450487..51c1db45 100644
--- a/geopandas/io/tests/test_arrow.py
+++ b/geopandas/io/tests/test_arrow.py
@@ -16,7 +16,6 @@ from geopandas.io.arrow import (
     _create_metadata,
     _decode_metadata,
     _encode_metadata,
-    _encode_wkb,
     _validate_dataframe,
     _validate_metadata,
     METADATA_VERSION,
@@ -174,20 +173,6 @@ def test_validate_metadata_invalid(metadata, error):
         _validate_metadata(metadata)
 
 
-def test_encode_wkb():
-    test_dataset = "naturalearth_lowres"
-    df = read_file(get_path(test_dataset))
-
-    encoded = _encode_wkb(df)
-
-    # make sure original is not modified
-    assert isinstance(df, GeoDataFrame)
-    assert (
-        encoded.geometry.iloc[0][:16]
-        == b"\x01\x06\x00\x00\x00\x03\x00\x00\x00\x01\x03\x00\x00\x00\x01\x00"
-    )
-
-
 # TEMPORARY: used to determine if pyarrow fails for roundtripping pandas data
 # without geometries
 def test_pandas_parquet_roundtrip1(tmpdir):
diff --git a/geopandas/tests/test_crs.py b/geopandas/tests/test_crs.py
index 14f4f719..ea28f070 100644
--- a/geopandas/tests/test_crs.py
+++ b/geopandas/tests/test_crs.py
@@ -581,6 +581,16 @@ class TestGeometryArrayCRS:
         result = s.apply(lambda x: x.centroid)
         assert result.crs == 27700
 
+    def test_apply_geodataframe(self):
+        df = GeoDataFrame({"col1": [0, 1]}, geometry=self.geoms, crs=27700)
+        assert df.crs == 27700
+
+        # apply preserves the CRS if the result is a GeoDataFrame
+        result = df.apply(lambda col: col, axis=0)
+        assert result.crs == 27700
+        result = df.apply(lambda row: row, axis=1)
+        assert result.crs == 27700
+
 
 class TestSetCRS:
     @pytest.mark.parametrize(
diff --git a/geopandas/tests/test_dissolve.py b/geopandas/tests/test_dissolve.py
index cc5c6eef..d50d25fe 100644
--- a/geopandas/tests/test_dissolve.py
+++ b/geopandas/tests/test_dissolve.py
@@ -3,6 +3,7 @@ import pandas as pd
 
 import geopandas
 from geopandas import GeoDataFrame, read_file
+from geopandas import _compat as compat
 
 from pandas.testing import assert_frame_equal
 import pytest
@@ -128,3 +129,175 @@ def test_dissolve_none_mean(nybb_polydf):
         crs=nybb_polydf.crs,
     )
     assert_frame_equal(expected, test, check_column_type=False)
+
+
+def test_dissolve_level():
+    gdf = geopandas.GeoDataFrame(
+        {
+            "a": [1, 1, 2, 2],
+            "b": [3, 4, 4, 4],
+            "c": [3, 4, 5, 6],
+            "geometry": geopandas.array.from_wkt(
+                ["POINT (0 0)", "POINT (1 1)", "POINT (2 2)", "POINT (3 3)"]
+            ),
+        }
+    ).set_index(["a", "b", "c"])
+
+    expected_a = geopandas.GeoDataFrame(
+        {
+            "a": [1, 2],
+            "geometry": geopandas.array.from_wkt(
+                ["MULTIPOINT (0 0, 1 1)", "MULTIPOINT (2 2, 3 3)"]
+            ),
+        }
+    ).set_index("a")
+    expected_b = geopandas.GeoDataFrame(
+        {
+            "b": [3, 4],
+            "geometry": geopandas.array.from_wkt(
+                ["POINT (0 0)", "MULTIPOINT (1 1, 2 2, 3 3)"]
+            ),
+        }
+    ).set_index("b")
+    expected_ab = geopandas.GeoDataFrame(
+        {
+            "a": [1, 1, 2],
+            "b": [3, 4, 4],
+            "geometry": geopandas.array.from_wkt(
+                ["POINT (0 0)", "POINT (1 1)", "MULTIPOINT (2 2, 3 3)"]
+            ),
+        }
+    ).set_index(["a", "b"])
+
+    assert_frame_equal(expected_a, gdf.dissolve(level=0))
+    assert_frame_equal(expected_a, gdf.dissolve(level="a"))
+    assert_frame_equal(expected_b, gdf.dissolve(level=1))
+    assert_frame_equal(expected_b, gdf.dissolve(level="b"))
+    assert_frame_equal(expected_ab, gdf.dissolve(level=[0, 1]))
+    assert_frame_equal(expected_ab, gdf.dissolve(level=["a", "b"]))
+
+
+def test_dissolve_sort():
+    gdf = geopandas.GeoDataFrame(
+        {
+            "a": [2, 1, 1],
+            "geometry": geopandas.array.from_wkt(
+                ["POINT (0 0)", "POINT (1 1)", "POINT (2 2)"]
+            ),
+        }
+    )
+
+    expected_unsorted = geopandas.GeoDataFrame(
+        {
+            "a": [2, 1],
+            "geometry": geopandas.array.from_wkt(
+                ["POINT (0 0)", "MULTIPOINT (1 1, 2 2)"]
+            ),
+        }
+    ).set_index("a")
+    expected_sorted = expected_unsorted.sort_index()
+
+    assert_frame_equal(expected_sorted, gdf.dissolve("a"))
+    assert_frame_equal(expected_unsorted, gdf.dissolve("a", sort=False))
+
+
+@pytest.mark.skipif(
+    not compat.PANDAS_GE_025,
+    reason="'observed' param behavior changed in pandas 0.25.0",
+)
+def test_dissolve_categorical():
+    gdf = geopandas.GeoDataFrame(
+        {
+            "cat": pd.Categorical(["a", "a", "b", "b"]),
+            "noncat": [1, 1, 1, 2],
+            "to_agg": [1, 2, 3, 4],
+            "geometry": geopandas.array.from_wkt(
+                ["POINT (0 0)", "POINT (1 1)", "POINT (2 2)", "POINT (3 3)"]
+            ),
+        }
+    )
+
+    # when observed=False we get an additional observation
+    # that wasn't in the original data
+    expected_gdf_observed_false = geopandas.GeoDataFrame(
+        {
+            "cat": pd.Categorical(["a", "a", "b", "b"]),
+            "noncat": [1, 2, 1, 2],
+            "geometry": geopandas.array.from_wkt(
+                [
+                    "MULTIPOINT (0 0, 1 1)",
+                    None,
+                    "POINT (2 2)",
+                    "POINT (3 3)",
+                ]
+            ),
+            "to_agg": [1, None, 3, 4],
+        }
+    ).set_index(["cat", "noncat"])
+
+    # when observed=True we do not get any additional observations
+    expected_gdf_observed_true = geopandas.GeoDataFrame(
+        {
+            "cat": pd.Categorical(["a", "b", "b"]),
+            "noncat": [1, 1, 2],
+            "geometry": geopandas.array.from_wkt(
+                ["MULTIPOINT (0 0, 1 1)", "POINT (2 2)", "POINT (3 3)"]
+            ),
+            "to_agg": [1, 3, 4],
+        }
+    ).set_index(["cat", "noncat"])
+
+    assert_frame_equal(expected_gdf_observed_false, gdf.dissolve(["cat", "noncat"]))
+    assert_frame_equal(
+        expected_gdf_observed_true, gdf.dissolve(["cat", "noncat"], observed=True)
+    )
+
+
+@pytest.mark.skipif(
+    not compat.PANDAS_GE_11, reason="dropna groupby kwarg added in pandas 1.1.0"
+)
+def test_dissolve_dropna():
+    gdf = geopandas.GeoDataFrame(
+        {
+            "a": [1, 1, None],
+            "geometry": geopandas.array.from_wkt(
+                ["POINT (0 0)", "POINT (1 1)", "POINT (2 2)"]
+            ),
+        }
+    )
+
+    expected_with_na = geopandas.GeoDataFrame(
+        {
+            "a": [1.0, np.nan],
+            "geometry": geopandas.array.from_wkt(
+                ["MULTIPOINT (0 0, 1 1)", "POINT (2 2)"]
+            ),
+        }
+    ).set_index("a")
+    expected_no_na = geopandas.GeoDataFrame(
+        {
+            "a": [1.0],
+            "geometry": geopandas.array.from_wkt(["MULTIPOINT (0 0, 1 1)"]),
+        }
+    ).set_index("a")
+
+    assert_frame_equal(expected_with_na, gdf.dissolve("a", dropna=False))
+    assert_frame_equal(expected_no_na, gdf.dissolve("a"))
+
+
+@pytest.mark.skipif(
+    compat.PANDAS_GE_11, reason="dropna warning is only emitted if pandas < 1.1.0"
+)
+def test_dissolve_dropna_warn(nybb_polydf):
+    # No warning with default params
+    with pytest.warns(None) as record:
+        nybb_polydf.dissolve()
+
+    for r in record:
+        assert "dropna kwarg is not supported" not in str(r.message)
+
+    # Warning is emitted with non-default dropna value
+    with pytest.warns(
+        UserWarning, match="dropna kwarg is not supported for pandas < 1.1.0"
+    ):
+        nybb_polydf.dissolve(dropna=False)
diff --git a/geopandas/tests/test_geodataframe.py b/geopandas/tests/test_geodataframe.py
index 47c38aa8..62cb23e9 100644
--- a/geopandas/tests/test_geodataframe.py
+++ b/geopandas/tests/test_geodataframe.py
@@ -706,6 +706,44 @@ class TestDataFrame:
             assert self.df.estimate_utm_crs() == CRS("EPSG:32618")
             assert self.df.estimate_utm_crs("NAD83") == CRS("EPSG:26918")
 
+    def test_to_wkb(self):
+        wkbs0 = [
+            (
+                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)
+            (
+                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 = [
+            (
+                b"\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00"
+                b"\x00\x00@\x00\x00\x00\x00\x00\x00\x00@"
+            ),  # POINT (2 2)
+            (
+                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)
+        gdf = GeoDataFrame({"geom_col0": gs0, "geom_col1": gs1})
+
+        expected_df = pd.DataFrame({"geom_col0": wkbs0, "geom_col1": wkbs1})
+        assert_frame_equal(expected_df, gdf.to_wkb())
+
+    def test_to_wkt(self):
+        wkts0 = ["POINT (0 0)", "POINT (1 1)"]
+        wkts1 = ["POINT (2 2)", "POINT (3 3)"]
+        gs0 = GeoSeries.from_wkt(wkts0)
+        gs1 = GeoSeries.from_wkt(wkts1)
+        gdf = GeoDataFrame({"gs0": gs0, "gs1": gs1})
+
+        expected_df = pd.DataFrame({"gs0": wkts0, "gs1": wkts1})
+        assert_frame_equal(expected_df, gdf.to_wkt())
+
 
 def check_geodataframe(df, geometry_column="geometry"):
     assert isinstance(df, GeoDataFrame)
diff --git a/geopandas/tests/test_geoseries.py b/geopandas/tests/test_geoseries.py
index bdb44d54..80ffbbc0 100644
--- a/geopandas/tests/test_geoseries.py
+++ b/geopandas/tests/test_geoseries.py
@@ -22,6 +22,7 @@ from shapely.geometry.base import BaseGeometry
 from geopandas import GeoSeries, GeoDataFrame
 from geopandas._compat import PYPROJ_LT_3
 from geopandas.array import GeometryArray, GeometryDtype
+from geopandas.testing import assert_geoseries_equal
 
 from geopandas.tests.util import geom_equals
 from pandas.testing import assert_series_equal
@@ -282,6 +283,45 @@ class TestSeries:
         reprojected_dict = self.g3.to_crs({"proj": "utm", "zone": "30N"})
         assert np.all(reprojected_string.geom_almost_equals(reprojected_dict))
 
+    def test_from_wkb(self):
+        assert_geoseries_equal(self.g1, GeoSeries.from_wkb([self.t1.wkb, self.sq.wkb]))
+
+    def test_from_wkb_series(self):
+        s = pd.Series([self.t1.wkb, self.sq.wkb], index=[1, 2])
+        expected = self.g1.copy()
+        expected.index = pd.Index([1, 2])
+        assert_geoseries_equal(expected, GeoSeries.from_wkb(s))
+
+    def test_from_wkb_series_with_index(self):
+        index = [0]
+        s = pd.Series([self.t1.wkb, self.sq.wkb], index=[0, 2])
+        expected = self.g1.reindex(index)
+        assert_geoseries_equal(expected, GeoSeries.from_wkb(s, index=index))
+
+    def test_from_wkt(self):
+        assert_geoseries_equal(self.g1, GeoSeries.from_wkt([self.t1.wkt, self.sq.wkt]))
+
+    def test_from_wkt_series(self):
+        s = pd.Series([self.t1.wkt, self.sq.wkt], index=[1, 2])
+        expected = self.g1.copy()
+        expected.index = pd.Index([1, 2])
+        assert_geoseries_equal(expected, GeoSeries.from_wkt(s))
+
+    def test_from_wkt_series_with_index(self):
+        index = [0]
+        s = pd.Series([self.t1.wkt, self.sq.wkt], index=[0, 2])
+        expected = self.g1.reindex(index)
+        assert_geoseries_equal(expected, GeoSeries.from_wkt(s, index=index))
+
+    def test_to_wkb(self):
+        assert_series_equal(pd.Series([self.t1.wkb, self.sq.wkb]), self.g1.to_wkb())
+        assert_series_equal(
+            pd.Series([self.t1.wkb_hex, self.sq.wkb_hex]), self.g1.to_wkb(hex=True)
+        )
+
+    def test_to_wkt(self):
+        assert_series_equal(pd.Series([self.t1.wkt, self.sq.wkt]), self.g1.to_wkt())
+
 
 def test_missing_values_empty_warning():
     s = GeoSeries([Point(1, 1), None, np.nan, BaseGeometry(), Polygon()])
diff --git a/geopandas/tests/test_overlay.py b/geopandas/tests/test_overlay.py
index b8281122..f0c095a6 100644
--- a/geopandas/tests/test_overlay.py
+++ b/geopandas/tests/test_overlay.py
@@ -2,7 +2,7 @@ import os
 
 import pandas as pd
 
-from shapely.geometry import Point, Polygon, LineString, GeometryCollection
+from shapely.geometry import Point, Polygon, LineString, GeometryCollection, box
 from fiona.errors import DriverError
 
 import geopandas
@@ -571,3 +571,24 @@ def test_keep_geom_type_geometry_collection():
     intersection = overlay(df1, df2, keep_geom_type=False)
     assert len(intersection) == 1
     assert (intersection.geom_type == "GeometryCollection").all()
+
+
+@pytest.mark.parametrize("make_valid", [True, False])
+def test_overlap_make_valid(make_valid):
+    bowtie = Polygon([(1, 1), (9, 9), (9, 1), (1, 9), (1, 1)])
+    assert not bowtie.is_valid
+    fixed_bowtie = bowtie.buffer(0)
+    assert fixed_bowtie.is_valid
+
+    df1 = GeoDataFrame({"col1": ["region"], "geometry": GeoSeries([box(0, 0, 10, 10)])})
+    df_bowtie = GeoDataFrame(
+        {"col1": ["invalid", "valid"], "geometry": GeoSeries([bowtie, fixed_bowtie])}
+    )
+
+    if make_valid:
+        df_overlay_bowtie = overlay(df1, df_bowtie, make_valid=make_valid)
+        assert df_overlay_bowtie.at[0, "geometry"].equals(fixed_bowtie)
+        assert df_overlay_bowtie.at[1, "geometry"].equals(fixed_bowtie)
+    else:
+        with pytest.raises(ValueError, match="1 invalid input geometries"):
+            overlay(df1, df_bowtie, make_valid=make_valid)
diff --git a/geopandas/tests/test_sindex.py b/geopandas/tests/test_sindex.py
index 9ef56961..7d8d35c8 100644
--- a/geopandas/tests/test_sindex.py
+++ b/geopandas/tests/test_sindex.py
@@ -718,3 +718,16 @@ class TestPygeosInterface:
 
         res = world.sindex.query_bulk(capitals.geometry, predicate)
         assert res.shape == expected_shape
+
+
+@pytest.mark.skipif(not compat.HAS_RTREE, reason="no rtree installed")
+def test_old_spatial_index_deprecated():
+    t1 = Polygon([(0, 0), (1, 0), (1, 1)])
+    t2 = Polygon([(0, 0), (1, 1), (0, 1)])
+
+    stream = ((i, item.bounds, None) for i, item in enumerate([t1, t2]))
+
+    with pytest.warns(FutureWarning):
+        idx = geopandas.sindex.SpatialIndex(stream)
+
+    assert list(idx.intersection((0, 0, 1, 1))) == [0, 1]