Repository: geopandas/geopandas
Pull request #2997 (2023-09-01 12:20:25)

REF: Change GeometryDtype.na_value to None
The `GeometryDtype.na_value` in practice is `None` (this is being returned when accessing a missing value), but the attribute itself it set to NaN. 

In the past I remember that it gave some test failures, but let's see what it does now.

jorisvandenbossche: And indeed, it gives a whole bunch of errors, it seems up to pandas 2.0. So we might be able to change this in the future when supporting pandas >= 2.0
m-richards: > And indeed, it gives a whole bunch of errors, it seems up to pandas 2.0. So we might be able to change this in the future when supporting pandas >= 2.0

I imagine defining `na_value` conditionally based on pandas version would be bad?  (I could imagine this might be a problem for pickling but not sure)
jorisvandenbossche: It might not be too bad to make `na_value` dependent on the pandas version, but I would personally like to avoid that I think
m-richards: With https://github.com/pandas-dev/pandas/pull/54930 this is no longer needed, and I suppose we can close this. (Though longer term it's perhaps still worth looking at setting the NA value to None for consistency once we only support pandas >=2)

Patch:
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
deleted file mode 100644
index cea47a1c..00000000
--- a/.github/FUNDING.yml
+++ /dev/null
@@ -1,2 +0,0 @@
-github: numfocus
-custom: https://numfocus.org/donate-for-geopandas
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index be81015f..25ab12b9 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -5,7 +5,7 @@ ci:
 files: 'geopandas\/'
 repos:
     - repo: https://github.com/astral-sh/ruff-pre-commit
-      rev: "v0.5.5"
+      rev: "v0.8.6"
       hooks:
         - id: ruff-format
         - id: ruff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1f3c9d8a..b9d6dacd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,31 +2,16 @@
 
 ## Version 1.1.0
 
-Notes on dependencies:
-
-- GeoPandas 1.1 now requires Python 3.10 or greater and pandas 2.0, numpy 1.24, pyproj 3.5,
-  are now the minimum required version for these dependencies.
-  Furthermore, the minimum tested version for optional dependencies has been updated to
-  fiona 1.8.21, scipy 1.9, matplotlib 3.7, mapclassify 2.5, folium 0.12 and
-  SQLAlchemy 2.0. Older versions of these libraries may continue to work, but are no longer
-  considered supported (#3371).
-
 New features and improvements:
 
 - Add ``grid_size`` parameter to ``union_all`` and ``dissolve`` (#3445).
-
-Bug fixes:
-
-- Fix an issue that showed numpy dtypes in bbox in `to_geo_dict` and `__geo_interface__`. (#3436)
-
-New features and improvements:
-
 - `GeoDataFrame.plot` now supports `pd.Index` as an input for the `column` keyword (#3463).
-
-## Version 1.0.2 (???)
+- Avoid change of the plot aspect when plotting missing values (#3438).
 
 Bug fixes:
 
+- Fix an issue that showed numpy dtypes in bbox in `to_geo_dict` and `__geo_interface__`. (#3436)
+- Fix an issue in `sample_points` that could occasionally result in non-uniform distribution (#3470).
 - Fix unspecified layer warning being emitted while reading multilayer datasets, even
   when layer is specified when using the mask or bbox keywords (#3378).
 - Properly support named aggregations over a geometry column in `GroupBy.agg` (#3368).
@@ -37,13 +22,30 @@ Bug fixes:
 - Fix regression where `overlay` with `keep_geom_type` returns wrong results if the
   input contains invalid geometries (#3395).
 - Fix the dtype of the GeometryArray backing data being incorrect for zero length
-  GeoDataFrames causing errors in `overlay` (3424).
+  GeoDataFrames causing errors in `overlay` (#3424).
+- Fix regression where constructing a GeoSeries from a pd.Series with GeometryDtype values
+  failed when `crs` was provided (#3383).
+
+Notes on dependencies:
+
+- GeoPandas 1.1 now requires Python 3.10 or greater and pandas 2.0, numpy 1.24, pyproj 3.5,
+  are now the minimum required version for these dependencies.
+  Furthermore, the minimum tested version for optional dependencies has been updated to
+  fiona 1.8.21, scipy 1.9, matplotlib 3.7, mapclassify 2.5, folium 0.12 and
+  SQLAlchemy 2.0. Older versions of these libraries may continue to work, but are no longer
+  considered supported (#3371).
 
 Deprecations and compatibility notes:
 
 - The `GeoSeries.select` method wrapping the pandas `Series.select` method has been removed.
   The upstream method no longer exists in all supported version of pandas (#3394).
 
+New features and improvements:
+
+- Added `geopandas.accessors` module. Import this module to register a
+  `pandas.Series.geo` accessor, which exposes GeoSeries methods via pandas's
+  extension mechanism (#3272).
+
 ## Version 1.0.1 (July 2, 2024)
 
 Bug fixes:
diff --git a/doc/source/docs/user_guide/io.rst b/doc/source/docs/user_guide/io.rst
index 35f60f87..7d79e8c2 100644
--- a/doc/source/docs/user_guide/io.rst
+++ b/doc/source/docs/user_guide/io.rst
@@ -26,6 +26,11 @@ without extra arguments, but for more help, type::
     import pyogrio; help(pyogrio.read_dataframe)
     import fiona; help(fiona.open)
 
+.. note::
+    For faster data reading, pass ``use_arrow=True`` when using the default pyogrio engine. This can be 2-4 times faster than the default reading behavior and works with all drivers. See `pyogrio.read_dataframe <https://pyogrio.readthedocs.io/en/latest/api.html#pyogrio.read_dataframe>`_ for full details.
+
+    Note that this requires the ``pyarrow`` dependency to exist in your environment.
+
 Among other things, one can explicitly set the driver (shapefile, GeoJSON) with
 the ``driver`` keyword, or pick a single layer from a multi-layered file with
 the ``layer`` keyword::
@@ -76,6 +81,15 @@ You can also read path objects::
     path_object = pathlib.Path(filename)
     df = geopandas.read_file(path_object)
 
+Using Arrow for faster reading
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+For faster data reading, pass ``use_arrow=True`` when using the default pyogrio engine. This can be 2-4 times faster than the default reading behavior and works with all drivers. See `pyogrio.read_dataframe <https://pyogrio.readthedocs.io/en/latest/api.html#pyogrio.read_dataframe>`_ for full details.
+
+It is also possible to enable this by default by setting the environment variable ``PYOGRIO_USE_ARROW=1`` (which will also enable writing data using arrow).
+
+Note that this requires the ``pyarrow`` dependency to exist in your environment.
+
 Reading subsets of the data
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
@@ -203,6 +217,11 @@ For a full list of supported formats, type ``import pyogrio; pyogrio.list_driver
 In addition, GeoDataFrames can be uploaded to `PostGIS <https://postgis.net/>`__ database (starting with GeoPandas 0.8)
 by using the :meth:`geopandas.GeoDataFrame.to_postgis` method.
 
+.. note::
+    For faster data writing, pass ``use_arrow=True`` when using the default pyogrio engine. This can be 2-4 times faster than the default writing behavior and works with all drivers. See `pyogrio.write_dataframe <https://pyogrio.readthedocs.io/en/latest/api.html#pyogrio.write_dataframe>`_ for full details.
+
+    Note that this requires the ``pyarrow`` dependency to exist in your environment.
+
 .. note::
 
     GeoDataFrame can contain more field types than supported by most of the file formats. For example tuples or lists
@@ -219,6 +238,10 @@ by using the :meth:`geopandas.GeoDataFrame.to_postgis` method.
 
     countries_gdf.to_file("countries.shp")
 
+**Writing to Shapefile with via Arrow**::
+
+    countries_gdf.to_file("countries.shp", use_arrow=True)
+
 **Writing to GeoJSON**::
 
     countries_gdf.to_file("countries.geojson", driver='GeoJSON')
@@ -257,7 +280,7 @@ Apache Parquet and Feather file formats
 
 .. versionadded:: 0.8.0
 
-GeoPandas supports writing and reading the Apache Parquet and Feather file
+GeoPandas supports writing and reading the Apache Parquet (`GeoParquet <https://geoparquet.org/>`__) and Feather file
 formats.
 
 `Apache Parquet <https://parquet.apache.org/>`__ is an efficient, columnar
@@ -267,7 +290,7 @@ representation of the `Apache Arrow <https://arrow.apache.org/>`__ memory
 format, an open standard for in-memory columnar data.
 
 The :func:`geopandas.read_parquet`, :func:`geopandas.read_feather`,
-:meth:`GeoDataFrame.to_parquet` and :meth:`GeoDataFrame.to_feather` methods
+:meth:`geopandas.GeoDataFrame.to_parquet` and :meth:`geopandas.GeoDataFrame.to_feather` methods
 enable fast roundtrip from GeoPandas to those binary file formats, preserving
 the spatial information.
 
diff --git a/doc/source/gallery/pandas_accessor.ipynb b/doc/source/gallery/pandas_accessor.ipynb
new file mode 100644
index 00000000..a125eaca
--- /dev/null
+++ b/doc/source/gallery/pandas_accessor.ipynb
@@ -0,0 +1,139 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Using GeoPandas via the pandas \"geo\" accessor\n",
+    "\n",
+    "GeoPandas can be used from pandas with the \"geo\" accessor, which is registered on `pandas.Series` when `geopandas.accessors` is first imported."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Import geopandas.accessors to register the \"geo\" accessor.\n",
+    "import geopandas.accessors  # noqa # pylint: disable=unused-import\n",
+    "import pandas as pd\n",
+    "from shapely import Point"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Using the Series.geo accessor\n",
+    "\n",
+    "Construct a Series using the GeometryDtype."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "0    POINT (1 2)\n",
+       "1    POINT (3 4)\n",
+       "2    POINT (5 6)\n",
+       "dtype: geometry"
+      ]
+     },
+     "execution_count": 2,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "s = pd.Series(\n",
+    "    [Point(1, 2), Point(3, 4), Point(5, 6)],\n",
+    "    dtype='geometry',\n",
+    ")\n",
+    "s"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "All GeoSeries methods and properties are available via the \"geo\" accessor."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "0    1.0\n",
+       "1    3.0\n",
+       "2    5.0\n",
+       "dtype: float64"
+      ]
+     },
+     "execution_count": 3,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "s.geo.x"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "0     4.472136\n",
+       "1    10.000000\n",
+       "2    15.620499\n",
+       "dtype: float64"
+      ]
+     },
+     "execution_count": 4,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "s2 = pd.Series(\n",
+    "    [Point(-1, -2), Point(-3, -4), Point(-5, -6)],\n",
+    "    dtype='geometry',\n",
+    ")\n",
+    "s.geo.distance(s2)"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "dev-3.11",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.11.2"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/geopandas/_compat.py b/geopandas/_compat.py
index 3d269a11..53ed07e5 100644
--- a/geopandas/_compat.py
+++ b/geopandas/_compat.py
@@ -9,7 +9,6 @@ import shapely
 # pandas compat
 # -----------------------------------------------------------------------------
 
-PANDAS_GE_20 = Version(pd.__version__) >= Version("2.0.0")
 PANDAS_GE_202 = Version(pd.__version__) >= Version("2.0.2")
 PANDAS_GE_21 = Version(pd.__version__) >= Version("2.1.0")
 PANDAS_GE_22 = Version(pd.__version__) >= Version("2.2.0")
diff --git a/geopandas/accessors.py b/geopandas/accessors.py
new file mode 100644
index 00000000..cd8f8e0e
--- /dev/null
+++ b/geopandas/accessors.py
@@ -0,0 +1,28 @@
+"""
+Accessors for accessing GeoPandas functionality via pandas extension dtypes
+"""
+
+import pandas.api.extensions
+
+from geopandas import GeoSeries
+from geopandas.array import GeometryDtype
+
+
+@pandas.api.extensions.register_series_accessor("geo")
+class GeoSeriesAccessor:
+    """Series.geo accessor to expose GeoSeries methods on pandas Series.
+
+    Parameters
+    ----------
+    series : pandas.Series
+        A Series with geometry dtype.
+    """
+
+    def __init__(self, series):
+        if not isinstance(series.dtype, GeometryDtype):
+            raise AttributeError("Can only use .geo accessor with GeometryDtype values")
+
+        self._geoseries = GeoSeries(series)
+
+    def __getattr__(self, name):
+        return getattr(self._geoseries, name)
diff --git a/geopandas/array.py b/geopandas/array.py
index ed917ce1..d818a2ce 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -52,7 +52,7 @@ geometry_type_values = np.array(list(type_mapping.values()), dtype=object)
 class GeometryDtype(ExtensionDtype):
     type = BaseGeometry
     name = "geometry"
-    na_value = np.nan
+    na_value = None
 
     @classmethod
     def construct_from_string(cls, string):
diff --git a/geopandas/base.py b/geopandas/base.py
index a2dfdb00..2d4efbab 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -34,6 +34,18 @@ def _delegate_binary_method(op, this, other, align, *args, **kwargs):
     else:
         maybe_warn = False
     this = this.geometry
+
+    # Use same alignment logic, regardless of if `other` is Series or GeoSeries.
+    if (
+        not isinstance(other, GeoPandasBase)
+        and isinstance(other, pd.Series)
+        and isinstance(other.dtype, GeometryDtype)
+    ):
+        # Avoid circular imports by importing here.
+        import geopandas.geoseries
+
+        other = geopandas.geoseries.GeoSeries(other)
+
     if isinstance(other, GeoPandasBase):
         if align and not this.index.equals(other.index):
             if maybe_warn:
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index c5756ed7..c6cc2412 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -178,19 +178,28 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
                 )
 
             # only if we have actual geometry values -> call set_geometry
-            try:
-                if (
-                    hasattr(self["geometry"].values, "crs")
-                    and self["geometry"].values.crs
-                    and crs
-                    and not self["geometry"].values.crs == crs
-                ):
-                    raise ValueError(crs_mismatch_error)
-                self["geometry"] = _ensure_geometry(self["geometry"].values, crs)
-            except TypeError:
-                pass
-            else:
-                geometry = "geometry"
+            if (
+                hasattr(self["geometry"].values, "crs")
+                and self["geometry"].values.crs
+                and crs
+                and not self["geometry"].values.crs == crs
+            ):
+                raise ValueError(crs_mismatch_error)
+            # If "geometry" is potentially coercible to geometry, we try and convert it
+            geom_dtype = self["geometry"].dtype
+            if (
+                geom_dtype == "geometry"  # noqa: PLR1714
+                or geom_dtype == "object"
+                # special case for geometry = [], has float dtype
+                or (len(self) == 0 and geom_dtype == "float")
+            ):
+                try:
+                    self["geometry"] = _ensure_geometry(self["geometry"].values, crs)
+                except TypeError:
+                    pass
+                else:
+                    # feed through to call set geometry below
+                    geometry = "geometry"
 
         if geometry is not None:
             if (
@@ -1221,7 +1230,7 @@ properties': {'col1': 'name1'}, 'geometry': {'type': 'Point', 'coordinates': (1.
 
         See https://geoarrow.org/ for details on the GeoArrow specification.
 
-        This functions returns a generic Arrow data object implementing
+        This function returns a generic Arrow data object implementing
         the `Arrow PyCapsule Protocol`_ (i.e. having an ``__arrow_c_stream__``
         method). This object can then be consumed by your Arrow implementation
         of choice that supports this protocol.
@@ -1802,8 +1811,7 @@ default 'snappy'
 
         if not pd.api.types.is_list_like(key) and (
             key == self._geometry_column_name
-            or key == "geometry"
-            and self._geometry_column_name is None
+            or (key == "geometry" and self._geometry_column_name is None)
         ):
             if pd.api.types.is_scalar(value) or isinstance(value, BaseGeometry):
                 value = [value] * self.shape[0]
@@ -1918,7 +1926,7 @@ default 'snappy'
 
     def __finalize__(self, other, method=None, **kwargs):
         """propagate metadata from other to self"""
-        self = super().__finalize__(other, method=method, **kwargs)
+        self = super().__finalize__(other, method=method, **kwargs)  # noqa: PLW0642
 
         # merge operation: using metadata of the left object
         if method == "merge":
@@ -2080,25 +2088,7 @@ default 'snappy'
 
         # Process non-spatial component
         data = self.drop(labels=self.geometry.name, axis=1)
-        with warnings.catch_warnings(record=True) as record:
-            aggregated_data = data.groupby(**groupby_kwargs).agg(aggfunc, **kwargs)
-        for w in record:
-            if str(w.message).startswith("The default value of numeric_only"):
-                msg = (
-                    f"The default value of numeric_only in aggfunc='{aggfunc}' "
-                    "within pandas.DataFrameGroupBy.agg used in dissolve is "
-                    "deprecated. In pandas 2.0, numeric_only will default to False. "
-                    "Either specify numeric_only as additional argument in dissolve() "
-                    "or select only columns which should be valid for the function."
-                )
-                warnings.warn(msg, FutureWarning, stacklevel=2)
-            else:
-                # Only want to capture specific warning,
-                # other warnings from pandas should be passed through
-                # TODO this is not an ideal approach
-                warnings.showwarning(
-                    w.message, w.category, w.filename, w.lineno, w.file, w.line
-                )
+        aggregated_data = data.groupby(**groupby_kwargs).agg(aggfunc, **kwargs)
 
         aggregated_data.columns = aggregated_data.columns.to_flat_index()
 
@@ -2228,35 +2218,6 @@ default 'snappy'
 
         return df
 
-    # overrides the pandas astype method to ensure the correct return type
-    # should be removable when pandas 1.4 is dropped
-    def astype(self, dtype, copy=None, errors="raise", **kwargs):
-        """
-        Cast a pandas object to a specified dtype ``dtype``.
-        Returns a GeoDataFrame when the geometry column is kept as geometries,
-        otherwise returns a pandas DataFrame.
-        See the pandas.DataFrame.astype docstring for more details.
-        Returns
-        -------
-        GeoDataFrame or DataFrame
-        """
-        if not PANDAS_GE_30 and copy is None:
-            copy = True
-        if copy is not None:
-            kwargs["copy"] = copy
-
-        df = super().astype(dtype, errors=errors, **kwargs)
-
-        try:
-            geoms = df[self._geometry_column_name]
-            if is_geometry_type(geoms):
-                return geopandas.GeoDataFrame(df, geometry=self._geometry_column_name)
-        except KeyError:
-            pass
-        # if the geometry column is converted to non-geometries or did not exist
-        # do not return a GeoDataFrame
-        return pd.DataFrame(df)
-
     def to_postgis(
         self,
         name,
diff --git a/geopandas/io/file.py b/geopandas/io/file.py
index 4884d0dd..b2dfd9b5 100644
--- a/geopandas/io/file.py
+++ b/geopandas/io/file.py
@@ -20,7 +20,7 @@ from shapely.geometry import mapping
 from shapely.geometry.base import BaseGeometry
 
 from geopandas import GeoDataFrame, GeoSeries
-from geopandas._compat import HAS_PYPROJ, PANDAS_GE_20
+from geopandas._compat import HAS_PYPROJ
 from geopandas.io.util import vsi_path
 
 _VALID_URLS = set(uses_relative + uses_netloc + uses_params)
@@ -264,6 +264,11 @@ def _read_file(
     may fail. In this case, the proper encoding can be specified explicitly
     by using the encoding keyword parameter, e.g. ``encoding='utf-8'``.
 
+    For faster data reading with the default pyogrio engine when
+    pyarrow is installed, pass ``use_arrow=True`` as an argument. See the User
+    Guide page :doc:`../../user_guide/io` for details.
+
+
     When specifying a URL, geopandas will check if the server supports reading
     partial data and in that case pass the URL as is to the underlying engine,
     which will then use the network file system handler of GDAL to read from
@@ -448,10 +453,7 @@ def _read_file_fiona(
                 # fiona only supports up to ms precision (any microseconds are
                 # floating point rounding error)
                 if as_dt is not None and not (as_dt.dtype == "object"):
-                    if PANDAS_GE_20:
-                        df[k] = as_dt.dt.as_unit("ms")
-                    else:
-                        df[k] = as_dt.dt.round(freq="ms")
+                    df[k] = as_dt.dt.as_unit("ms")
             return df
 
 
diff --git a/geopandas/plotting.py b/geopandas/plotting.py
index 5b22236a..ae5e788f 100644
--- a/geopandas/plotting.py
+++ b/geopandas/plotting.py
@@ -882,7 +882,7 @@ def plot_dataframe(
         merged_kwds = style_kwds.copy()
         merged_kwds.update(missing_kwds)
 
-        plot_series(expl_series[nan_idx], ax=ax, **merged_kwds)
+        plot_series(expl_series[nan_idx], ax=ax, **merged_kwds, aspect=None)
 
     if legend and not color:
         if legend_kwds is None:
diff --git a/geopandas/tools/__init__.py b/geopandas/tools/__init__.py
index 6ece4117..60077bf9 100644
--- a/geopandas/tools/__init__.py
+++ b/geopandas/tools/__init__.py
@@ -5,11 +5,11 @@ from .sjoin import sjoin, sjoin_nearest
 from .util import collect
 
 __all__ = [
+    "clip",
     "collect",
     "geocode",
     "overlay",
     "reverse_geocode",
     "sjoin",
     "sjoin_nearest",
-    "clip",
 ]
diff --git a/geopandas/tools/_random.py b/geopandas/tools/_random.py
index 210c44e3..1d930657 100644
--- a/geopandas/tools/_random.py
+++ b/geopandas/tools/_random.py
@@ -86,4 +86,5 @@ def _uniform_polygon(geom, size, generator):
         )
         valid_samples = batch[batch.sindex.query(geom, predicate="contains")]
         candidates.extend(valid_samples)
+    generator.shuffle(candidates)  # avoid the artifacts of STRTree ordering
     return GeoSeries(candidates[:size]).union_all()
diff --git a/geopandas/tools/clip.py b/geopandas/tools/clip.py
index d1386f30..7567ad96 100644
--- a/geopandas/tools/clip.py
+++ b/geopandas/tools/clip.py
@@ -24,13 +24,29 @@ from geopandas.array import (
 
 
 def _mask_is_list_like_rectangle(mask):
+    """
+    Check if the input mask is list-like and not an instance of
+    specific geometric types.
+
+    Parameters
+    ----------
+    mask : GeoDataFrame, GeoSeries, (Multi)Polygon, list-like
+        Polygon vector layer used to clip ``gdf``.
+
+    Returns
+    -------
+    bool
+        True if `mask` is list-like and not an instance of `GeoDataFrame`,
+        `GeoSeries`, `Polygon`, or `MultiPolygon`, otherwise False.
+    """
     return pandas.api.types.is_list_like(mask) and not isinstance(
         mask, GeoDataFrame | GeoSeries | Polygon | MultiPolygon
     )
 
 
 def _clip_gdf_with_mask(gdf, mask, sort=False):
-    """Clip geometry to the polygon/rectangle extent.
+    """
+    Clip geometry to the polygon/rectangle extent.
 
     Clip an input GeoDataFrame to the polygon extent of the polygon
     parameter.
@@ -163,17 +179,17 @@ def clip(gdf, mask, keep_geom_type=False, sort=False):
     if not isinstance(gdf, GeoDataFrame | GeoSeries):
         raise TypeError(f"'gdf' should be GeoDataFrame or GeoSeries, got {type(gdf)}")
 
-    mask_is_list_like = _mask_is_list_like_rectangle(mask)
+    clipping_by_rectangle = _mask_is_list_like_rectangle(mask)
     if (
         not isinstance(mask, GeoDataFrame | GeoSeries | Polygon | MultiPolygon)
-        and not mask_is_list_like
+        and not clipping_by_rectangle
     ):
         raise TypeError(
             "'mask' should be GeoDataFrame, GeoSeries,"
             f"(Multi)Polygon or list-like, got {type(mask)}"
         )
 
-    if mask_is_list_like and len(mask) != 4:
+    if clipping_by_rectangle and len(mask) != 4:
         raise TypeError(
             "If 'mask' is list-like, it must have four values (minx, miny, maxx, maxy)"
         )
@@ -184,7 +200,7 @@ def clip(gdf, mask, keep_geom_type=False, sort=False):
 
     if isinstance(mask, GeoDataFrame | GeoSeries):
         box_mask = mask.total_bounds
-    elif mask_is_list_like:
+    elif clipping_by_rectangle:
         box_mask = mask
     else:
         # Avoid empty tuple returned by .bounds when geometry is empty. A tuple of
diff --git a/pyproject.toml b/pyproject.toml
index 842f1c34..9717a007 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -31,7 +31,7 @@ dependencies = [
 
 [project.optional-dependencies]
 all = [
-    "psycopg-binary>=3.1.0",
+    "psycopg[binary]>=3.1.0",
     "SQLAlchemy>=2.0",
     "geopy",
     "matplotlib>=3.7",
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 8f9395a0..0eca77c7 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -8,7 +8,7 @@ packaging
 
 # geodatabase access
 psycopg2-binary>=2.8.0
-psycopg-binary>=3.1.0
+psycopg[binary]>=3.1.0
 SQLAlchemy>=2.0
 
 # geocoding


Test patch:
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index d167d253..f7f05710 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -96,4 +96,4 @@ jobs:
         run: |
           pytest -v --color=yes --doctest-only geopandas --ignore=geopandas/datasets
 
-      - uses: codecov/codecov-action@v4
+      - uses: codecov/codecov-action@v5
diff --git a/geopandas/io/tests/test_file.py b/geopandas/io/tests/test_file.py
index 74857fef..db450144 100644
--- a/geopandas/io/tests/test_file.py
+++ b/geopandas/io/tests/test_file.py
@@ -18,7 +18,7 @@ from shapely.geometry import Point, Polygon, box, mapping
 
 import geopandas
 from geopandas import GeoDataFrame, read_file
-from geopandas._compat import HAS_PYPROJ, PANDAS_GE_20, PANDAS_GE_30
+from geopandas._compat import HAS_PYPROJ, PANDAS_GE_30
 from geopandas.io.file import _EXTENSION_TO_DRIVER, _detect_driver
 
 import pytest
@@ -229,8 +229,7 @@ def test_to_file_datetime(tmpdir, driver, ext, time, engine):
     assert_geodataframe_equal(df.drop(columns=["b"]), df_read.drop(columns=["b"]))
     # Check datetime column
     expected = df["b"]
-    if PANDAS_GE_20:
-        expected = df["b"].dt.as_unit("ms")
+    expected = df["b"].dt.as_unit("ms")
     actual = df_read["b"]
     if df["b"].dt.tz is not None:
         # US/Eastern becomes a UTC-5 fixed offset when read from file
@@ -281,8 +280,6 @@ def test_read_file_datetime_invalid(tmpdir, ext, engine):
 
 @pytest.mark.parametrize("ext", dt_exts)
 def test_read_file_datetime_out_of_bounds_ns(tmpdir, ext, engine):
-    if engine == "pyogrio" and not PANDAS_GE_20:
-        pytest.skip("with pyogrio requires pandas >= 2.0 to pass")
     # https://github.com/geopandas/geopandas/issues/2502
     date_str = "9999-12-31T00:00:00"  # valid to GDAL, not to [ns] format
     tempfilename = write_invalid_date_file(date_str, tmpdir, ext, engine)
diff --git a/geopandas/io/tests/test_sql.py b/geopandas/io/tests/test_sql.py
index 1232c31e..e3313744 100644
--- a/geopandas/io/tests/test_sql.py
+++ b/geopandas/io/tests/test_sql.py
@@ -767,7 +767,7 @@ class TestIO:
 
     @pytest.mark.parametrize("engine_postgis", POSTGIS_DRIVERS, indirect=True)
     @pytest.mark.xfail(
-        compat.PANDAS_GE_20 and not compat.PANDAS_GE_202,
+        not compat.PANDAS_GE_202,
         reason="Duplicate columns are dropped in read_sql with pandas 2.0.0 and 2.0.1",
     )
     def test_duplicate_geometry_column_fails(self, engine_postgis):
diff --git a/geopandas/testing.py b/geopandas/testing.py
index fd83eafb..b99345d7 100644
--- a/geopandas/testing.py
+++ b/geopandas/testing.py
@@ -158,7 +158,7 @@ def assert_geoseries_equal(
         Typically useful with ``check_less_precise=True``, which uses
         ``geom_equals_exact`` and requires exact coordinate order.
     """
-    assert len(left) == len(right), "%d != %d" % (len(left), len(right))
+    assert len(left) == len(right), f"{len(left)} != {len(right)}"
 
     if check_dtype:
         msg = "dtype should be a GeometryDtype, got {0}"
diff --git a/geopandas/tests/test_dissolve.py b/geopandas/tests/test_dissolve.py
index 22ede130..0d8e5e62 100644
--- a/geopandas/tests/test_dissolve.py
+++ b/geopandas/tests/test_dissolve.py
@@ -7,7 +7,7 @@ from shapely import MultiPolygon, Polygon
 
 import geopandas
 from geopandas import GeoDataFrame, read_file
-from geopandas._compat import HAS_PYPROJ, PANDAS_GE_20, PANDAS_GE_30
+from geopandas._compat import HAS_PYPROJ, PANDAS_GE_30
 
 import pytest
 from geopandas.testing import assert_geodataframe_equal, geom_almost_equals
@@ -98,10 +98,7 @@ def test_dissolve_emits_other_warnings(nybb_polydf):
     # test to be true on any version
     def sum_and_warn(group):
         warnings.warn("foo")  # noqa: B028
-        if PANDAS_GE_20:
-            return group.sum(numeric_only=False)
-        else:
-            return group.sum()
+        return group.sum(numeric_only=False)
 
     with pytest.warns(UserWarning, match="foo"):
         nybb_polydf.dissolve("manhattan_bronx", aggfunc=sum_and_warn)
diff --git a/geopandas/tests/test_explore.py b/geopandas/tests/test_explore.py
index 88b757d5..03cf5e52 100644
--- a/geopandas/tests/test_explore.py
+++ b/geopandas/tests/test_explore.py
@@ -22,6 +22,7 @@ from matplotlib import cm, colors
 
 BRANCA_05 = Version(branca.__version__) > Version("0.4.2")
 FOLIUM_G_014 = Version(folium.__version__) > Version("0.14.0")
+FOLIUM_GE_019 = Version(folium.__version__) >= Version("0.19.0")
 
 
 @pytest.fixture(scope="class")
@@ -70,7 +71,10 @@ class TestExplore:
             pytest.approx(2.842170943040401e-14, rel=1e-6),
         ]
         assert m.options["zoom"] == 10
-        assert m.options["zoomControl"] is True
+        if not FOLIUM_GE_019:
+            assert m.options["zoomControl"] is True
+        else:
+            assert m.options["zoom_control"] is True
         assert m.position == "relative"
         assert m.height == (100.0, "%")
         assert m.width == (100.0, "%")
@@ -93,7 +97,10 @@ class TestExplore:
             pytest.approx(-73.9778006856748, rel=1e-6),
         ]
         assert m.options["zoom"] == 10
-        assert m.options["zoomControl"] is False
+        if not FOLIUM_GE_019:
+            assert m.options["zoomControl"] is False
+        else:
+            assert m.options["zoom_control"] is False
         assert m.height == (200.0, "px")
         assert m.width == (200.0, "px")
 
@@ -751,7 +758,7 @@ class TestExplore:
             'attribution":"\\u0026copy;\\u003cahref=\\"https://www.openstreetmap.org'
             in out_str
         )
-        assert '"maxZoom":20,"minZoom":0' in out_str
+        assert '"maxZoom":20' in out_str
 
     @pytest.mark.skipif(not HAS_PYPROJ, reason="requires pyproj")
     def test_xyzservices_query_name(self):
@@ -768,7 +775,7 @@ class TestExplore:
             'attribution":"\\u0026copy;\\u003cahref=\\"https://www.openstreetmap.org'
             in out_str
         )
-        assert '"maxZoom":20,"minZoom":0' in out_str
+        assert '"maxZoom":20' in out_str
 
     @pytest.mark.skipif(not HAS_PYPROJ, reason="requires pyproj")
     def test_xyzservices_providers_min_zoom_override(self):
@@ -779,7 +786,7 @@ class TestExplore:
         )
         out_str = self._fetch_map_string(m)
 
-        assert '"maxZoom":20,"minZoom":3' in out_str
+        assert '"minZoom":3' in out_str
 
     @pytest.mark.skipif(not HAS_PYPROJ, reason="requires pyproj")
     def test_xyzservices_providers_max_zoom_override(self):
@@ -790,7 +797,7 @@ class TestExplore:
         )
         out_str = self._fetch_map_string(m)
 
-        assert '"maxZoom":12,"minZoom":0' in out_str
+        assert '"maxZoom":12' in out_str
 
     @pytest.mark.skipif(not HAS_PYPROJ, reason="requires pyproj")
     def test_xyzservices_providers_both_zooms_override(self):
@@ -803,7 +810,8 @@ class TestExplore:
         )
         out_str = self._fetch_map_string(m)
 
-        assert '"maxZoom":12,"minZoom":3' in out_str
+        assert '"maxZoom":12' in out_str
+        assert '"minZoom":3' in out_str
 
     def test_linearrings(self):
         rings = self.nybb.explode(index_parts=True).exterior
@@ -979,9 +987,15 @@ class TestExplore:
     def test_map_kwds(self):
         def check():
             out_str = self._fetch_map_string(m)
-            assert "zoomControl:false" in out_str
-            assert "dragging:false" in out_str
-            assert "scrollWheelZoom:false" in out_str
+            if not FOLIUM_GE_019:
+                assert m.options["zoomControl"] is False
+            else:
+                assert m.options["zoom_control"] is False
+            assert "dragging:false" in out_str or '"dragging":false' in out_str
+            assert (
+                "scrollWheelZoom:false" in out_str
+                or '"scrollWheelZoom":false' in out_str
+            )
 
         # check that folium and leaflet Map() parameters can be passed
         m = self.world.explore(
diff --git a/geopandas/tests/test_geodataframe.py b/geopandas/tests/test_geodataframe.py
index c34ef948..0b558410 100644
--- a/geopandas/tests/test_geodataframe.py
+++ b/geopandas/tests/test_geodataframe.py
@@ -602,6 +602,14 @@ class TestDataFrame:
         assert type(df) is GeoDataFrame
         assert type(df.copy()) is GeoDataFrame
 
+    def test_empty(self):
+        df = GeoDataFrame({"geometry": []})
+        assert df.geometry.dtype == "geometry"
+        df = GeoDataFrame({"a": []}, geometry="a")
+        assert df.geometry.dtype == "geometry"
+        df = GeoDataFrame(geometry=[])
+        assert df.geometry.dtype == "geometry"
+
     def test_bool_index(self):
         # Find boros with 'B' in their name
         df = self.df[self.df["BoroName"].str.contains("B")]
diff --git a/geopandas/tests/test_overlay.py b/geopandas/tests/test_overlay.py
index ed2f4c1d..c9f8dad0 100644
--- a/geopandas/tests/test_overlay.py
+++ b/geopandas/tests/test_overlay.py
@@ -8,7 +8,7 @@ from shapely.geometry import GeometryCollection, LineString, Point, Polygon, box
 
 import geopandas
 from geopandas import GeoDataFrame, GeoSeries, overlay, read_file
-from geopandas._compat import HAS_PYPROJ, PANDAS_GE_20
+from geopandas._compat import HAS_PYPROJ
 
 import pytest
 from geopandas.testing import assert_geodataframe_equal, assert_geoseries_equal
@@ -768,18 +768,12 @@ def test_non_overlapping(how):
     result = overlay(df1, df2, how=how)
 
     if how == "intersection":
-        if PANDAS_GE_20:
-            index = None
-        else:
-            index = pd.Index([], dtype="object")
-
         expected = GeoDataFrame(
             {
                 "col1": np.array([], dtype="int64"),
                 "col2": np.array([], dtype="int64"),
                 "geometry": [],
-            },
-            index=index,
+            }
         )
     elif how == "union":
         expected = GeoDataFrame(
diff --git a/geopandas/tests/test_pandas_accessor.py b/geopandas/tests/test_pandas_accessor.py
new file mode 100644
index 00000000..ad81b550
--- /dev/null
+++ b/geopandas/tests/test_pandas_accessor.py
@@ -0,0 +1,73 @@
+"""Ensure geopandas methods can be accessed via pandas 'geo' accessor."""
+
+import re
+
+import pandas as pd
+
+from shapely.geometry import Point
+
+# Use this import to register the "geo" accessor.
+import geopandas.accessors  # noqa: F401 # pylint: disable=unused-import
+from geopandas.array import GeometryDtype
+
+import pandas.testing
+import pytest
+
+
+@pytest.fixture
+def s():
+    return pd.Series(
+        [Point(x, y) for x, y in zip(range(3), range(3))], dtype=GeometryDtype()
+    )
+
+
+@pytest.fixture
+def s2():
+    return pd.Series(
+        [Point(x, y + 1) for x, y in zip(range(3), range(3))], dtype=GeometryDtype()
+    )
+
+
+def test_series_geo_buffer(s):
+    """Ensure returned geometry values have the expected dtype."""
+    got = s.geo.buffer(0.2)
+    assert isinstance(got.dtype, GeometryDtype)
+
+    # Double-check that the .geo accessor works on the result.
+    radius = got.geo.minimum_bounding_radius()
+    for row in radius.index:
+        assert radius[row] >= 0.1999  # Allow for some rounding error.
+
+
+def test_series_geo_distance(s, s2):
+    got = s.geo.distance(s2)
+    pandas.testing.assert_series_equal(
+        got,
+        pd.Series([1.0, 1.0, 1.0]),
+    )
+
+
+def test_series_geo_x(s):
+    x = s.geo.x
+    pandas.testing.assert_series_equal(
+        x,
+        pd.Series([0.0, 1.0, 2.0]),
+    )
+
+
+def test_series_geo_x_attributeerror_for_not_geo_dtype():
+    s = pd.Series([1, 2, 3])
+
+    with pytest.raises(
+        AttributeError,
+        match=re.escape("Can only use .geo accessor with GeometryDtype values"),
+    ):
+        s.geo.x
+
+
+def test_series_geo_y(s):
+    y = s.geo.y
+    pandas.testing.assert_series_equal(
+        y,
+        pd.Series([0.0, 1.0, 2.0]),
+    )
diff --git a/geopandas/tests/test_pandas_methods.py b/geopandas/tests/test_pandas_methods.py
index 83d4c767..4e182051 100644
--- a/geopandas/tests/test_pandas_methods.py
+++ b/geopandas/tests/test_pandas_methods.py
@@ -332,10 +332,7 @@ def test_to_csv(df):
 def test_numerical_operations(s, df):
     # df methods ignore the geometry column
     exp = pd.Series([3, 4], index=["value1", "value2"])
-    if not compat.PANDAS_GE_20:
-        res = df.sum()
-    else:
-        res = df.sum(numeric_only=True)
+    res = df.sum(numeric_only=True)
     assert_series_equal(res, exp)
 
     # series methods raise error (not supported for geometry)
@@ -549,10 +546,7 @@ def test_value_counts():
     # each object is considered unique
     s = GeoSeries([Point(0, 0), Point(1, 1), Point(0, 0)])
     res = s.value_counts()
-    if compat.PANDAS_GE_20:
-        name = "count"
-    else:
-        name = None
+    name = "count"
     exp = pd.Series([2, 1], index=from_shapely([Point(0, 0), Point(1, 1)]), name=name)
     assert_series_equal(res, exp)
     # Check crs doesn't make a difference - note it is not kept in output index anyway
@@ -613,10 +607,8 @@ def test_groupby(df):
     assert_frame_equal(res, exp)
 
     # reductions ignore geometry column
-    if not compat.PANDAS_GE_20:
-        res = df.groupby("value2").sum()
-    else:
-        res = df.groupby("value2").sum(numeric_only=True)
+
+    res = df.groupby("value2").sum(numeric_only=True)
     exp = pd.DataFrame({"value1": [2, 1], "value2": [1, 2]}, dtype="int64").set_index(
         "value2"
     )
@@ -696,11 +688,7 @@ def test_groupby_metadata(crs, geometry_name):
 
     df.groupby("value2").apply(func, **kwargs)
     # selecting the non-group columns -> no need to pass the keyword
-    if (
-        compat.PANDAS_GE_22
-        or (compat.PANDAS_GE_20 and geometry_name == "geometry")
-        or not compat.PANDAS_GE_20
-    ):
+    if compat.PANDAS_GE_22 or (geometry_name == "geometry"):
         df.groupby("value2")[[geometry_name, "value1"]].apply(func)
     else:
         # https://github.com/geopandas/geopandas/pull/2966#issuecomment-1878816712
@@ -841,6 +829,19 @@ def test_pivot(df):
     assert_geodataframe_equal(result, expected)
 
 
+def test_isna_empty_dtypes():
+    # https://github.com/geopandas/geopandas/issues/3417
+    # should not auto coerce isna to geometry dtype
+    expected = pd.DataFrame({"geometry": []}).isna()
+    actual = GeoDataFrame({"geometry": []}).isna()
+    assert_frame_equal(expected, actual)
+
+    # different geometry col name
+    expected = pd.DataFrame({"a": []}).isna()
+    actual = GeoDataFrame({"a": []}, geometry="a").isna()
+    assert_frame_equal(expected, actual)
+
+
 def test_preserve_attrs(df):
     # https://github.com/geopandas/geopandas/issues/1654
     df.attrs["name"] = "my_name"
diff --git a/geopandas/tests/test_plotting.py b/geopandas/tests/test_plotting.py
index e74f7a1c..83395ac8 100644
--- a/geopandas/tests/test_plotting.py
+++ b/geopandas/tests/test_plotting.py
@@ -451,6 +451,17 @@ class TestPointPlotting:
         df["category"] = df["values"].astype("str")
         df.plot("category", missing_kwds={"facecolor": "none"}, legend=True)
 
+    def test_missing_aspect(self):
+        self.df.loc[0, "values"] = np.nan
+        ax = self.df.plot(
+            "values",
+            missing_kwds={"color": "r"},
+            categorical=True,
+            legend=True,
+            aspect=2,
+        )
+        assert ax.get_aspect() == 2
+
 
 class TestPointZPlotting:
     def setup_method(self):
diff --git a/geopandas/tests/test_sindex.py b/geopandas/tests/test_sindex.py
index e3d6143f..efaf3801 100644
--- a/geopandas/tests/test_sindex.py
+++ b/geopandas/tests/test_sindex.py
@@ -156,12 +156,12 @@ class TestFrameSindex:
         # with pandas 2.0, the column is now copied, losing the index. But
         # with pandas >= 3.0 and Copy-on-Write this is preserved again
         subset1 = self.df[["geom", "A"]]
-        if compat.PANDAS_GE_20 and not compat.PANDAS_GE_30:
+        if not compat.PANDAS_GE_30:
             assert subset1.sindex is not original_index
         else:
             assert subset1.sindex is original_index
         subset2 = self.df[["A", "geom"]]
-        if compat.PANDAS_GE_20 and not compat.PANDAS_GE_30:
+        if not compat.PANDAS_GE_30:
             assert subset2.sindex is not original_index
         else:
             assert subset2.sindex is original_index
diff --git a/geopandas/tools/tests/test_random.py b/geopandas/tools/tests/test_random.py
index a8d9a4fb..9d4c14b9 100644
--- a/geopandas/tools/tests/test_random.py
+++ b/geopandas/tools/tests/test_random.py
@@ -1,5 +1,7 @@
 import numpy
 
+import shapely
+
 import geopandas
 from geopandas.tools._random import uniform
 
@@ -65,3 +67,22 @@ def test_uniform_generator(polygons):
 
     assert sample.equals(gen_sample)
     assert not sample.equals(gen_sample2)
+
+
+@pytest.mark.parametrize("size", range(5, 12))
+def test_unimodality(size):  # GH 3470
+    circle = shapely.Point(0, 0).buffer(1)
+    generator = numpy.random.default_rng(seed=1)
+    centers_x = []
+    centers_y = []
+    for _ in range(200):
+        pts = shapely.get_coordinates(uniform(circle, size=2**size, rng=generator))
+        centers_x.append(numpy.mean(pts[:, 0]))
+        centers_y.append(numpy.mean(pts[:, 1]))
+
+    numpy.testing.assert_allclose(numpy.mean(centers_x), 0, atol=1e-2)
+    numpy.testing.assert_allclose(numpy.mean(centers_y), 0, atol=1e-2)
+
+    stats = pytest.importorskip("scipy.stats")
+    assert stats.shapiro(centers_x).pvalue > 0.05
+    assert stats.shapiro(centers_y).pvalue > 0.05