Repository: geopandas/geopandas

Issue:
ENH: add support for to_feather / read_feather
This would build on #1180 to enable I/O with `feather` format as of `pyarrow` >= 0.17.

Once this lands, the standalone [`geofeather`](https://github.com/brendan-ward/geofeather) package can be deprecated and replaced with this instead.

Discussion:
jorisvandenbossche: Thanks for the review!

I still need to further update the tests (and properly skip for older pyarrow versions).
jorisvandenbossche: @brendan-ward this is ready for another look.

I added benchmarks for Feather, but also refactored the IO benchmarks a bit to combine those with Parquet (the setup was basically the same).

Running them now gives:

```
[ 60.00%] ··· io.BenchFrame.time_read                                                                                                                                                                            ok
[ 60.00%] ··· ================ ===========
                file_format               
              ---------------- -----------
               ESRI Shapefile   367±300ms 
                  GeoJSON       619±100ms 
                    GPKG        615±100ms 
                  Parquet        20.8±3ms 
                  Feather        19.5±5ms 
              ================ ===========

[ 70.00%] ··· io.BenchFrame.time_write                                                                                                                                                                           ok
[ 70.00%] ··· ================ ============
                file_format                
              ---------------- ------------
               ESRI Shapefile   1.35±0.08s 
                  GeoJSON       1.33±0.05s 
                    GPKG        1.13±0.2s  
                  Parquet       33.0±10ms  
                  Feather       28.8±20ms  
              ================ ============

[ 80.00%] ··· io.BenchSeries.time_read_series                                                                                                                                                                    ok
[ 80.00%] ··· ================ ==========
                file_format              
              ---------------- ----------
               ESRI Shapefile   463±60ms 
                  GeoJSON       519±40ms 
                    GPKG        512±40ms 
              ================ ==========

[ 90.00%] ··· io.BenchSeries.time_read_series_from_frame                                                                                                                                                         ok
[ 90.00%] ··· ================ ==========
                file_format              
              ---------------- ----------
               ESRI Shapefile   597±80ms 
                  GeoJSON       672±20ms 
                    GPKG        526±30ms 
              ================ ==========

[100.00%] ··· io.BenchSeries.time_write_series                                                                                                                                                                   ok
[100.00%] ··· ================ ==========
                file_format              
              ---------------- ----------
               ESRI Shapefile   837±70ms 
                  GeoJSON       706±60ms 
                    GPKG        754±40ms 
              ================ ==========
```

Patch:
diff --git a/benchmarks/io.py b/benchmarks/io.py
index f169512a..70cf845c 100644
--- a/benchmarks/io.py
+++ b/benchmarks/io.py
@@ -1,59 +1,109 @@
 import os
 import shutil
 import tempfile
+import warnings
 
-from geopandas import GeoDataFrame, GeoSeries
 import numpy as np
+
 from shapely.geometry import Point
 
+from geopandas import GeoDataFrame, GeoSeries, read_file, read_parquet, read_feather
+
+
+# TEMP: hide warning from to_parquet
+warnings.filterwarnings("ignore", message=".*initial implementation of Parquet.*")
 
-class Bench:
 
-    # extensions for different file types to test
-    params = [".shp", ".json", ".gpkg"]
-    param_names = ["ext"]
+format_dict = {
+    "ESRI Shapefile": (
+        ".shp",
+        lambda gdf, filename: gdf.to_file(filename, driver="ESRI Shapefile"),
+        lambda filename: read_file(filename, driver="ESRI Shapefile"),
+    ),
+    "GeoJSON": (
+        ".json",
+        lambda gdf, filename: gdf.to_file(filename, driver="GeoJSON"),
+        lambda filename: read_file(filename, driver="GeoJSON"),
+    ),
+    "GPKG": (
+        ".gpkg",
+        lambda gdf, filename: gdf.to_file(filename, driver="GeoJSON"),
+        lambda filename: read_file(filename, driver="GeoJSON"),
+    ),
+    "Parquet": (
+        ".parquet",
+        lambda gdf, filename: gdf.to_parquet(filename),
+        lambda filename: read_parquet(filename),
+    ),
+    "Feather": (
+        ".feather",
+        lambda gdf, filename: gdf.to_feather(filename),
+        lambda filename: read_feather(filename),
+    ),
+}
 
-    def setup(self, ext):
 
-        self.driver_dict = {".shp": "ESRI Shapefile",
-                            ".json": "GeoJSON",
-                            ".gpkg": "GPKG"}
-        driver = self.driver_dict[ext]
+class Bench:
+
+    params = ["ESRI Shapefile", "GeoJSON", "GPKG", "Parquet", "Feather"]
+    param_names = ["file_format"]
+
+    def setup(self, file_format):
+        self.ext, self.writer, self.reader = format_dict[file_format]
 
         num_points = 20000
         xs = np.random.rand(num_points)
         ys = np.random.rand(num_points)
 
         self.points = GeoSeries([Point(x, y) for (x, y) in zip(xs, ys)])
-        self.df = GeoDataFrame({"geometry": self.points, "x": xs, "y": ys,
-                                "s": np.zeros(num_points, dtype="object")})
+        self.df = GeoDataFrame(
+            {
+                "geometry": self.points,
+                "x": xs,
+                "y": ys,
+                "s": np.zeros(num_points, dtype="object"),
+            }
+        )
 
         self.tmpdir = tempfile.mkdtemp()
-        self.series_filename = os.path.join(self.tmpdir, "series" + ext)
-        self.frame_filename = os.path.join(self.tmpdir, "frame" + ext)
-        self.points.to_file(self.series_filename, driver=driver)
-        self.df.to_file(self.frame_filename, driver=driver)
+        self.filename = os.path.join(self.tmpdir, "frame" + self.ext)
+        self.writer(self.df, self.filename)
 
-    def teardown(self, ext):
+    def teardown(self, file_format):
         shutil.rmtree(self.tmpdir)
 
-    def time_write_frame(self, ext):
-        driver = self.driver_dict[ext]
-        with tempfile.TemporaryDirectory() as tmpdir:
-            out_filename = os.path.join(tmpdir, "frame" + ext)
-            self.df.to_file(out_filename, driver=driver)
 
-    def time_write_series(self, ext):
-        driver = self.driver_dict[ext]
+class BenchFrame(Bench):
+
+    params = ["ESRI Shapefile", "GeoJSON", "GPKG", "Parquet", "Feather"]
+    param_names = ["file_format"]
+
+    def time_write(self, file_format):
         with tempfile.TemporaryDirectory() as tmpdir:
-            out_filename = os.path.join(tmpdir, "series" + ext)
-            self.points.to_file(out_filename, driver=driver)
+            out_filename = os.path.join(tmpdir, "frame" + self.ext)
+            self.writer(self.df, out_filename)
+
+    def time_read(self, file_format):
+        self.reader(self.filename)
+
 
-    def time_read_frame(self, ext):
-        frame = GeoDataFrame.from_file(self.frame_filename)
+class BenchSeries(Bench):
+
+    params = ["ESRI Shapefile", "GeoJSON", "GPKG"]
+    param_names = ["file_format"]
+
+    def setup(self, file_format):
+        super().setup(file_format)
+        self.filename_series = os.path.join(self.tmpdir, "series" + self.ext)
+        self.writer(self.points, self.filename_series)
+
+    def time_write_series(self, file_format):
+        with tempfile.TemporaryDirectory() as tmpdir:
+            out_filename = os.path.join(tmpdir, "series" + self.ext)
+            self.writer(self.points, out_filename)
 
-    def time_read_series(self, ext):
-        points = GeoSeries.from_file(self.series_filename)
+    def time_read_series(self, file_format):
+        GeoSeries.from_file(self.filename_series)
 
-    def time_read_series_from_frame(self, ext):
-        points = GeoSeries.from_file(self.frame_filename)
+    def time_read_series_from_frame(self, file_format):
+        GeoSeries.from_file(self.filename)
diff --git a/benchmarks/parquet.py b/benchmarks/parquet.py
deleted file mode 100644
index f2a707bc..00000000
--- a/benchmarks/parquet.py
+++ /dev/null
@@ -1,44 +0,0 @@
-import os
-import shutil
-import tempfile
-import warnings
-
-from geopandas import GeoDataFrame, GeoSeries, read_parquet
-import numpy as np
-from shapely.geometry import Point
-
-
-# TEMP: hide warning from to_parquet
-warnings.filterwarnings("ignore", message=".*initial implementation of Parquet.*")
-
-
-class Bench:
-    def setup(self):
-        num_points = 20000
-        xs = np.random.rand(num_points)
-        ys = np.random.rand(num_points)
-
-        self.points = GeoSeries([Point(x, y) for (x, y) in zip(xs, ys)])
-        self.df = GeoDataFrame(
-            {
-                "geometry": self.points,
-                "x": xs,
-                "y": ys,
-                "s": np.zeros(num_points, dtype="object"),
-            }
-        )
-
-        self.tmpdir = tempfile.mkdtemp()
-        self.filename = os.path.join(self.tmpdir, "frame.pq")
-
-        self.df.to_parquet(self.filename)
-
-    def teardown(self):
-        shutil.rmtree(self.tmpdir)
-
-    def time_read_parquet(self):
-        read_parquet(self.filename)
-
-    def time_write_parquet(self):
-        with tempfile.TemporaryDirectory() as tmpdir:
-            self.df.to_parquet(os.path.join(tmpdir, "frame.pq"))
diff --git a/doc/source/reference.rst b/doc/source/reference.rst
index 50753ead..820d9ad0 100644
--- a/doc/source/reference.rst
+++ b/doc/source/reference.rst
@@ -157,6 +157,8 @@ Currently, the following methods/attributes are implemented for a ``GeoDataFrame
 
 .. automethod:: geopandas.GeoDataFrame.to_parquet
 
+.. automethod:: geopandas.GeoDataFrame.to_feather
+
 .. automethod:: geopandas.GeoDataFrame.to_postgis
 
 .. automethod:: geopandas.GeoDataFrame.plot
@@ -202,6 +204,7 @@ Top-level Functions
   GeoSeries
   read_file
   read_parquet
+  read_feather
   read_postgis
   sjoin
   overlay
diff --git a/geopandas/__init__.py b/geopandas/__init__.py
index a0fed937..79bffdd7 100644
--- a/geopandas/__init__.py
+++ b/geopandas/__init__.py
@@ -5,7 +5,8 @@ from geopandas.geodataframe import GeoDataFrame  # noqa
 from geopandas.array import points_from_xy  # noqa
 
 from geopandas.io.file import _read_file as read_file  # noqa
-from geopandas.io.parquet import _read_parquet as read_parquet  # noqa
+from geopandas.io.arrow import _read_parquet as read_parquet  # noqa
+from geopandas.io.arrow import _read_feather as read_feather  # noqa
 from geopandas.io.sql import _read_postgis as read_postgis  # noqa
 from geopandas.tools import sjoin  # noqa
 from geopandas.tools import overlay  # noqa
diff --git a/geopandas/array.py b/geopandas/array.py
index 1e481388..d886a1f9 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -55,7 +55,7 @@ if compat.PANDAS_GE_024:
 
 def _isna(value):
     """
-    Check if scalar value is NA-like (None or np.nan).
+    Check if scalar value is NA-like (None, np.nan or pd.NA).
 
     Custom version that only works for scalars (returning True or False),
     as `pd.isna` also works for array-like input returning a boolean array.
@@ -64,6 +64,8 @@ def _isna(value):
         return True
     elif isinstance(value, float) and np.isnan(value):
         return True
+    elif compat.PANDAS_GE_10 and value is pd.NA:
+        return True
     else:
         return False
 
@@ -82,6 +84,36 @@ def _check_crs(left, right, allow_none=False):
     return True
 
 
+def _crs_mismatch_warn(left, right, stacklevel=3):
+    """
+    Raise a CRS mismatch warning with the information on the assigned CRS.
+    """
+    if left.crs:
+        left_srs = left.crs.to_string()
+        left_srs = left_srs if len(left_srs) <= 50 else " ".join([left_srs[:50], "..."])
+    else:
+        left_srs = None
+
+    if right.crs:
+        right_srs = right.crs.to_string()
+        right_srs = (
+            right_srs if len(right_srs) <= 50 else " ".join([right_srs[:50], "..."])
+        )
+    else:
+        right_srs = None
+
+    warnings.warn(
+        "CRS mismatch between the CRS of left geometries "
+        "and the CRS of right geometries.\n"
+        "Use `to_crs()` to reproject one of "
+        "the input geometries to match the CRS of the other.\n\n"
+        "Left CRS: {0}\n"
+        "Right CRS: {1}\n".format(left_srs, right_srs),
+        UserWarning,
+        stacklevel=stacklevel,
+    )
+
+
 # -----------------------------------------------------------------------------
 # Constructors / converters to other formats
 # -----------------------------------------------------------------------------
@@ -423,18 +455,7 @@ class GeometryArray(ExtensionArray):
                 )
                 raise ValueError(msg)
             if not _check_crs(left, right):
-                warnings.warn(
-                    "CRS mismatch between the CRS of left geometries "
-                    "and the CRS of right geoemtries. "
-                    "Use `GeoSeries.to_crs()` to reproject one of "
-                    "the passed geometry arrays.\n"
-                    "Left CRS:\n {0}\n"
-                    "Right CRS:\n {1}\n".format(
-                        left.crs.__repr__(), right.crs.__repr__()
-                    ),
-                    UserWarning,
-                    stacklevel=6,
-                )
+                _crs_mismatch_warn(left, right, stacklevel=7)
             right = right.data
 
         return getattr(vectorized, op)(left.data, right, **kwargs)
diff --git a/geopandas/base.py b/geopandas/base.py
index 3e971b17..ad31a6df 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -748,8 +748,8 @@ class GeoPandasBase(object):
         original index and a zero-based integer index that counts the
         number of single geometries within a multi-part geometry.
 
-        Example
-        -------
+        Examples
+        --------
         >>> gdf  # gdf is GeoSeries of MultiPoints
         0         MULTIPOINT (0 0, 1 1)
         1    MULTIPOINT (2 2, 3 3, 4 4)
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index 238c41df..67e042d1 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -579,7 +579,7 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
 
         return geo
 
-    def to_parquet(self, filename, compression="snappy", index=None, **kwargs):
+    def to_parquet(self, path, index=None, compression="snappy", **kwargs):
         """Write a GeoDataFrame to the Parquet format.
 
         Any geometry columns present are serialized to WKB format in the file.
@@ -600,21 +600,59 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
         Parameters
         ----------
         path : str, path object
+        index : bool, default None
+            If ``True``, always include the dataframe's index(es) as columns
+            in the file output.
+            If ``False``, the index(es) will not be written to the file.
+            If ``None``, the index(ex) will be included as columns in the file
+            output except `RangeIndex` which is stored as metadata only.
         compression : {'snappy', 'gzip', 'brotli', None}, default 'snappy'
             Name of the compression to use. Use ``None`` for no compression.
+        kwargs
+            Additional keyword arguments passed to to pyarrow.parquet.write_table().
+        """
+
+        from geopandas.io.arrow import _to_parquet
+
+        _to_parquet(self, path, compression=compression, index=index, **kwargs)
+
+    def to_feather(self, path, index=None, compression=None, **kwargs):
+        """Write a GeoDataFrame to the Feather format.
+
+        Any geometry columns present are serialized to WKB format in the file.
+
+        Requires 'pyarrow' >= 0.17.
+
+        WARNING: this is an initial implementation of Feather file support and
+        associated metadata.  This is tracking version 0.1.0 of the metadata
+        specification at:
+        https://github.com/geopandas/geo-arrow-spec
+
+        This metadata specification does not yet make stability promises.  As such,
+        we do not yet recommend using this in a production setting unless you are
+        able to rewrite your Feather files.
+
+        .. versionadded:: 0.8
+
+        Parameters
+        ----------
+        path : str, path object
         index : bool, default None
             If ``True``, always include the dataframe's index(es) as columns
             in the file output.
             If ``False``, the index(es) will not be written to the file.
             If ``None``, the index(ex) will be included as columns in the file
             output except `RangeIndex` which is stored as metadata only.
+        compression : {'zstd', 'lz4', 'uncompressed'}, optional
+            Name of the compression to use. Use ``"uncompressed"`` for no
+            compression. By default uses LZ4 if available, otherwise uncompressed.
         kwargs
-            Additional keyword arguments passed to to pyarrow.parquet.write_table().
+            Additional keyword arguments passed to to pyarrow.feather.write_feather().
         """
 
-        from geopandas.io.parquet import _to_parquet
+        from geopandas.io.arrow import _to_feather
 
-        _to_parquet(self, filename, compression=compression, index=index, **kwargs)
+        _to_feather(self, path, index=index, compression=compression, **kwargs)
 
     def to_file(
         self, filename, driver="ESRI Shapefile", schema=None, index=None, **kwargs
@@ -877,6 +915,9 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
         """
         df_copy = self.copy()
 
+        if "level_1" in df_copy.columns:  # GH1393
+            df_copy = df_copy.rename(columns={"level_1": "__level_1"})
+
         exploded_geom = df_copy.geometry.explode().reset_index(level=-1)
         exploded_index = exploded_geom.columns[0]
 
@@ -887,6 +928,10 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
         # exploded GeoSeries index.
         df.set_index(exploded_index, append=True, inplace=True)
         df.index.names = list(self.index.names) + [None]
+
+        if "__level_1" in df.columns:
+            df = df.rename(columns={"__level_1": "level_1"})
+
         geo_df = df.set_geometry(self._geometry_column_name)
         return geo_df
 
diff --git a/geopandas/io/parquet.py b/geopandas/io/arrow.py
similarity index 64%
rename from geopandas/io/parquet.py
rename to geopandas/io/arrow.py
index 36b56c02..acd574f6 100644
--- a/geopandas/io/parquet.py
+++ b/geopandas/io/arrow.py
@@ -1,3 +1,4 @@
+from distutils.version import LooseVersion
 import json
 import warnings
 
@@ -128,11 +129,11 @@ def _validate_dataframe(df):
     """
 
     if not isinstance(df, DataFrame):
-        raise ValueError("to_parquet only supports IO with DataFrames")
+        raise ValueError("Writing to Parquet/Feather only supports IO with DataFrames")
 
     # must have value column names (strings only)
     if df.columns.inferred_type not in {"string", "unicode", "empty"}:
-        raise ValueError("parquet must have string column names")
+        raise ValueError("Writing to Parquet/Feather requires string column names")
 
     # index level names must be strings
     valid_names = all(
@@ -154,13 +155,13 @@ def _validate_metadata(metadata):
     """
 
     if not metadata:
-        raise ValueError("Missing or malformed geo metadata in Parquet file")
+        raise ValueError("Missing or malformed geo metadata in Parquet/Feather file")
 
     required_keys = ("primary_column", "columns")
     for key in required_keys:
         if metadata.get(key, None) is None:
             raise ValueError(
-                "'geo' metadata in Parquet file is missing required key: "
+                "'geo' metadata in Parquet/Feather file is missing required key: "
                 "'{key}'".format(key=key)
             )
 
@@ -173,7 +174,7 @@ def _validate_metadata(metadata):
         for key in required_col_keys:
             if key not in column_metadata:
                 raise ValueError(
-                    "'geo' metadata in Parquet file is missing required key "
+                    "'geo' metadata in Parquet/Feather file is missing required key "
                     "'{key}' for column '{col}'".format(key=key, col=col)
                 )
 
@@ -181,7 +182,44 @@ def _validate_metadata(metadata):
             raise ValueError("Only WKB geometry encoding is supported")
 
 
-def _to_parquet(df, path, compression="snappy", index=None, **kwargs):
+def _geopandas_to_arrow(df, index=None):
+    """
+    Helper function with main, shared logic for to_parquet/to_feather.
+    """
+    from pyarrow import Table
+
+    warnings.warn(
+        "this is an initial implementation of Parquet/Feather file support and "
+        "associated metadata.  This is tracking version 0.1.0 of the metadata "
+        "specification at "
+        "https://github.com/geopandas/geo-arrow-spec\n\n"
+        "This metadata specification does not yet make stability promises.  "
+        "We do not yet recommend using this in a production setting unless you "
+        "are able to rewrite your Parquet/Feather files.\n\n"
+        "To further ignore this warning, you can do: \n"
+        "import warnings; warnings.filterwarnings('ignore', "
+        "message='.*initial implementation of Parquet.*')",
+        UserWarning,
+        stacklevel=4,
+    )
+
+    _validate_dataframe(df)
+
+    # create geo metadata before altering incoming data frame
+    geo_metadata = _create_metadata(df)
+
+    df = _encode_wkb(df)
+
+    table = Table.from_pandas(df, preserve_index=index)
+
+    # Store geopandas specific file-level metadata
+    # This must be done AFTER creating the table or it is not persisted
+    metadata = table.schema.metadata
+    metadata.update({b"geo": _encode_metadata(geo_metadata)})
+    return table.replace_schema_metadata(metadata)
+
+
+def _to_parquet(df, path, index=None, compression="snappy", **kwargs):
     """
     Write a GeoDataFrame to the Parquet format.
 
@@ -204,54 +242,123 @@ def _to_parquet(df, path, compression="snappy", index=None, **kwargs):
     Parameters
     ----------
     path : str, path object
-    compression : {'snappy', 'gzip', 'brotli', None}, default 'snappy'
-        Name of the compression to use. Use ``None`` for no compression.
     index : bool, default None
         If ``True``, always include the dataframe's index(es) as columns
         in the file output.
         If ``False``, the index(es) will not be written to the file.
         If ``None``, the index(ex) will be included as columns in the file
         output except `RangeIndex` which is stored as metadata only.
+    compression : {'snappy', 'gzip', 'brotli', None}, default 'snappy'
+        Name of the compression to use. Use ``None`` for no compression.
     kwargs
         Additional keyword arguments passed to pyarrow.parquet.write_table().
     """
-
-    import_optional_dependency(
+    parquet = import_optional_dependency(
         "pyarrow.parquet", extra="pyarrow is required for Parquet support."
     )
-    from pyarrow import parquet, Table
 
-    warnings.warn(
-        "this is an initial implementation of Parquet file support and "
-        "associated metadata.  This is tracking version 0.1.0 of the metadata "
-        "specification at "
-        "https://github.com/geopandas/geo-arrow-spec\n\n"
-        "This metadata specification does not yet make stability promises.  "
-        "We do not yet recommend using this in a production setting unless you "
-        "are able to rewrite your Parquet files.\n\n"
-        "To further ignore this warning, you can do: \n"
-        "import warnings; warnings.filterwarnings('ignore', "
-        "message='.*initial implementation of Parquet.*')",
-        UserWarning,
-        stacklevel=3,
+    table = _geopandas_to_arrow(df, index=index)
+    parquet.write_table(table, path, compression=compression, **kwargs)
+
+
+def _to_feather(df, path, index=None, compression=None, **kwargs):
+    """
+    Write a GeoDataFrame to the Feather format.
+
+    Any geometry columns present are serialized to WKB format in the file.
+
+    Requires 'pyarrow' >= 0.17.
+
+    WARNING: this is an initial implementation of Feather file support and
+    associated metadata.  This is tracking version 0.1.0 of the metadata
+    specification at:
+    https://github.com/geopandas/geo-arrow-spec
+
+    This metadata specification does not yet make stability promises.  As such,
+    we do not yet recommend using this in a production setting unless you are
+    able to rewrite your Feather files.
+
+    .. versionadded:: 0.8
+
+    Parameters
+    ----------
+    path : str, path object
+    index : bool, default None
+        If ``True``, always include the dataframe's index(es) as columns
+        in the file output.
+        If ``False``, the index(es) will not be written to the file.
+        If ``None``, the index(ex) will be included as columns in the file
+        output except `RangeIndex` which is stored as metadata only.
+    compression : {'zstd', 'lz4', 'uncompressed'}, optional
+        Name of the compression to use. Use ``"uncompressed"`` for no
+        compression. By default uses LZ4 if available, otherwise uncompressed.
+    kwargs
+        Additional keyword arguments passed to pyarrow.feather.write_feather().
+    """
+    feather = import_optional_dependency(
+        "pyarrow.feather", extra="pyarrow is required for Feather support."
     )
+    # TODO move this into `import_optional_dependency`
+    import pyarrow
 
-    _validate_dataframe(df)
+    if pyarrow.__version__ < LooseVersion("0.17.0"):
+        raise ImportError("pyarrow >= 0.17 required for Feather support")
 
-    # create geo metadata before altering incoming data frame
-    geo_metadata = _create_metadata(df)
+    table = _geopandas_to_arrow(df, index=index)
+    feather.write_feather(table, path, compression=compression, **kwargs)
 
-    df = _encode_wkb(df)
 
-    table = Table.from_pandas(df, preserve_index=index)
+def _arrow_to_geopandas(table):
+    """
+    Helper function with main, shared logic for read_parquet/read_feather.
+    """
+    df = table.to_pandas()
 
-    # Store geopandas specific file-level metadata
-    # This must be done AFTER creating the table or it is not persisted
     metadata = table.schema.metadata
-    metadata.update({b"geo": _encode_metadata(geo_metadata)})
+    if b"geo" not in metadata:
+        raise ValueError(
+            """Missing geo metadata in Parquet/Feather file.
+            Use pandas.read_parquet/read_feather() instead."""
+        )
 
-    table = table.replace_schema_metadata(metadata)
-    parquet.write_table(table, path, compression=compression, **kwargs)
+    try:
+        metadata = _decode_metadata(metadata.get(b"geo", b""))
+
+    except (TypeError, json.decoder.JSONDecodeError):
+        raise ValueError("Missing or malformed geo metadata in Parquet/Feather file")
+
+    _validate_metadata(metadata)
+
+    # Find all geometry columns that were read from the file.  May
+    # be a subset if 'columns' parameter is used.
+    geometry_columns = df.columns.intersection(metadata["columns"])
+
+    if not len(geometry_columns):
+        raise ValueError(
+            """No geometry columns are included in the columns read from
+            the Parquet/Feather file.  To read this file without geometry columns,
+            use pandas.read_parquet/read_feather() instead."""
+        )
+
+    geometry = metadata["primary_column"]
+
+    # Missing geometry likely indicates a subset of columns was read;
+    # promote the first available geometry to the primary geometry.
+    if len(geometry_columns) and geometry not in geometry_columns:
+        geometry = geometry_columns[0]
+
+        # if there are multiple non-primary geometry columns, raise a warning
+        if len(geometry_columns) > 1:
+            warnings.warn(
+                "Multiple non-primary geometry columns read from Parquet/Feather "
+                "file. The first column read was promoted to the primary geometry."
+            )
+
+    # Convert the WKB columns that are present back to geometry.
+    for col in geometry_columns:
+        df[col] = from_wkb(df[col].values, crs=metadata["columns"][col]["crs"])
+
+    return GeoDataFrame(df, geometry=geometry)
 
 
 def _read_parquet(path, columns=None, **kwargs):
@@ -289,58 +396,59 @@ def _read_parquet(path, columns=None, **kwargs):
     GeoDataFrame
     """
 
-    import_optional_dependency(
-        "pyarrow", extra="pyarrow is required for Parquet support."
+    parquet = import_optional_dependency(
+        "pyarrow.parquet", extra="pyarrow is required for Parquet support."
     )
-    from pyarrow import parquet
 
     kwargs["use_pandas_metadata"] = True
     table = parquet.read_table(path, columns=columns, **kwargs)
 
-    df = table.to_pandas()
+    return _arrow_to_geopandas(table)
 
-    metadata = table.schema.metadata
-    if b"geo" not in metadata:
-        raise ValueError(
-            """Missing geo metadata in Parquet file.
-            Use pandas.read_parquet() instead."""
-        )
 
-    try:
-        metadata = _decode_metadata(metadata.get(b"geo", b""))
+def _read_feather(path, columns=None, **kwargs):
+    """
+    Load a Feather object from the file path, returning a GeoDataFrame.
 
-    except (TypeError, json.decoder.JSONDecodeError):
-        raise ValueError("Missing or malformed geo metadata in Parquet file")
+    You can read a subset of columns in the file using the ``columns`` parameter.
+    However, the structure of the returned GeoDataFrame will depend on which
+    columns you read:
 
-    _validate_metadata(metadata)
+    * if no geometry columns are read, this will raise a ``ValueError`` - you
+      should use the pandas `read_feather` method instead.
+    * if the primary geometry column saved to this file is not included in
+      columns, the first available geometry column will be set as the geometry
+      column of the returned GeoDataFrame.
 
-    # Find all geometry columns that were read from the file.  May
-    # be a subset if 'columns' parameter is used.
-    geometry_columns = df.columns.intersection(metadata["columns"])
+    Requires 'pyarrow' >= 0.17.
 
-    if not len(geometry_columns):
-        raise ValueError(
-            """No geometry columns are included in the columns read from
-            the Parquet file.  To read this file without geometry columns,
-            use pandas.read_parquet() instead."""
-        )
+    .. versionadded:: 0.8
 
-    geometry = metadata["primary_column"]
+    Parameters
+    ----------
+    path : str, path object
+    columns : list-like of strings, default=None
+        If not None, only these columns will be read from the file.  If
+        the primary geometry column is not included, the first secondary
+        geometry read from the file will be set as the geometry column
+        of the returned GeoDataFrame.  If no geometry columns are present,
+        a ``ValueError`` will be raised.
+    **kwargs
+        Any additional kwargs passed to pyarrow.feather.read_table().
 
-    # Missing geometry likely indicates a subset of columns was read;
-    # promote the first available geometry to the primary geometry.
-    if len(geometry_columns) and geometry not in geometry_columns:
-        geometry = geometry_columns[0]
+    Returns
+    -------
+    GeoDataFrame
+    """
 
-        # if there are multiple non-primary geometry columns, raise a warning
-        if len(geometry_columns) > 1:
-            warnings.warn(
-                "Multiple non-primary geometry columns read from Parquet file.  "
-                "The first column read was promoted to the primary geometry."
-            )
+    feather = import_optional_dependency(
+        "pyarrow.feather", extra="pyarrow is required for Feather support."
+    )
+    # TODO move this into `import_optional_dependency`
+    import pyarrow
 
-    # Convert the WKB columns that are present back to geometry.
-    for col in geometry_columns:
-        df[col] = from_wkb(df[col].values, crs=metadata["columns"][col]["crs"])
+    if pyarrow.__version__ < LooseVersion("0.17.0"):
+        raise ImportError("pyarrow >= 0.17 required for Feather support")
 
-    return GeoDataFrame(df, geometry=geometry)
+    table = feather.read_table(path, columns=columns, **kwargs)
+    return _arrow_to_geopandas(table)
diff --git a/geopandas/io/sql.py b/geopandas/io/sql.py
index 56512e0b..16996c7e 100644
--- a/geopandas/io/sql.py
+++ b/geopandas/io/sql.py
@@ -103,8 +103,8 @@ def _read_postgis(
     -------
     GeoDataFrame
 
-    Example
-    -------
+    Examples
+    --------
     PostGIS
     >>> sql = "SELECT geom, kind FROM polygons"
     SpatiaLite
diff --git a/geopandas/tools/_show_versions.py b/geopandas/tools/_show_versions.py
index c7c59379..c977172a 100644
--- a/geopandas/tools/_show_versions.py
+++ b/geopandas/tools/_show_versions.py
@@ -124,9 +124,12 @@ def show_versions():
     """
     Print system information and installed module versions.
 
-    Example
-    -------
-    > python -c "import geopandas; geopandas.show_versions()"
+    Examples
+    --------
+
+    ::
+
+        $ python -c "import geopandas; geopandas.show_versions()"
     """
     sys_info = _get_sys_info()
     deps_info = _get_deps_info()
diff --git a/geopandas/tools/clip.py b/geopandas/tools/clip.py
index dd7cda85..8a1d6d1e 100644
--- a/geopandas/tools/clip.py
+++ b/geopandas/tools/clip.py
@@ -13,6 +13,7 @@ import pandas as pd
 from shapely.geometry import Polygon, MultiPolygon
 
 from geopandas import GeoDataFrame, GeoSeries
+from geopandas.array import _check_crs, _crs_mismatch_warn
 
 
 def _clip_points(gdf, poly):
@@ -129,6 +130,10 @@ def clip(gdf, mask, keep_geom_type=False):
             "(Multi)Polygon, got {}".format(type(gdf))
         )
 
+    if isinstance(mask, (GeoDataFrame, GeoSeries)):
+        if not _check_crs(gdf, mask):
+            _crs_mismatch_warn(gdf, mask, stacklevel=3)
+
     if isinstance(mask, (GeoDataFrame, GeoSeries)):
         box_mask = mask.total_bounds
     else:
diff --git a/geopandas/tools/overlay.py b/geopandas/tools/overlay.py
index e21f4607..422bad5f 100644
--- a/geopandas/tools/overlay.py
+++ b/geopandas/tools/overlay.py
@@ -1,9 +1,11 @@
+import warnings
 from functools import reduce
 
 import numpy as np
 import pandas as pd
 
 from geopandas import GeoDataFrame, GeoSeries
+from geopandas.array import _check_crs, _crs_mismatch_warn
 
 
 def _ensure_geometry_column(df):
@@ -179,6 +181,10 @@ def overlay(df1, df2, how="intersection", make_valid=True, keep_geom_type=True):
         raise NotImplementedError(
             "overlay currently only implemented for " "GeoDataFrames"
         )
+
+    if not _check_crs(df1, df2):
+        _crs_mismatch_warn(df1, df2, stacklevel=3)
+
     polys = ["Polygon", "MultiPolygon"]
     lines = ["LineString", "MultiLineString", "LinearRing"]
     points = ["Point", "MultiPoint"]
@@ -199,17 +205,19 @@ def overlay(df1, df2, how="intersection", make_valid=True, keep_geom_type=True):
     if df2.geom_type.isin(polys).all():
         df2[df2._geometry_column_name] = df2.geometry.buffer(0)
 
-    if how == "difference":
-        return _overlay_difference(df1, df2)
-    elif how == "intersection":
-        result = _overlay_intersection(df1, df2)
-    elif how == "symmetric_difference":
-        result = _overlay_symmetric_diff(df1, df2)
-    elif how == "union":
-        result = _overlay_union(df1, df2)
-    elif how == "identity":
-        dfunion = _overlay_union(df1, df2)
-        result = dfunion[dfunion["__idx1"].notnull()].copy()
+    with warnings.catch_warnings():  # CRS checked above, supress array-level warning
+        warnings.filterwarnings("ignore", message="CRS mismatch between the CRS")
+        if how == "difference":
+            return _overlay_difference(df1, df2)
+        elif how == "intersection":
+            result = _overlay_intersection(df1, df2)
+        elif how == "symmetric_difference":
+            result = _overlay_symmetric_diff(df1, df2)
+        elif how == "union":
+            result = _overlay_union(df1, df2)
+        elif how == "identity":
+            dfunion = _overlay_union(df1, df2)
+            result = dfunion[dfunion["__idx1"].notnull()].copy()
 
     if keep_geom_type:
         type = df1.geom_type.iloc[0]
diff --git a/geopandas/tools/sjoin.py b/geopandas/tools/sjoin.py
index 7f67c19a..79133b00 100644
--- a/geopandas/tools/sjoin.py
+++ b/geopandas/tools/sjoin.py
@@ -1,5 +1,3 @@
-from warnings import warn
-
 import numpy as np
 import pandas as pd
 
@@ -7,6 +5,7 @@ from shapely import prepared
 
 from geopandas import GeoDataFrame
 from geopandas import _compat as compat
+from geopandas.array import _check_crs, _crs_mismatch_warn
 
 
 def sjoin(
@@ -55,13 +54,8 @@ def sjoin(
             '`op` was "%s" but is expected to be in %s' % (op, allowed_ops)
         )
 
-    if left_df.crs != right_df.crs:
-        warn(
-            (
-                "CRS of frames being joined does not match!"
-                "(%s != %s)" % (left_df.crs, right_df.crs)
-            )
-        )
+    if not _check_crs(left_df, right_df):
+        _crs_mismatch_warn(left_df, right_df, stacklevel=3)
 
     index_left = "index_%s" % lsuffix
     index_right = "index_%s" % rsuffix


Test patch:
diff --git a/geopandas/io/tests/test_parquet.py b/geopandas/io/tests/test_arrow.py
similarity index 74%
rename from geopandas/io/tests/test_parquet.py
rename to geopandas/io/tests/test_arrow.py
index 6eed0bac..ecfa37cd 100644
--- a/geopandas/io/tests/test_parquet.py
+++ b/geopandas/io/tests/test_arrow.py
@@ -1,4 +1,6 @@
 from __future__ import absolute_import
+
+from distutils.version import LooseVersion
 import os
 
 import pytest
@@ -7,10 +9,10 @@ from pandas.testing import assert_frame_equal
 import numpy as np
 
 import geopandas
-from geopandas import GeoDataFrame, read_file, read_parquet
+from geopandas import GeoDataFrame, read_file, read_parquet, read_feather
 from geopandas.array import to_wkb
 from geopandas.datasets import get_path
-from geopandas.io.parquet import (
+from geopandas.io.arrow import (
     _create_metadata,
     _decode_metadata,
     _encode_metadata,
@@ -23,12 +25,31 @@ from geopandas.testing import assert_geodataframe_equal, assert_geoseries_equal
 
 
 # Skip all tests in this module if pyarrow is not available
-pyarrow_skip = pytest.importorskip("pyarrow")
+pyarrow = pytest.importorskip("pyarrow")
 
 # TEMPORARY: hide warning from to_parquet
 pytestmark = pytest.mark.filterwarnings("ignore:.*initial implementation of Parquet.*")
 
 
+@pytest.fixture(
+    params=[
+        "parquet",
+        pytest.param(
+            "feather",
+            marks=pytest.mark.skipif(
+                pyarrow.__version__ < LooseVersion("0.17.0"),
+                reason="needs pyarrow >= 0.17",
+            ),
+        ),
+    ]
+)
+def file_format(request):
+    if request.param == "parquet":
+        return read_parquet, GeoDataFrame.to_parquet
+    elif request.param == "feather":
+        return read_feather, GeoDataFrame.to_feather
+
+
 def test_create_metadata():
     test_dataset = "naturalearth_lowres"
     df = read_file(get_path(test_dataset))
@@ -104,14 +125,14 @@ def test_validate_metadata_valid():
 @pytest.mark.parametrize(
     "metadata,error",
     [
-        ({}, "Missing or malformed geo metadata in Parquet file"),
+        ({}, "Missing or malformed geo metadata in Parquet/Feather file"),
         (
             {"primary_column": "foo"},
-            "'geo' metadata in Parquet file is missing required key:",
+            "'geo' metadata in Parquet/Feather file is missing required key:",
         ),
         (
             {"primary_column": "foo", "columns": None},
-            "'geo' metadata in Parquet file is missing required key",
+            "'geo' metadata in Parquet/Feather file is missing required key",
         ),
         (
             {"primary_column": "foo", "columns": []},
@@ -120,17 +141,17 @@ def test_validate_metadata_valid():
         (
             {"primary_column": "foo", "columns": {"foo": {}}},
             (
-                "'geo' metadata in Parquet file is missing required key 'crs' "
+                "'geo' metadata in Parquet/Feather file is missing required key 'crs' "
                 "for column 'foo'"
             ),
         ),
         (
             {"primary_column": "foo", "columns": {"foo": {"crs": None}}},
-            "'geo' metadata in Parquet file is missing required key",
+            "'geo' metadata in Parquet/Feather file is missing required key",
         ),
         (
             {"primary_column": "foo", "columns": {"foo": {"encoding": None}}},
-            "'geo' metadata in Parquet file is missing required key",
+            "'geo' metadata in Parquet/Feather file is missing required key",
         ),
         (
             {
@@ -198,10 +219,11 @@ def test_pandas_parquet_roundtrip2(test_dataset, tmpdir):
 @pytest.mark.parametrize(
     "test_dataset", ["naturalearth_lowres", "naturalearth_cities", "nybb"]
 )
-def test_parquet_roundtrip(test_dataset, tmpdir):
+def test_roundtrip(tmpdir, file_format, test_dataset):
     """Writing to parquet should not raise errors, and should not alter original
     GeoDataFrame
     """
+    reader, writer = file_format
 
     df = read_file(get_path(test_dataset))
     orig = df.copy()
@@ -210,7 +232,7 @@ def test_parquet_roundtrip(test_dataset, tmpdir):
 
     # TEMP: Initial implementation should raise a UserWarning
     with pytest.warns(UserWarning, match="initial implementation"):
-        df.to_parquet(filename)
+        writer(df, filename)
 
     assert os.path.exists(filename)
 
@@ -218,28 +240,29 @@ def test_parquet_roundtrip(test_dataset, tmpdir):
     assert_geodataframe_equal(df, orig)
 
     # make sure that we can roundtrip the data frame
-    pq_df = read_parquet(filename)
+    pq_df = reader(filename)
 
     assert isinstance(pq_df, GeoDataFrame)
     assert_geodataframe_equal(df, pq_df)
 
 
-def test_parquet_index(tmpdir):
+def test_index(tmpdir, file_format):
     """Setting index=`True` should preserve index in output, and
     setting index=`False` should drop index from output.
     """
+    reader, writer = file_format
 
     test_dataset = "naturalearth_lowres"
     df = read_file(get_path(test_dataset)).set_index("iso_a3")
 
     filename = os.path.join(str(tmpdir), "test_with_index.pq")
-    df.to_parquet(filename, index=True)
-    pq_df = read_parquet(filename)
+    writer(df, filename, index=True)
+    pq_df = reader(filename)
     assert_geodataframe_equal(df, pq_df)
 
     filename = os.path.join(str(tmpdir), "drop_index.pq")
-    df.to_parquet(filename, index=False)
-    pq_df = read_parquet(filename)
+    writer(df, filename, index=False)
+    pq_df = reader(filename)
     assert_geodataframe_equal(df.reset_index(drop=True), pq_df)
 
 
@@ -260,21 +283,43 @@ def test_parquet_compression(compression, tmpdir):
     assert_geodataframe_equal(df, pq_df)
 
 
-def test_parquet_multiple_geom_cols(tmpdir):
+@pytest.mark.skipif(
+    pyarrow.__version__ < LooseVersion("0.17.0"),
+    reason="Feather only supported for pyarrow >= 0.17",
+)
+@pytest.mark.parametrize("compression", ["uncompressed", "lz4", "zstd"])
+def test_feather_compression(compression, tmpdir):
+    """Using compression options should not raise errors, and should
+    return identical GeoDataFrame.
+    """
+
+    test_dataset = "naturalearth_lowres"
+    df = read_file(get_path(test_dataset))
+
+    filename = os.path.join(str(tmpdir), "test.feather")
+    df.to_feather(filename, compression=compression)
+    pq_df = read_feather(filename)
+
+    assert isinstance(pq_df, GeoDataFrame)
+    assert_geodataframe_equal(df, pq_df)
+
+
+def test_parquet_multiple_geom_cols(tmpdir, file_format):
     """If multiple geometry columns are present when written to parquet,
     they should all be returned as such when read from parquet.
     """
+    reader, writer = file_format
 
     test_dataset = "naturalearth_lowres"
     df = read_file(get_path(test_dataset))
     df["geom2"] = df.geometry.copy()
 
     filename = os.path.join(str(tmpdir), "test.pq")
-    df.to_parquet(filename)
+    writer(df, filename)
 
     assert os.path.exists(filename)
 
-    pq_df = read_parquet(filename)
+    pq_df = reader(filename)
 
     assert isinstance(pq_df, GeoDataFrame)
     assert_geodataframe_equal(df, pq_df)
@@ -302,21 +347,23 @@ def test_parquet_missing_metadata(tmpdir):
     df.to_parquet(filename)
 
     # missing metadata will raise ValueError
-    with pytest.raises(ValueError, match="Missing geo metadata in Parquet file."):
+    with pytest.raises(
+        ValueError, match="Missing geo metadata in Parquet/Feather file."
+    ):
         read_parquet(filename)
 
 
 @pytest.mark.parametrize(
     "geo_meta,error",
     [
-        ({"geo": b""}, "Missing or malformed geo metadata in Parquet file"),
+        ({"geo": b""}, "Missing or malformed geo metadata in Parquet/Feather file"),
         (
             {"geo": _encode_metadata({})},
-            "Missing or malformed geo metadata in Parquet file",
+            "Missing or malformed geo metadata in Parquet/Feather file",
         ),
         (
             {"geo": _encode_metadata({"foo": "bar"})},
-            "'geo' metadata in Parquet file is missing required key",
+            "'geo' metadata in Parquet/Feather file is missing required key",
         ),
     ],
 )
@@ -348,24 +395,25 @@ def test_parquet_invalid_metadata(tmpdir, geo_meta, error):
         read_parquet(filename)
 
 
-def test_parquet_subset_columns(tmpdir):
+def test_subset_columns(tmpdir, file_format):
     """Reading a subset of columns should correctly decode selected geometry
     columns.
     """
+    reader, writer = file_format
 
     test_dataset = "naturalearth_lowres"
     df = read_file(get_path(test_dataset))
 
     filename = os.path.join(str(tmpdir), "test.pq")
-    df.to_parquet(filename)
-    pq_df = read_parquet(filename, columns=["name", "geometry"])
+    writer(df, filename)
+    pq_df = reader(filename, columns=["name", "geometry"])
 
     assert_geodataframe_equal(df[["name", "geometry"]], pq_df)
 
     with pytest.raises(
         ValueError, match="No geometry columns are included in the columns read"
     ):
-        read_parquet(filename, columns=[])
+        reader(filename, columns=["name"])
 
 
 def test_parquet_repeat_columns(tmpdir):
@@ -384,53 +432,56 @@ def test_parquet_repeat_columns(tmpdir):
     assert pq_df.columns.tolist() == ["name", "iso_a3", "geometry"]
 
 
-def test_parquet_promote_secondary_geometry(tmpdir):
+def test_promote_secondary_geometry(tmpdir, file_format):
     """Reading a subset of columns that does not include the primary geometry
     column should promote the first geometry column present.
     """
+    reader, writer = file_format
 
     test_dataset = "naturalearth_lowres"
     df = read_file(get_path(test_dataset))
     df["geom2"] = df.geometry.copy()
 
     filename = os.path.join(str(tmpdir), "test.pq")
-    df.to_parquet(filename)
-    pq_df = read_parquet(filename, columns=["name", "geom2"])
+    writer(df, filename)
+    pq_df = reader(filename, columns=["name", "geom2"])
 
     assert_geodataframe_equal(df.set_geometry("geom2")[["name", "geom2"]], pq_df)
 
     df["geom3"] = df.geometry.copy()
 
-    df.to_parquet(filename)
+    writer(df, filename)
     with pytest.warns(
         UserWarning,
-        match="Multiple non-primary geometry columns read from Parquet file.",
+        match="Multiple non-primary geometry columns read from Parquet/Feather file.",
     ):
-        pq_df = read_parquet(filename, columns=["name", "geom2", "geom3"])
+        pq_df = reader(filename, columns=["name", "geom2", "geom3"])
 
     assert_geodataframe_equal(
         df.set_geometry("geom2")[["name", "geom2", "geom3"]], pq_df
     )
 
 
-def test_parquet_columns_no_geometry(tmpdir):
+def test_columns_no_geometry(tmpdir, file_format):
     """Reading a parquet file that is missing all of the geometry columns
     should raise a ValueError"""
+    reader, writer = file_format
 
     test_dataset = "naturalearth_lowres"
     df = read_file(get_path(test_dataset))
 
     filename = os.path.join(str(tmpdir), "test.pq")
-    df.to_parquet(filename)
+    writer(df, filename)
 
     with pytest.raises(ValueError):
-        read_parquet(filename, columns=["name"])
+        reader(filename, columns=["name"])
 
 
-def test_parquet_missing_crs(tmpdir):
+def test_missing_crs(tmpdir, file_format):
     """If CRS is `None`, it should be properly handled
     and remain `None` when read from parquet`.
     """
+    reader, writer = file_format
 
     test_dataset = "naturalearth_lowres"
 
@@ -438,9 +489,23 @@ def test_parquet_missing_crs(tmpdir):
     df.crs = None
 
     filename = os.path.join(str(tmpdir), "test.pq")
-    df.to_parquet(filename)
-    pq_df = read_parquet(filename)
+    writer(df, filename)
+    pq_df = reader(filename)
 
     assert pq_df.crs is None
 
     assert_geodataframe_equal(df, pq_df, check_crs=True)
+
+
+@pytest.mark.skipif(
+    pyarrow.__version__ >= LooseVersion("0.17.0"),
+    reason="Feather only supported for pyarrow >= 0.17",
+)
+def test_feather_arrow_version(tmpdir):
+    df = read_file(get_path("naturalearth_lowres"))
+    filename = os.path.join(str(tmpdir), "test.feather")
+
+    with pytest.raises(
+        ImportError, match="pyarrow >= 0.17 required for Feather support"
+    ):
+        df.to_feather(filename)
diff --git a/geopandas/tests/test_array.py b/geopandas/tests/test_array.py
index 90b28c18..8710af73 100644
--- a/geopandas/tests/test_array.py
+++ b/geopandas/tests/test_array.py
@@ -21,6 +21,7 @@ from geopandas.array import (
     to_wkb,
     to_wkt,
     _check_crs,
+    _crs_mismatch_warn,
 )
 import geopandas._compat as compat
 
@@ -822,3 +823,36 @@ def test_check_crs():
     assert _check_crs(t1, T) is False
     assert _check_crs(t1, t1) is True
     assert _check_crs(t1, T, allow_none=True) is True
+
+
+def test_crs_mismatch_warn():
+    t1 = T.copy()
+    t2 = T.copy()
+    t1.crs = 4326
+    t2.crs = 3857
+
+    # two different CRS
+    with pytest.warns(UserWarning, match="CRS mismatch between the CRS"):
+        _crs_mismatch_warn(t1, t2)
+
+    # left None
+    with pytest.warns(UserWarning, match="CRS mismatch between the CRS"):
+        _crs_mismatch_warn(T, t2)
+
+    # right None
+    with pytest.warns(UserWarning, match="CRS mismatch between the CRS"):
+        _crs_mismatch_warn(t1, T)
+
+
+@pytest.mark.parametrize("NA", [None, np.nan])
+def test_isna(NA):
+    t1 = T.copy()
+    t1[0] = NA
+    assert t1[0] is None
+
+
+@pytest.mark.skipif(not compat.PANDAS_GE_10, reason="pd.NA introduced in pandas 1.0")
+def test_isna_pdNA():
+    t1 = T.copy()
+    t1[0] = pd.NA
+    assert t1[0] is None
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
index f7151463..4fcdb006 100644
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -12,6 +12,7 @@ from geopandas import GeoDataFrame, GeoSeries
 from geopandas.base import GeoPandasBase
 
 from geopandas.tests.util import assert_geoseries_equal, geom_almost_equals, geom_equals
+from geopandas._compat import PANDAS_GE_024
 from pandas.testing import assert_frame_equal, assert_series_equal
 import pytest
 
@@ -658,6 +659,27 @@ class TestGeomMethods:
         expected_df = expected_df.set_index(expected_index)
         assert_frame_equal(test_df, expected_df)
 
+    @pytest.mark.parametrize("index_name", [None, "test"])
+    def test_explode_geodataframe_level_1(self, index_name):
+        # GH1393
+        s = GeoSeries([MultiPoint([Point(1, 2), Point(2, 3)]), Point(5, 5)])
+        df = GeoDataFrame({"level_1": [1, 2], "geometry": s})
+        df.index.name = index_name
+
+        test_df = df.explode()
+
+        expected_s = GeoSeries([Point(1, 2), Point(2, 3), Point(5, 5)])
+        expected_df = GeoDataFrame({"level_1": [1, 1, 2], "geometry": expected_s})
+        expected_index = MultiIndex(
+            [[0, 1], [0, 1]],  # levels
+            [[0, 0, 1], [0, 1, 0]],  # labels/codes
+            names=[index_name, None],
+        )
+        expected_df = expected_df.set_index(expected_index)
+        if not PANDAS_GE_024:
+            expected_df = expected_df[["level_1", "geometry"]]
+        assert_frame_equal(test_df, expected_df)
+
     #
     # Test '&', '|', '^', and '-'
     #
diff --git a/geopandas/tests/test_overlay.py b/geopandas/tests/test_overlay.py
index cf250f44..467fbb91 100644
--- a/geopandas/tests/test_overlay.py
+++ b/geopandas/tests/test_overlay.py
@@ -327,6 +327,14 @@ def test_preserve_crs(dfs, how):
     assert result.crs == crs
 
 
+def test_crs_mismatch(dfs, how):
+    df1, df2 = dfs
+    df1.crs = 4326
+    df2.crs = 3857
+    with pytest.warns(UserWarning, match="CRS mismatch between the CRS"):
+        overlay(df1, df2, how=how)
+
+
 def test_empty_intersection(dfs):
     df1, df2 = dfs
     polys3 = GeoSeries(
diff --git a/geopandas/tools/tests/test_clip.py b/geopandas/tools/tests/test_clip.py
index 5c8f552b..40abaa04 100644
--- a/geopandas/tools/tests/test_clip.py
+++ b/geopandas/tools/tests/test_clip.py
@@ -360,3 +360,8 @@ def test_warning_geomcoll(single_rectangle_gdf, geomcol_gdf):
     called on a GDF with GeometryCollection"""
     with pytest.warns(UserWarning):
         clip(geomcol_gdf, single_rectangle_gdf, keep_geom_type=True)
+
+
+def test_warning_crs_mismatch(point_gdf, single_rectangle_gdf):
+    with pytest.warns(UserWarning, match="CRS mismatch between the CRS"):
+        clip(point_gdf, single_rectangle_gdf.to_crs(3857))
diff --git a/geopandas/tools/tests/test_sjoin.py b/geopandas/tools/tests/test_sjoin.py
index 5c0f9b0a..b3013476 100644
--- a/geopandas/tools/tests/test_sjoin.py
+++ b/geopandas/tools/tests/test_sjoin.py
@@ -95,7 +95,7 @@ class TestSpatialJoin:
     def test_crs_mismatch(self, dfs):
         index, df1, df2, expected = dfs
         df1.crs = "epsg:4326"
-        with pytest.warns(UserWarning):
+        with pytest.warns(UserWarning, match="CRS mismatch between the CRS"):
             sjoin(df1, df2)
 
     @pytest.mark.parametrize(