Repository: geopandas/geopandas

Issue:
DOC: Errors in website providing Geoplot examples
When looking through Geoplot examples in Geopandas I ran across multiple errors inside the online documentation.

#### Screenshot

I think the issue stems from here:
![image](https://github.com/geopandas/geopandas/assets/16186217/96f4e019-429a-40cb-9f89-83e018742412)

But it breaks the code on the whole page:
![image](https://github.com/geopandas/geopandas/assets/16186217/8e198431-eb0e-40fd-bc84-43a4620eda50)

#### Problem description
[Link to page](https://geopandas.org/en/stable/gallery/plotting_with_geoplot.html)
Data is not loaded properly into Geopandas and therefore it can't be used for plotting.

#### Expected Output

The expected output is a properly rendered map.


Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b0a18651..ab8cbb04 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -78,6 +78,8 @@ Deprecations and compatibility notes:
   - Removed deprecated GeoSeries/ GeoDataFrame methods `__xor__`, `__or__`, `__and__` and 
     `__sub__`. Instead use methods `symmetric_difference`, `union`, `intersection` and 
     `difference` respectively.
+- Fixes for compatibility with psycopg (#3167).
+
 
 ## Version 0.14.3 (Jan 31, 2024)
 
diff --git a/doc/source/gallery/plotting_with_geoplot.ipynb b/doc/source/gallery/plotting_with_geoplot.ipynb
deleted file mode 100644
index 441fd741..00000000
--- a/doc/source/gallery/plotting_with_geoplot.ipynb
+++ /dev/null
@@ -1,192 +0,0 @@
-{
- "cells": [
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "\n",
-    "# Plotting with Geoplot and GeoPandas\n",
-    "\n",
-    "[Geoplot](https://residentmario.github.io/geoplot/index.html) is a Python\n",
-    "library providing a selection of easy-to-use geospatial visualizations. It is\n",
-    "built on top of the lower-level [CartoPy](http://scitools.org.uk/cartopy/),\n",
-    "covered in a separate section of this tutorial, and is designed to work with\n",
-    "GeoPandas input.\n",
-    "\n",
-    "This example is a brief tour of the `geoplot` API. For more details on the\n",
-    "library refer to [its documentation](https://residentmario.github.io/geoplot/index.html).\n",
-    "\n",
-    "First we'll load in the data using GeoPandas.\n"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "import geopandas\n",
-    "import geodatasets\n",
-    "import geoplot\n",
-    "\n",
-    "world = geopandas.read_file(\"https://naciscdn.org/naturalearth/110m/cultural/ne_110m_admin_0_countries.zip\")\n",
-    "boroughs = geopandas.read_file(geoplot.datasets.get_path(\"nyc_boroughs\"))\n",
-    "collisions = geopandas.read_file(geoplot.datasets.get_path(\"nyc_injurious_collisions\"))"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Plotting with Geoplot\n",
-    "=====================\n",
-    "\n",
-    "We start out by replicating the basic GeoPandas world plot using Geoplot.\n",
-    "\n"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "geoplot.polyplot(world, figsize=(8, 4))"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Geoplot can re-project data into any of the map projections provided by\n",
-    "CartoPy (see the list\n",
-    "[here](http://scitools.org.uk/cartopy/docs/latest/crs/projections.html)).\n",
-    "\n"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# use the Orthographic map projection (e.g. a world globe)\n",
-    "ax = geoplot.polyplot(world, projection=geoplot.crs.Orthographic(), figsize=(8, 4))\n",
-    "ax.outline_patch.set_visible(True)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "``polyplot`` is trivial and can only plot the geometries you pass to it. If\n",
-    "you want to use color as a visual variable, specify a ``choropleth``. Here\n",
-    "we sort GDP per person by country into five buckets by color, using\n",
-    "\"quantiles\" binning from the [Mapclassify](https://pysal.org/mapclassify/)\n",
-    "library.\n",
-    "\n"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "import mapclassify\n",
-    "\n",
-    "gpd_per_person = world[\"gdp_md_est\"] / world[\"pop_est\"]\n",
-    "scheme = mapclassify.Quantiles(gpd_per_person, k=5)\n",
-    "\n",
-    "# Note: this code sample requires geoplot>=0.4.0.\n",
-    "geoplot.choropleth(\n",
-    "    world, hue=gpd_per_person, scheme=scheme, cmap=\"Greens\", figsize=(8, 4)\n",
-    ")"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "If you want to use size as a visual variable, use a ``cartogram``. Here are\n",
-    "population estimates for countries in Africa.\n",
-    "\n"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "africa = world.query('continent == \"Africa\"')\n",
-    "ax = geoplot.cartogram(\n",
-    "    africa, scale=\"pop_est\", limits=(0.2, 1), edgecolor=\"None\", figsize=(7, 8)\n",
-    ")\n",
-    "geoplot.polyplot(africa, edgecolor=\"gray\", ax=ax)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "If we have data in the shape of points in space, we may generate a\n",
-    "three-dimensional heatmap on it using ``kdeplot``.\n",
-    "\n"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {
-    "tags": [
-     "nbsphinx-thumbnail"
-    ]
-   },
-   "outputs": [],
-   "source": [
-    "ax = geoplot.kdeplot(\n",
-    "    collisions.head(1000),\n",
-    "    clip=boroughs.geometry,\n",
-    "    shade=True,\n",
-    "    cmap=\"Reds\",\n",
-    "    projection=geoplot.crs.AlbersEqualArea(),\n",
-    ")\n",
-    "geoplot.polyplot(boroughs, ax=ax, zorder=1)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "These are just some of the plots you can make with Geoplot. There are\n",
-    "many other possibilities not covered in this brief introduction. For more\n",
-    "examples, refer to the\n",
-    "[Gallery](https://residentmario.github.io/geoplot/gallery/index.html) in\n",
-    "the Geoplot documentation.\n",
-    "\n"
-   ]
-  }
- ],
- "metadata": {
-  "kernelspec": {
-   "display_name": "Python 3",
-   "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.0"
-  }
- },
- "nbformat": 4,
- "nbformat_minor": 4
-}
diff --git a/doc/source/getting_started/install.rst b/doc/source/getting_started/install.rst
index 2abb210b..d5db47b9 100644
--- a/doc/source/getting_started/install.rst
+++ b/doc/source/getting_started/install.rst
@@ -152,7 +152,8 @@ Required dependencies:
 Further, optional dependencies are:
 
 - `pyogrio`_ (optional; faster alternative for fiona, will become the default in GeoPandas 1.0)
-- `psycopg2`_ (optional; for PostGIS connection)
+- `psycopg`_ (optional; for PostGIS connection)
+- `psycopg2`_ (optional; for PostGIS connection - older version of `psycopg` library)
 - `GeoAlchemy2`_ (optional; for writing to PostGIS)
 - `geopy`_ (optional; for geocoding)
 - `pointpats`_ (optional; for advanced point sampling)
@@ -182,6 +183,8 @@ For plotting, these additional packages may be used:
 
 .. _geopy: https://github.com/geopy/geopy
 
+.. _psycopg: https://pypi.python.org/pypi/psycopg
+
 .. _psycopg2: https://pypi.python.org/pypi/psycopg2
 
 .. _GeoAlchemy2: https://geoalchemy-2.readthedocs.io/
diff --git a/environment-dev.yml b/environment-dev.yml
index 4e6c4883..db5f53d7 100644
--- a/environment-dev.yml
+++ b/environment-dev.yml
@@ -33,10 +33,10 @@ dependencies:
     - geopy
     # geodatabase access
     - psycopg2>=2.8.0
+    - psycopg>=3.1.0
     - SQLAlchemy>=1.3
     # plotting
     - matplotlib>=3.5
     - mapclassify
     # point sampling
     - pointpats
-
diff --git a/geopandas/_compat.py b/geopandas/_compat.py
index 0032eb47..297e7f3d 100644
--- a/geopandas/_compat.py
+++ b/geopandas/_compat.py
@@ -13,6 +13,7 @@ import shapely.geos
 PANDAS_GE_14 = Version(pd.__version__) >= Version("1.4.0rc0")
 PANDAS_GE_15 = Version(pd.__version__) >= Version("1.5.0")
 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")
 PANDAS_GE_30 = Version(pd.__version__) >= Version("3.0.0.dev0")
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index fea7f3fc..d5d361e5 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -2001,7 +2001,7 @@ properties': {'col1': 'name1'}, 'geometry': {'type': 'Point', 'coordinates': (1.
         Upload GeoDataFrame into PostGIS database.
 
         This method requires SQLAlchemy and GeoAlchemy2, and a PostgreSQL
-        Python driver (e.g. psycopg2) to be installed.
+        Python driver (psycopg or psycopg2) to be installed.
 
         It is also possible to use :meth:`~GeoDataFrame.to_file` to write to a database.
         Especially for file geodatabases like GeoPackage or SpatiaLite this can be
diff --git a/geopandas/geoseries.py b/geopandas/geoseries.py
index 1a4aaa13..8ff2faa5 100644
--- a/geopandas/geoseries.py
+++ b/geopandas/geoseries.py
@@ -808,12 +808,10 @@ class GeoSeries(GeoPandasBase, Series):
         """Alias for `notna` method. See `notna` for more detail."""
         return self.notna()
 
-    def fillna(self, value=None, method=None, inplace: bool = False, **kwargs):
+    def fillna(self, value=None, inplace: bool = False, **kwargs):
         """
         Fill NA values with geometry (or geometries).
 
-        ``method`` is currently not implemented.
-
         Parameters
         ----------
         value : shapely geometry or GeoSeries, default None
@@ -882,7 +880,7 @@ class GeoSeries(GeoPandasBase, Series):
         """
         if value is None:
             value = GeometryCollection()
-        return super().fillna(value=value, method=method, inplace=inplace, **kwargs)
+        return super().fillna(value=value, inplace=inplace, **kwargs)
 
     def __contains__(self, other) -> bool:
         """Allow tests of the form "geom in s"
diff --git a/geopandas/io/arrow.py b/geopandas/io/arrow.py
index ee6c7f1e..d08b90de 100644
--- a/geopandas/io/arrow.py
+++ b/geopandas/io/arrow.py
@@ -559,7 +559,7 @@ def _read_parquet(path, columns=None, storage_options=None, **kwargs):
         filesystem is preferred. Provide the instantiated fsspec filesystem using
         the ``filesystem`` keyword if you wish to use its implementation.
     **kwargs
-        Any additional kwargs passed to pyarrow.parquet.read_table().
+        Any additional kwargs passed to :func:`pyarrow.parquet.read_table`.
 
     Returns
     -------
diff --git a/geopandas/io/sql.py b/geopandas/io/sql.py
index 7bcb9c55..c6607e44 100644
--- a/geopandas/io/sql.py
+++ b/geopandas/io/sql.py
@@ -304,11 +304,16 @@ def _psql_insert_copy(tbl, conn, keys, data_iter):
     columns = ", ".join('"{}"'.format(k) for k in keys)
 
     dbapi_conn = conn.connection
+    sql = 'COPY "{}"."{}" ({}) FROM STDIN WITH CSV'.format(
+        tbl.table.schema, tbl.table.name, columns
+    )
     with dbapi_conn.cursor() as cur:
-        sql = 'COPY "{}"."{}" ({}) FROM STDIN WITH CSV'.format(
-            tbl.table.schema, tbl.table.name, columns
-        )
-        cur.copy_expert(sql=sql, file=s_buf)
+        # Use psycopg method if it's available
+        if hasattr(cur, "copy") and callable(cur.copy):
+            with cur.copy(sql) as copy:
+                copy.write(s_buf.read())
+        else:  # otherwise use psycopg2 method
+            cur.copy_expert(sql, s_buf)
 
 
 def _write_postgis(
diff --git a/geopandas/tools/_show_versions.py b/geopandas/tools/_show_versions.py
index 90986e06..04a87ac5 100644
--- a/geopandas/tools/_show_versions.py
+++ b/geopandas/tools/_show_versions.py
@@ -120,6 +120,7 @@ def _get_deps_info():
         "matplotlib",
         "mapclassify",
         "pyogrio",
+        "psycopg",
         "psycopg2",
         "pyarrow",
     ]
diff --git a/pyproject.toml b/pyproject.toml
index 3941f7d3..382829fb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -30,7 +30,7 @@ dependencies = [
 
 [project.optional-dependencies]
 all = [
-    "psycopg2-binary>=2.8.0",
+    "psycopg-binary>=3.1.0",
     "SQLAlchemy>=1.3",
     "geopy",
     "matplotlib>=3.5.0",
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 911f439c..4808fbf5 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -7,6 +7,7 @@ packaging
 
 # geodatabase access
 psycopg2-binary>=2.8.0
+psycopg-binary>=3.1.0
 SQLAlchemy>=1.3
 
 # geocoding


Test patch:
diff --git a/ci/envs/311-latest-conda-forge.yaml b/ci/envs/311-latest-conda-forge.yaml
index 83d8513b..c5d1a091 100644
--- a/ci/envs/311-latest-conda-forge.yaml
+++ b/ci/envs/311-latest-conda-forge.yaml
@@ -27,6 +27,7 @@ dependencies:
   # installed in tests.yaml, because not available on windows
   # - postgis
   - SQLalchemy>=2
+  - psycopg
   - psycopg2
   - libspatialite
   - geoalchemy2
diff --git a/geopandas/io/tests/test_sql.py b/geopandas/io/tests/test_sql.py
index 286d9b74..4444d9bf 100644
--- a/geopandas/io/tests/test_sql.py
+++ b/geopandas/io/tests/test_sql.py
@@ -7,6 +7,7 @@ see geopandas.tests.util for more information.
 
 import os
 import warnings
+from importlib.util import find_spec
 
 import pandas as pd
 
@@ -32,25 +33,43 @@ def df_nybb(nybb_filename):
     return df
 
 
-@pytest.fixture()
-def connection_postgis():
-    """
-    Initiates a connection to a postGIS database that must already exist.
-    See create_postgis for more information.
+def check_available_postgis_drivers() -> list[str]:
+    """Work out which of psycopg2 and psycopg are available.
+    This prevents tests running if the relevant package isn't installed
+    (rather than being skipped, as skips are treated as failures during postgis CI)
     """
-    psycopg2 = pytest.importorskip("psycopg2")
-    from psycopg2 import OperationalError
-
-    dbname = "test_geopandas"
-    user = os.environ.get("PGUSER")
-    password = os.environ.get("PGPASSWORD")
-    host = os.environ.get("PGHOST")
-    port = os.environ.get("PGPORT")
+    drivers = []
+    if find_spec("psycopg"):
+        drivers.append("psycopg")
+    if find_spec("psycopg2"):
+        drivers.append("psycopg2")
+    return drivers
+
+
+POSTGIS_DRIVERS = check_available_postgis_drivers()
+
+
+def prepare_database_credentials() -> dict:
+    """Gather postgres connection credentials from environment variables."""
+    return {
+        "dbname": "test_geopandas",
+        "user": os.environ.get("PGUSER"),
+        "password": os.environ.get("PGPASSWORD"),
+        "host": os.environ.get("PGHOST"),
+        "port": os.environ.get("PGPORT"),
+    }
+
+
+@pytest.fixture()
+def connection_postgis(request):
+    """Create a postgres connection using either psycopg2 or psycopg.
+
+    Use this as an indirect fixture, where the request parameter is POSTGIS_DRIVERS."""
+    psycopg = pytest.importorskip(request.param)
+
     try:
-        con = psycopg2.connect(
-            dbname=dbname, user=user, password=password, host=host, port=port
-        )
-    except OperationalError:
+        con = psycopg.connect(**prepare_database_credentials())
+    except psycopg.OperationalError:
         pytest.skip("Cannot connect with postgresql database")
     with warnings.catch_warnings():
         warnings.filterwarnings(
@@ -61,28 +80,25 @@ def connection_postgis():
 
 
 @pytest.fixture()
-def engine_postgis():
+def engine_postgis(request):
     """
-    Initiates a connection engine to a postGIS database that must already exist.
+    Initiate a sqlalchemy connection engine using either psycopg2 or psycopg.
+
+    Use this as an indirect fixture, where the request parameter is POSTGIS_DRIVERS.
     """
     sqlalchemy = pytest.importorskip("sqlalchemy")
     from sqlalchemy.engine.url import URL
 
-    user = os.environ.get("PGUSER")
-    password = os.environ.get("PGPASSWORD")
-    host = os.environ.get("PGHOST")
-    port = os.environ.get("PGPORT")
-    dbname = "test_geopandas"
-
+    credentials = prepare_database_credentials()
     try:
         con = sqlalchemy.create_engine(
             URL.create(
-                drivername="postgresql+psycopg2",
-                username=user,
-                database=dbname,
-                password=password,
-                host=host,
-                port=port,
+                drivername=f"postgresql+{request.param}",
+                username=credentials["user"],
+                database=credentials["dbname"],
+                password=credentials["password"],
+                host=credentials["host"],
+                port=credentials["port"],
             )
         )
         con.connect()
@@ -204,6 +220,7 @@ def df_3D_geoms():
 
 
 class TestIO:
+    @pytest.mark.parametrize("engine_postgis", POSTGIS_DRIVERS, indirect=True)
     def test_get_conn(self, engine_postgis):
         Connection = pytest.importorskip("sqlalchemy.engine.base").Connection
 
@@ -217,6 +234,7 @@ class TestIO:
             with get_conn(object()):
                 pass
 
+    @pytest.mark.parametrize("connection_postgis", POSTGIS_DRIVERS, indirect=True)
     def test_read_postgis_default(self, connection_postgis, df_nybb):
         con = connection_postgis
         create_postgis(con, df_nybb)
@@ -229,6 +247,7 @@ class TestIO:
         # by user; should not be set to 0, as from get_srid failure
         assert df.crs is None
 
+    @pytest.mark.parametrize("connection_postgis", POSTGIS_DRIVERS, indirect=True)
     def test_read_postgis_custom_geom_col(self, connection_postgis, df_nybb):
         con = connection_postgis
         geom_col = "the_geom"
@@ -239,6 +258,7 @@ class TestIO:
 
         validate_boro_df(df)
 
+    @pytest.mark.parametrize("connection_postgis", POSTGIS_DRIVERS, indirect=True)
     def test_read_postgis_select_geom_as(self, connection_postgis, df_nybb):
         """Tests that a SELECT {geom} AS {some_other_geom} works."""
         con = connection_postgis
@@ -254,6 +274,7 @@ class TestIO:
 
         validate_boro_df(df)
 
+    @pytest.mark.parametrize("connection_postgis", POSTGIS_DRIVERS, indirect=True)
     def test_read_postgis_get_srid(self, connection_postgis, df_nybb):
         """Tests that an SRID can be read from a geodatabase (GH #451)."""
         con = connection_postgis
@@ -267,6 +288,7 @@ class TestIO:
         validate_boro_df(df)
         assert df.crs == crs
 
+    @pytest.mark.parametrize("connection_postgis", POSTGIS_DRIVERS, indirect=True)
     def test_read_postgis_override_srid(self, connection_postgis, df_nybb):
         """Tests that a user specified CRS overrides the geodatabase SRID."""
         con = connection_postgis
@@ -279,6 +301,7 @@ class TestIO:
         validate_boro_df(df)
         assert df.crs == orig_crs
 
+    @pytest.mark.parametrize("connection_postgis", POSTGIS_DRIVERS, indirect=True)
     def test_from_postgis_default(self, connection_postgis, df_nybb):
         con = connection_postgis
         create_postgis(con, df_nybb)
@@ -288,6 +311,7 @@ class TestIO:
 
         validate_boro_df(df, case_sensitive=False)
 
+    @pytest.mark.parametrize("connection_postgis", POSTGIS_DRIVERS, indirect=True)
     def test_from_postgis_custom_geom_col(self, connection_postgis, df_nybb):
         con = connection_postgis
         geom_col = "the_geom"
@@ -323,6 +347,7 @@ class TestIO:
         df = read_postgis(sql, con, geom_col=geom_col)
         validate_boro_df(df)
 
+    @pytest.mark.parametrize("connection_postgis", POSTGIS_DRIVERS, indirect=True)
     def test_read_postgis_chunksize(self, connection_postgis, df_nybb):
         """Test chunksize argument"""
         chunksize = 2
@@ -337,6 +362,7 @@ class TestIO:
         # by user; should not be set to 0, as from get_srid failure
         assert df.crs is None
 
+    @pytest.mark.parametrize("engine_postgis", POSTGIS_DRIVERS, indirect=True)
     def test_write_postgis_default(self, engine_postgis, df_nybb):
         """Tests that GeoDataFrame can be written to PostGIS with defaults."""
         engine = engine_postgis
@@ -352,6 +378,7 @@ class TestIO:
         df = read_postgis(sql, engine, geom_col="geometry")
         validate_boro_df(df)
 
+    @pytest.mark.parametrize("engine_postgis", POSTGIS_DRIVERS, indirect=True)
     def test_write_postgis_uppercase_tablename(self, engine_postgis, df_nybb):
         """Tests writing GeoDataFrame to PostGIS with uppercase tablename."""
         engine = engine_postgis
@@ -367,6 +394,7 @@ class TestIO:
         df = read_postgis(sql, engine, geom_col="geometry")
         validate_boro_df(df)
 
+    @pytest.mark.parametrize("engine_postgis", POSTGIS_DRIVERS, indirect=True)
     def test_write_postgis_sqlalchemy_connection(self, engine_postgis, df_nybb):
         """Tests that GeoDataFrame can be written to PostGIS with defaults."""
         with engine_postgis.begin() as con:
@@ -382,6 +410,7 @@ class TestIO:
             df = read_postgis(sql, con, geom_col="geometry")
             validate_boro_df(df)
 
+    @pytest.mark.parametrize("engine_postgis", POSTGIS_DRIVERS, indirect=True)
     def test_write_postgis_fail_when_table_exists(self, engine_postgis, df_nybb):
         """
         Tests that uploading the same table raises error when: if_replace='fail'.
@@ -401,6 +430,7 @@ class TestIO:
             else:
                 raise e
 
+    @pytest.mark.parametrize("engine_postgis", POSTGIS_DRIVERS, indirect=True)
     def test_write_postgis_replace_when_table_exists(self, engine_postgis, df_nybb):
         """
         Tests that replacing a table is possible when: if_replace='replace'.
@@ -418,6 +448,7 @@ class TestIO:
         df = read_postgis(sql, engine, geom_col="geometry")
         validate_boro_df(df)
 
+    @pytest.mark.parametrize("engine_postgis", POSTGIS_DRIVERS, indirect=True)
     def test_write_postgis_append_when_table_exists(self, engine_postgis, df_nybb):
         """
         Tests that appending to existing table produces correct results when:
@@ -448,6 +479,7 @@ class TestIO:
             ),
         )
 
+    @pytest.mark.parametrize("engine_postgis", POSTGIS_DRIVERS, indirect=True)
     def test_write_postgis_without_crs(self, engine_postgis, df_nybb):
         """
         Tests that GeoDataFrame can be written to PostGIS without CRS information.
@@ -470,6 +502,7 @@ class TestIO:
             target_srid = conn.execute(sql).fetchone()[0]
         assert target_srid == 0, "SRID should be 0, found %s" % target_srid
 
+    @pytest.mark.parametrize("engine_postgis", POSTGIS_DRIVERS, indirect=True)
     def test_write_postgis_with_esri_authority(self, engine_postgis, df_nybb):
         """
         Tests that GeoDataFrame can be written to PostGIS with ESRI Authority
@@ -492,6 +525,7 @@ class TestIO:
             target_srid = conn.execute(sql).fetchone()[0]
         assert target_srid == 102003, "SRID should be 102003, found %s" % target_srid
 
+    @pytest.mark.parametrize("engine_postgis", POSTGIS_DRIVERS, indirect=True)
     def test_write_postgis_geometry_collection(
         self, engine_postgis, df_geom_collection
     ):
@@ -518,6 +552,7 @@ class TestIO:
         assert geom_type.upper() == "GEOMETRYCOLLECTION"
         assert df.geom_type.unique()[0] == "GeometryCollection"
 
+    @pytest.mark.parametrize("engine_postgis", POSTGIS_DRIVERS, indirect=True)
     def test_write_postgis_mixed_geometry_types(
         self, engine_postgis, df_mixed_single_and_multi
     ):
@@ -544,6 +579,7 @@ class TestIO:
         assert res[1][0].upper() == "MULTILINESTRING"
         assert res[2][0].upper() == "POINT"
 
+    @pytest.mark.parametrize("engine_postgis", POSTGIS_DRIVERS, indirect=True)
     def test_write_postgis_linear_ring(self, engine_postgis, df_linear_ring):
         """
         Tests that writing a LinearRing.
@@ -565,6 +601,7 @@ class TestIO:
 
         assert geom_type.upper() == "LINESTRING"
 
+    @pytest.mark.parametrize("engine_postgis", POSTGIS_DRIVERS, indirect=True)
     def test_write_postgis_in_chunks(self, engine_postgis, df_mixed_single_and_multi):
         """
         Tests writing a LinearRing works.
@@ -598,6 +635,7 @@ class TestIO:
         assert res[1][0].upper() == "MULTILINESTRING"
         assert res[2][0].upper() == "POINT"
 
+    @pytest.mark.parametrize("engine_postgis", POSTGIS_DRIVERS, indirect=True)
     def test_write_postgis_to_different_schema(self, engine_postgis, df_nybb):
         """
         Tests writing data to alternative schema.
@@ -621,6 +659,7 @@ class TestIO:
         df = read_postgis(sql, engine, geom_col="geometry")
         validate_boro_df(df)
 
+    @pytest.mark.parametrize("engine_postgis", POSTGIS_DRIVERS, indirect=True)
     def test_write_postgis_to_different_schema_when_table_exists(
         self, engine_postgis, df_nybb
     ):
@@ -665,6 +704,7 @@ class TestIO:
         df = read_postgis(sql, engine, geom_col="geometry")
         validate_boro_df(df)
 
+    @pytest.mark.parametrize("engine_postgis", POSTGIS_DRIVERS, indirect=True)
     def test_write_postgis_3D_geometries(self, engine_postgis, df_3D_geoms):
         """
         Tests writing a geometries with 3 dimensions works.
@@ -680,6 +720,7 @@ class TestIO:
         df = read_postgis(sql, engine, geom_col="geometry")
         assert list(df.geometry.has_z) == [True, True, True]
 
+    @pytest.mark.parametrize("engine_postgis", POSTGIS_DRIVERS, indirect=True)
     def test_row_order(self, engine_postgis, df_nybb):
         """
         Tests that the row order in db table follows the order of the original frame.
@@ -696,6 +737,7 @@ class TestIO:
         df = read_postgis(sql, engine, geom_col="geometry")
         assert df["BoroCode"].tolist() == correct_order
 
+    @pytest.mark.parametrize("engine_postgis", POSTGIS_DRIVERS, indirect=True)
     def test_append_before_table_exists(self, engine_postgis, df_nybb):
         """
         Tests that insert works with if_exists='append' when table does not exist yet.
@@ -713,6 +755,7 @@ class TestIO:
         df = read_postgis(sql, engine, geom_col="geometry")
         validate_boro_df(df)
 
+    @pytest.mark.parametrize("engine_postgis", POSTGIS_DRIVERS, indirect=True)
     def test_append_with_different_crs(self, engine_postgis, df_nybb):
         """
         Tests that the warning is raised if table CRS differs from frame.
@@ -729,9 +772,10 @@ class TestIO:
         with pytest.raises(ValueError, match="CRS of the target table"):
             write_postgis(df_nybb2, con=engine, name=table, if_exists="append")
 
+    @pytest.mark.parametrize("engine_postgis", POSTGIS_DRIVERS, indirect=True)
     @pytest.mark.xfail(
-        compat.PANDAS_GE_20 and not compat.PANDAS_GE_21,
-        reason="Duplicate columns are dropped in read_sql with pandas 2.0.x",
+        compat.PANDAS_GE_20 and 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/tests/test_api.py b/geopandas/tests/test_api.py
index 4f8c72c5..4704fade 100644
--- a/geopandas/tests/test_api.py
+++ b/geopandas/tests/test_api.py
@@ -14,6 +14,7 @@ def test_no_additional_imports():
         # "matplotlib",  # matplotlib gets imported by pandas, see below
         "mapclassify",
         "sqlalchemy",
+        "psycopg",
         "psycopg2",
         "geopy",
         "geoalchemy2",
diff --git a/geopandas/tests/test_show_versions.py b/geopandas/tests/test_show_versions.py
index a4178ddc..7071adc3 100644
--- a/geopandas/tests/test_show_versions.py
+++ b/geopandas/tests/test_show_versions.py
@@ -37,6 +37,7 @@ def test_get_deps_info():
     assert "matplotlib" in deps_info
     assert "mapclassify" in deps_info
     assert "geopy" in deps_info
+    assert "psycopg" in deps_info
     assert "psycopg2" in deps_info
     assert "geoalchemy2" in deps_info