Repository: geopandas/geopandas

Issue:
BUG: `to_postgis()` not compatible with psycopg v3
- [x] I have checked that this issue has not already been reported.

- [x] I have confirmed this bug exists on the latest version of geopandas.

- [x] (optional) I have confirmed this bug exists on the main branch of geopandas.

---

#### Problem description

The `to_postgis()` docs state:

> This method requires SQLAlchemy and GeoAlchemy2, and a PostgreSQL Python driver (e.g. psycopg2) to be installed.

I understand the "e.g." to mean that other drivers like psycopg (version 3) are/should be possible. 

However, `to_postgis()` or rather `_psql_insert_copy()` uses psycopg2's `copy_expert()` method:
https://github.com/geopandas/geopandas/blob/8f2b4cc1b2cbb62aed716c9a187e800e79df3cd4/geopandas/io/sql.py#L328

`copy_expert()` has been deprecated in psycopg in favor of [a single `copy()` method](https://www.psycopg.org/psycopg3/docs/basic/from_pg2.html#copy-is-no-longer-file-based). Hence, `to_postgis()` fails when used with a psycopg-enabled sqlAlchemy engine.

(More generally the use of a psycopg2 method means that `to_postgis()` only supports _that_ driver and not with any of the [other drivers supported by sqlAlchemy](https://docs.sqlalchemy.org/en/20/dialects/postgresql.html#dialect-postgresql), though I cannot say anything about their practical relevance. Except for [psycopg 3](https://www.psycopg.org/psycopg3/), the successor of psycopg2). 


Discussion:
MrAlecJohnson: (Hopefully this is relatively close to ready, but I've closed it till I can get back to it and get all the tests working, so it doesn't clutter your pull requests in the meantime)
MrAlecJohnson: Problems with test runners seem largely resolved, but there's still a couple I haven't got my head round. Will leave in draft till I can get back to this and work out what's going wrong
m-richards: @MrAlecJohnson I haven't looked through your PR in detail, but it might be handy to know
* the 3.11 dev enviroment builds against dev versions of packages, and if you see failures that look unrelated to what you're working on, they probably are (and we can clarify if they are and you are unsure)
* The tests.yaml handles postgis separately (you may have already seen this), so the postgis tests actually only run in `310-pd20-conda-forge.yaml` and `311-latest-conda-forge.yaml`.
Thanks for the draft contribution, I'll have a look in some detail hopefully soon.
MrAlecJohnson: Ah, okay, I see the issue - it's about dependency versions, so @m-richards it would be great to get your (or anyone else's!) views on how I should approach this. I don't want to mess with the builds unnecessarily.  

1. there's a tiny error in the Pandas compatibility xfails on [test_duplicate_geometry_column_fails](https://github.com/geopandas/geopandas/blob/e996129c5f77fb7aae1d7bbf76dee3c7678a8ecb/geopandas/io/tests/test_sql.py#L743) - this will xfail on 2.0.0 and 2.0.1, but xpass on 2.0.2 and 2.0.3 - and `310-pd20-conda-forge` pins to 2.0 without specifying minor version. I think the easiest fix here is to pin a specific minor version in this build - does that sound reasonable? Or would you rather I make the pandas compatbility checks more specific?

2. I failed to account for psycopg3 requiring sqlalchemy>=2.0. Rather than changing any of the sqlalchemy versions in the builds, I suspect I should just only include psycopg3 when there's already a sqlalchemy above 2.0. Does that also sound sensible? The new psycopg3 postgis tests run nicely on 311-latest-conda-forge, where sqlalchemy>=2.0
m-richards: > 1. there's a tiny error in the Pandas compatibility xfails on [test_duplicate_geometry_column_fails](https://github.com/geopandas/geopandas/blob/e996129c5f77fb7aae1d7bbf76dee3c7678a8ecb/geopandas/io/tests/test_sql.py#L743) - this will xfail on 2.0.0 and 2.0.1, but xpass on 2.0.2 and 2.0.3 - and `310-pd20-conda-forge` pins to 2.0 without specifying minor version. I think the easiest fix here is to pin a specific minor version in this build - does that sound reasonable? Or would you rather I make the pandas compatbility checks more specific?

I would prefer to fix in in the code than in the build deps - you'll notice this is only failing on this branch and not on main, and I presume that's because adding psycopg3 to the yml is causing conda to resolve to a different version of pandas. I think you could define a compat.PANDAS_GE_202 analogous to the others and update the guard in the test (https://github.com/pandas-dev/pandas/pull/53118 is where this was fixed on the pandas side and it lines up with the versions you have checked xfail/ xpass)

> 2. I failed to account for psycopg3 requiring sqlalchemy>=2.0. Rather than changing any of the sqlalchemy versions in the builds, I suspect I should just only include psycopg3 when there's already a sqlalchemy above 2.0. Does that also sound sensible? The new psycopg3 postgis tests run nicely on 311-latest-conda-forge, where sqlalchemy>=2.0

I agree this makes sense - at least for this PR. We only have two dev builds running the postgres tests, so it's good to keep one on sqlalchemy <2 and one on 2+. (Although I think this point 1. of yours will be resolved without you needing to make any code changes, so I supose that's fine too :))

We are at the point where we can probably consider dropping sqlalchemy 1.4 support - but I think thats a separate topic, because pandas didn't support sql 2 until pandas 2.

MrAlecJohnson: Those approaches sound great, thank you. 

Belatedly realised my 'skip tests for packages not installed' approach won't work with the 'TESTS SKIPPED, FAILING' in the test workflow, so I'll have to come back to that, hopefully soon!
MrAlecJohnson: I think this is now ready for review. There are still 2 runners failing, but as far as I can tell the failures are unconnected to these changes. Apologies if I've got that wrong! 

For the `test_sql` tests I've set them to only run on versions of psycopg that are installed, so if only one package is installed only one set of tests is run. If neither psycopg nor psycopg2 is installed, no sql-based tests run, but there are still skips, so they will still set off the 'Fail if tests are skipped' condition in the workflow.
MrAlecJohnson: @m-richards Thank you for those suggestions. All incorporated now, along with a much cleaner test setup and much smaller diff. I've managed to get rid of those unnecessary extra fixtures now. I hope this looks more useful! 



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/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 034d28fb..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")
@@ -67,3 +68,24 @@ def import_optional_dependency(name: str, extra: str = ""):
 # -----------------------------------------------------------------------------
 # pyproj compat
 # -----------------------------------------------------------------------------
+try:
+    import pyproj  # noqa: F401
+
+    HAS_PYPROJ = True
+
+except ImportError as err:
+    HAS_PYPROJ = False
+    pyproj_import_error = str(err)
+
+
+def requires_pyproj(func):
+    def wrapper(*args, **kwargs):
+        if not HAS_PYPROJ:
+            raise ImportError(
+                f"The 'pyproj' package is required for {func.__name__} to work. "
+                "Install it and initialize the object with a CRS before using it."
+                f"\nImporting pyproj resulted in: {pyproj_import_error}"
+            )
+        return func(*args, **kwargs)
+
+    return wrapper
diff --git a/geopandas/array.py b/geopandas/array.py
index ed71bb24..8a8d4caf 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -16,14 +16,16 @@ from pandas.api.extensions import (
     ExtensionDtype,
     register_extension_dtype,
 )
-from pyproj import CRS, Transformer
-from pyproj.aoi import AreaOfInterest
-from pyproj.database import query_utm_crs_info
+
 from shapely.geometry.base import BaseGeometry
 
 from .sindex import SpatialIndex
+from ._compat import HAS_PYPROJ, requires_pyproj
+
+if HAS_PYPROJ:
+    from pyproj import Transformer
 
-TransformerFromCRS = lru_cache(Transformer.from_crs)
+    TransformerFromCRS = lru_cache(Transformer.from_crs)
 
 _names = {
     "MISSING": None,
@@ -379,7 +381,21 @@ class GeometryArray(ExtensionArray):
     @crs.setter
     def crs(self, value):
         """Sets the value of the crs"""
-        self._crs = None if not value else CRS.from_user_input(value)
+        if HAS_PYPROJ:
+            from pyproj import CRS
+
+            self._crs = None if not value else CRS.from_user_input(value)
+        else:
+            if value is not None:
+                warnings.warn(
+                    "Cannot set the CRS, falling back to None. The CRS support requires"
+                    " the 'pyproj' package, but it is not installed or does not import"
+                    " correctly. The functions depending on CRS will raise an error or"
+                    " may produce unexpected results.",
+                    UserWarning,
+                    stacklevel=2,
+                )
+            self._crs = None
 
     def check_geographic_crs(self, stacklevel):
         """Check CRS and warn if the planar operation is done in a geographic CRS"""
@@ -881,6 +897,7 @@ class GeometryArray(ExtensionArray):
             crs=self.crs,
         )
 
+    @requires_pyproj
     def to_crs(self, crs=None, epsg=None):
         """Returns a ``GeometryArray`` with all geometries transformed to a new
         coordinate reference system.
@@ -950,6 +967,8 @@ class GeometryArray(ExtensionArray):
         - Prime Meridian: Greenwich
 
         """
+        from pyproj import CRS
+
         if self.crs is None:
             raise ValueError(
                 "Cannot transform naive geometries.  "
@@ -971,6 +990,7 @@ class GeometryArray(ExtensionArray):
         new_data = transform(self._data, transformer.transform)
         return GeometryArray(new_data, crs=crs)
 
+    @requires_pyproj
     def estimate_utm_crs(self, datum_name="WGS 84"):
         """Returns the estimated UTM CRS based on the bounds of the dataset.
 
@@ -1009,6 +1029,9 @@ class GeometryArray(ExtensionArray):
         - Ellipsoid: WGS 84
         - Prime Meridian: Greenwich
         """
+        from pyproj import CRS
+        from pyproj.aoi import AreaOfInterest
+        from pyproj.database import query_utm_crs_info
 
         if not self.crs:
             raise RuntimeError("crs must be set to estimate UTM CRS.")
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index 63051078..d5d361e5 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -10,14 +10,13 @@ from pandas.core.accessor import CachedAccessor
 from shapely.geometry import mapping, shape
 from shapely.geometry.base import BaseGeometry
 
-from pyproj import CRS
-
 from geopandas.array import GeometryArray, GeometryDtype, from_shapely, to_wkb, to_wkt
 from geopandas.base import GeoPandasBase, is_geometry_type
 from geopandas.geoseries import GeoSeries
 import geopandas.io
 from geopandas.explore import _explore
 from ._decorator import doc
+from ._compat import HAS_PYPROJ
 
 
 def _geodataframe_constructor_with_fallback(*args, **kwargs):
@@ -486,7 +485,15 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
                 crs = state.pop("crs", None)
             else:
                 crs = state.pop("_crs", None)
-            crs = CRS.from_user_input(crs) if crs is not None else crs
+            if crs is not None and not HAS_PYPROJ:
+                raise ImportError(
+                    "Unpickling a GeoDataFrame with CRS requires the 'pyproj' package, "
+                    "but it is not installed or does not import correctly. "
+                )
+            elif crs is not None:
+                from pyproj import CRS
+
+                crs = CRS.from_user_input(crs)
 
         super().__setstate__(state)
 
@@ -1994,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 2a5a7480..8ff2faa5 100644
--- a/geopandas/geoseries.py
+++ b/geopandas/geoseries.py
@@ -10,7 +10,6 @@ import pandas as pd
 from pandas import Series
 from pandas.core.internals import SingleBlockManager
 
-from pyproj import CRS
 import shapely
 from shapely.geometry.base import BaseGeometry
 from shapely.geometry import GeometryCollection
@@ -809,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
@@ -883,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"
@@ -971,7 +968,7 @@ class GeoSeries(GeoPandasBase, Series):
     #
     # Additional methods
     #
-
+    @compat.requires_pyproj
     def set_crs(
         self,
         crs: Optional[Any] = None,
@@ -1049,6 +1046,8 @@ class GeoSeries(GeoPandasBase, Series):
         GeoSeries.to_crs : re-project to another CRS
 
         """
+        from pyproj import CRS
+
         if crs is not None:
             crs = CRS.from_user_input(crs)
         elif epsg is not None:
@@ -1152,7 +1151,7 @@ class GeoSeries(GeoPandasBase, Series):
             self.values.to_crs(crs=crs, epsg=epsg), index=self.index, name=self.name
         )
 
-    def estimate_utm_crs(self, datum_name: str = "WGS 84") -> CRS:
+    def estimate_utm_crs(self, datum_name: str = "WGS 84"):
         """Returns the estimated UTM CRS based on the bounds of the dataset.
 
         .. versionadded:: 0.9
diff --git a/geopandas/io/file.py b/geopandas/io/file.py
index 36485bc8..5d11dbc7 100644
--- a/geopandas/io/file.py
+++ b/geopandas/io/file.py
@@ -8,7 +8,6 @@ import numpy as np
 import pandas as pd
 from pandas.api.types import is_integer_dtype
 
-import pyproj
 import shapely
 from shapely.geometry import mapping
 from shapely.geometry.base import BaseGeometry
@@ -20,7 +19,7 @@ from urllib.parse import urlparse as parse_url
 from urllib.parse import uses_netloc, uses_params, uses_relative
 import urllib.request
 
-from geopandas._compat import PANDAS_GE_20
+from geopandas._compat import PANDAS_GE_20, HAS_PYPROJ
 
 _VALID_URLS = set(uses_relative + uses_netloc + uses_params)
 _VALID_URLS.discard("")
@@ -654,11 +653,19 @@ def _to_file(
 
 
 def _to_file_fiona(df, filename, driver, schema, crs, mode, **kwargs):
+    if not HAS_PYPROJ and crs:
+        raise ImportError(
+            "The 'pyproj' package is required to write a file with a CRS, but it is not"
+            " installed or does not import correctly."
+        )
+
     if schema is None:
         schema = infer_schema(df)
 
     if crs:
-        crs = pyproj.CRS.from_user_input(crs)
+        from pyproj import CRS
+
+        crs = CRS.from_user_input(crs)
     else:
         crs = df.crs
 
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 44153b6e..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",
@@ -91,6 +91,7 @@ filterwarnings = [
     "ignore:The --rsyncdir command line argument:DeprecationWarning",
     # dateutil incompatibility with python 3.12 (https://github.com/dateutil/dateutil/issues/1284)
     "ignore:datetime.datetime.utcfromtimestamp:DeprecationWarning:dateutil",
+    "ignore:Cannot set the CRS, falling back to None:UserWarning:geopandas",
 ]
 
 [tool.black]
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/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index bbeaadad..6cc121b8 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -45,6 +45,7 @@ jobs:
           - ci/envs/310-latest-conda-forge.yaml
           - ci/envs/311-latest-conda-forge.yaml
           - ci/envs/312-latest-conda-forge.yaml
+          - ci/envs/312-latest-conda-forge_no_pyproj.yaml
         include:
           - env: ci/envs/39-latest-conda-forge_no_fiona.yaml
             os: macos-latest
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/ci/envs/312-latest-conda-forge_no_pyproj.yaml b/ci/envs/312-latest-conda-forge_no_pyproj.yaml
new file mode 100644
index 00000000..82ec8506
--- /dev/null
+++ b/ci/envs/312-latest-conda-forge_no_pyproj.yaml
@@ -0,0 +1,31 @@
+name: test
+channels:
+  - conda-forge
+dependencies:
+  - python=3.12
+  # required
+  - pandas
+  - shapely
+  - fiona
+  # - pyproj
+  - packaging
+  # testing
+  - pytest
+  - pytest-cov
+  - pytest-xdist
+  - fsspec
+  # optional
+  # - pyogrio
+  - matplotlib
+  - mapclassify
+  - folium
+  - xyzservices
+  - scipy
+  - geopy
+  # - pointpats
+  - geodatasets
+  # - SQLalchemy>=2
+  # - psycopg2
+  # - libspatialite
+  # - geoalchemy2
+  - pyarrow
diff --git a/geopandas/io/tests/test_arrow.py b/geopandas/io/tests/test_arrow.py
index bb74452b..f40e0aeb 100644
--- a/geopandas/io/tests/test_arrow.py
+++ b/geopandas/io/tests/test_arrow.py
@@ -10,7 +10,6 @@ import pytest
 from pandas import DataFrame, read_parquet as pd_read_parquet
 from pandas.testing import assert_frame_equal
 import numpy as np
-import pyproj
 import shapely
 from shapely.geometry import box, Point, MultiPolygon
 
@@ -32,6 +31,7 @@ from geopandas.io.arrow import (
 )
 from geopandas.testing import assert_geodataframe_equal, assert_geoseries_equal
 from geopandas.tests.util import mock
+from geopandas._compat import HAS_PYPROJ
 
 
 DATA_PATH = pathlib.Path(os.path.dirname(__file__)) / "data"
@@ -68,9 +68,10 @@ def test_create_metadata(naturalearth_lowres):
     assert metadata["version"] == METADATA_VERSION
     assert metadata["primary_column"] == "geometry"
     assert "geometry" in metadata["columns"]
-    crs_expected = df.crs.to_json_dict()
-    _remove_id_from_member_of_ensembles(crs_expected)
-    assert metadata["columns"]["geometry"]["crs"] == crs_expected
+    if HAS_PYPROJ:
+        crs_expected = df.crs.to_json_dict()
+        _remove_id_from_member_of_ensembles(crs_expected)
+        assert metadata["columns"]["geometry"]["crs"] == crs_expected
     assert metadata["columns"]["geometry"]["encoding"] == "WKB"
     assert metadata["columns"]["geometry"]["geometry_types"] == [
         "MultiPolygon",
@@ -86,6 +87,7 @@ def test_create_metadata(naturalearth_lowres):
 
 
 def test_crs_metadata_datum_ensemble():
+    pyproj = pytest.importorskip("pyproj")
     # compatibility for older PROJ versions using PROJJSON with datum ensembles
     # https://github.com/geopandas/geopandas/pull/2453
     crs = pyproj.CRS("EPSG:4326")
@@ -677,6 +679,7 @@ def test_write_empty_bbox(tmpdir, geometry):
 
 @pytest.mark.parametrize("format", ["feather", "parquet"])
 def test_write_read_default_crs(tmpdir, format):
+    pyproj = pytest.importorskip("pyproj")
     if format == "feather":
         from pyarrow.feather import write_feather as write
     else:
@@ -752,13 +755,14 @@ def test_write_spec_version(tmpdir, format, schema_version):
     assert metadata["version"] == schema_version
 
     # verify that CRS is correctly handled between versions
-    if schema_version == "0.1.0":
-        assert metadata["columns"]["geometry"]["crs"] == gdf.crs.to_wkt()
-
-    else:
-        crs_expected = gdf.crs.to_json_dict()
-        _remove_id_from_member_of_ensembles(crs_expected)
-        assert metadata["columns"]["geometry"]["crs"] == crs_expected
+    if HAS_PYPROJ:
+        if schema_version == "0.1.0":
+            assert metadata["columns"]["geometry"]["crs"] == gdf.crs.to_wkt()
+
+        else:
+            crs_expected = gdf.crs.to_json_dict()
+            _remove_id_from_member_of_ensembles(crs_expected)
+            assert metadata["columns"]["geometry"]["crs"] == crs_expected
 
     # verify that geometry_type(s) is correctly handled between versions
     if Version(schema_version) <= Version("0.4.0"):
diff --git a/geopandas/io/tests/test_file.py b/geopandas/io/tests/test_file.py
index ff543369..351b2752 100644
--- a/geopandas/io/tests/test_file.py
+++ b/geopandas/io/tests/test_file.py
@@ -16,7 +16,7 @@ from shapely.geometry import Point, Polygon, box, mapping
 
 import geopandas
 from geopandas import GeoDataFrame, read_file
-from geopandas._compat import PANDAS_GE_20
+from geopandas._compat import PANDAS_GE_20, HAS_PYPROJ
 from geopandas.io.file import _detect_driver, _EXTENSION_TO_DRIVER
 from geopandas.testing import assert_geodataframe_equal, assert_geoseries_equal
 from geopandas.tests.util import PACKAGE_DIR, validate_boro_df
@@ -440,6 +440,7 @@ def test_to_file_schema(tmpdir, df_nybb, engine):
         assert result_schema == schema
 
 
+@pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not installed")
 def test_to_file_crs(tmpdir, engine, nybb_filename):
     """
     Ensure that the file is written according to the crs
@@ -574,7 +575,8 @@ NYBB_CRS = "epsg:2263"
 def test_read_file(engine, nybb_filename):
     df = read_file(nybb_filename, engine=engine)
     validate_boro_df(df)
-    assert df.crs == NYBB_CRS
+    if HAS_PYPROJ:
+        assert df.crs == NYBB_CRS
     expected_columns = ["BoroCode", "BoroName", "Shape_Leng", "Shape_Area"]
     assert (df.columns[:-1] == expected_columns).all()
 
@@ -865,6 +867,7 @@ def test_read_file__columns(naturalearth_lowres):
     assert gdf.columns.tolist() == ["name", "pop_est", "geometry"]
 
 
+@pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not installed")
 @pytest.mark.parametrize("file_like", [False, True])
 def test_read_file_bbox_gdf(df_nybb, engine, nybb_filename, file_like):
     full_df_shape = df_nybb.shape
@@ -888,6 +891,7 @@ def test_read_file_bbox_gdf(df_nybb, engine, nybb_filename, file_like):
     assert filtered_df_shape == (2, 5)
 
 
+@pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not installed")
 @pytest.mark.parametrize("file_like", [False, True])
 def test_read_file_mask_gdf(df_nybb, engine, nybb_filename, file_like):
     skip_pyogrio_lt_07(engine)
@@ -941,6 +945,7 @@ def test_read_file_mask_geojson(df_nybb, nybb_filename, engine):
     assert filtered_df_shape == (2, 5)
 
 
+@pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not installed")
 def test_read_file_bbox_gdf_mismatched_crs(df_nybb, engine, nybb_filename):
     skip_pyogrio_lt_07(engine)
     full_df_shape = df_nybb.shape
@@ -962,6 +967,7 @@ def test_read_file_bbox_gdf_mismatched_crs(df_nybb, engine, nybb_filename):
     assert filtered_df_shape == (2, 5)
 
 
+@pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not installed")
 def test_read_file_mask_gdf_mismatched_crs(df_nybb, engine, nybb_filename):
     skip_pyogrio_lt_07(engine)
     full_df_shape = df_nybb.shape
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_array.py b/geopandas/tests/test_array.py
index 0e1f7bbc..9de3679b 100644
--- a/geopandas/tests/test_array.py
+++ b/geopandas/tests/test_array.py
@@ -4,7 +4,6 @@ import warnings
 import numpy as np
 import pandas as pd
 
-from pyproj import CRS
 import shapely
 import shapely.affinity
 import shapely.geometry
@@ -26,6 +25,8 @@ from geopandas.array import (
     _crs_mismatch_warn,
 )
 
+from geopandas._compat import HAS_PYPROJ
+
 import pytest
 
 triangle_no_missing = [
@@ -897,6 +898,7 @@ def test_astype_multipolygon():
     assert result[0] == multi_poly.wkt[:10]
 
 
+@pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not installed")
 def test_check_crs():
     t1 = T.copy()
     t1.crs = 4326
@@ -905,6 +907,7 @@ def test_check_crs():
     assert _check_crs(t1, T, allow_none=True) is True
 
 
+@pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not installed")
 def test_crs_mismatch_warn():
     t1 = T.copy()
     t2 = T.copy()
@@ -924,6 +927,14 @@ def test_crs_mismatch_warn():
         _crs_mismatch_warn(t1, T)
 
 
+@pytest.mark.skipif(HAS_PYPROJ, reason="pyproj installed")
+def test_missing_pyproj():
+    with pytest.warns(UserWarning, match="Cannot set the CRS, falling back to None"):
+        t = T.copy()
+        t.crs = 4326
+    assert t.crs is None
+
+
 @pytest.mark.parametrize("NA", [None, np.nan])
 def test_isna(NA):
     t1 = T.copy()
@@ -951,6 +962,24 @@ def test_unique_has_crs():
     assert t.unique().crs == t.crs
 
 
+@pytest.mark.skipif(HAS_PYPROJ, reason="pyproj installed")
+def test_to_crs_pyproj_error():
+    t = T.copy()
+    t.crs = 4326
+    with pytest.raises(
+        ImportError, match="The 'pyproj' package is required for to_crs"
+    ):
+        t.to_crs(3857)
+
+
+@pytest.mark.skipif(HAS_PYPROJ, reason="pyproj installed")
+def test_estimate_utm_crs_pyproj_error():
+    with pytest.raises(
+        ImportError, match="The 'pyproj' package is required for estimate_utm_crs"
+    ):
+        T.estimate_utm_crs()
+
+
 class TestEstimateUtmCrs:
     def setup_method(self):
         self.esb = shapely.geometry.Point(-73.9847, 40.7484)
@@ -958,15 +987,21 @@ class TestEstimateUtmCrs:
         self.landmarks = from_shapely([self.esb, self.sol], crs="epsg:4326")
 
     def test_estimate_utm_crs__geographic(self):
-        assert self.landmarks.estimate_utm_crs() == CRS("EPSG:32618")
-        assert self.landmarks.estimate_utm_crs("NAD83") == CRS("EPSG:26918")
+        pyproj = pytest.importorskip("pyproj")
+
+        assert self.landmarks.estimate_utm_crs() == pyproj.CRS("EPSG:32618")
+        assert self.landmarks.estimate_utm_crs("NAD83") == pyproj.CRS("EPSG:26918")
 
     def test_estimate_utm_crs__projected(self):
-        assert self.landmarks.to_crs("EPSG:3857").estimate_utm_crs() == CRS(
+        pyproj = pytest.importorskip("pyproj")
+
+        assert self.landmarks.to_crs("EPSG:3857").estimate_utm_crs() == pyproj.CRS(
             "EPSG:32618"
         )
 
     def test_estimate_utm_crs__antimeridian(self):
+        pyproj = pytest.importorskip("pyproj")
+
         antimeridian = from_shapely(
             [
                 shapely.geometry.Point(1722483.900174921, 5228058.6143420935),
@@ -974,15 +1009,19 @@ class TestEstimateUtmCrs:
             ],
             crs="EPSG:3851",
         )
-        assert antimeridian.estimate_utm_crs() == CRS("EPSG:32760")
+        assert antimeridian.estimate_utm_crs() == pyproj.CRS("EPSG:32760")
 
     def test_estimate_utm_crs__out_of_bounds(self):
+        pytest.importorskip("pyproj")
+
         with pytest.raises(RuntimeError, match="Unable to determine UTM CRS"):
             from_shapely(
                 [shapely.geometry.Polygon([(0, 90), (1, 90), (2, 90)])], crs="EPSG:4326"
             ).estimate_utm_crs()
 
     def test_estimate_utm_crs__missing_crs(self):
+        pytest.importorskip("pyproj")
+
         with pytest.raises(RuntimeError, match="crs must be set"):
             from_shapely(
                 [shapely.geometry.Polygon([(0, 90), (1, 90), (2, 90)])]
diff --git a/geopandas/tests/test_crs.py b/geopandas/tests/test_crs.py
index ed49834b..42533dee 100644
--- a/geopandas/tests/test_crs.py
+++ b/geopandas/tests/test_crs.py
@@ -3,7 +3,6 @@ import warnings
 
 import numpy as np
 import pandas as pd
-import pyproj
 import pytest
 from shapely.geometry import Point, Polygon, LineString
 
@@ -11,6 +10,8 @@ from geopandas import GeoSeries, GeoDataFrame, points_from_xy, read_file
 from geopandas.array import from_shapely, from_wkb, from_wkt, GeometryArray
 from geopandas.testing import assert_geodataframe_equal
 
+pyproj = pytest.importorskip("pyproj")
+
 
 def _create_df(x, y=None, crs=None):
     y = y or x
diff --git a/geopandas/tests/test_dissolve.py b/geopandas/tests/test_dissolve.py
index dcc961aa..f9f7cfce 100644
--- a/geopandas/tests/test_dissolve.py
+++ b/geopandas/tests/test_dissolve.py
@@ -9,7 +9,7 @@ from geopandas import GeoDataFrame, read_file
 from pandas.testing import assert_frame_equal
 import pytest
 
-from geopandas._compat import PANDAS_GE_15, PANDAS_GE_20, PANDAS_GE_30
+from geopandas._compat import PANDAS_GE_15, PANDAS_GE_20, PANDAS_GE_30, HAS_PYPROJ
 from geopandas.testing import assert_geodataframe_equal, geom_almost_equals
 
 
@@ -63,6 +63,7 @@ def test_geom_dissolve(nybb_polydf, first):
     assert geom_almost_equals(test, first)
 
 
+@pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not installed")
 def test_dissolve_retains_existing_crs(nybb_polydf):
     assert nybb_polydf.crs is not None
     test = nybb_polydf.dissolve("manhattan_bronx")
diff --git a/geopandas/tests/test_explore.py b/geopandas/tests/test_explore.py
index f39c470d..0c24b5f6 100644
--- a/geopandas/tests/test_explore.py
+++ b/geopandas/tests/test_explore.py
@@ -5,6 +5,8 @@ import pytest
 import shapely
 from packaging.version import Version
 
+from geopandas._compat import HAS_PYPROJ
+
 folium = pytest.importorskip("folium")
 branca = pytest.importorskip("branca")
 matplotlib = pytest.importorskip("matplotlib")
@@ -56,6 +58,7 @@ class TestExplore:
         """Make sure default choropleth pass"""
         self.world.explore(column="pop_est")
 
+    @pytest.mark.skipif(not HAS_PYPROJ, reason="requires pyproj")
     def test_map_settings_default(self):
         """Check default map settings"""
         m = self.world.explore()
@@ -74,6 +77,7 @@ class TestExplore:
         assert m.global_switches.disable_3d is False
         assert "openstreetmap" in m.to_dict()["children"].keys()
 
+    @pytest.mark.skipif(not HAS_PYPROJ, reason="requires pyproj")
     def test_map_settings_custom(self):
         """Check custom map settings"""
         m = self.nybb.explore(
@@ -693,6 +697,7 @@ class TestExplore:
             == "tickValues([140.0,'','','',559086084.0,'','','',1118172028.0,'','',''])"
         )
 
+    @pytest.mark.skipif(not HAS_PYPROJ, reason="requires pyproj")
     def test_xyzservices_providers(self):
         xyzservices = pytest.importorskip("xyzservices")
 
@@ -709,6 +714,7 @@ class TestExplore:
         )
         assert '"maxNativeZoom":20,"maxZoom":20,"minZoom":0' in out_str
 
+    @pytest.mark.skipif(not HAS_PYPROJ, reason="requires pyproj")
     def test_xyzservices_query_name(self):
         pytest.importorskip("xyzservices")
 
@@ -725,6 +731,7 @@ class TestExplore:
         )
         assert '"maxNativeZoom":20,"maxZoom":20,"minZoom":0' in out_str
 
+    @pytest.mark.skipif(not HAS_PYPROJ, reason="requires pyproj")
     def test_xyzservices_providers_min_zoom_override(self):
         xyzservices = pytest.importorskip("xyzservices")
 
@@ -735,6 +742,7 @@ class TestExplore:
 
         assert '"maxNativeZoom":20,"maxZoom":20,"minZoom":3' in out_str
 
+    @pytest.mark.skipif(not HAS_PYPROJ, reason="requires pyproj")
     def test_xyzservices_providers_max_zoom_override(self):
         xyzservices = pytest.importorskip("xyzservices")
 
@@ -745,6 +753,7 @@ class TestExplore:
 
         assert '"maxNativeZoom":12,"maxZoom":12,"minZoom":0' in out_str
 
+    @pytest.mark.skipif(not HAS_PYPROJ, reason="requires pyproj")
     def test_xyzservices_providers_both_zooms_override(self):
         xyzservices = pytest.importorskip("xyzservices")
 
diff --git a/geopandas/tests/test_geocode.py b/geopandas/tests/test_geocode.py
index 04f03bdd..e8193f09 100644
--- a/geopandas/tests/test_geocode.py
+++ b/geopandas/tests/test_geocode.py
@@ -11,6 +11,8 @@ from pandas.testing import assert_series_equal
 from geopandas.testing import assert_geodataframe_equal
 import pytest
 
+from geopandas._compat import HAS_PYPROJ
+
 geopy = pytest.importorskip("geopy")
 
 
@@ -71,7 +73,8 @@ def test_prepare_result():
 
     df = _prepare_geocode_result(d)
     assert type(df) is GeoDataFrame
-    assert df.crs == "EPSG:4326"
+    if HAS_PYPROJ:
+        assert df.crs == "EPSG:4326"
     assert len(df) == 2
     assert "address" in df
 
@@ -93,7 +96,8 @@ def test_prepare_result_none():
 
     df = _prepare_geocode_result(d)
     assert type(df) is GeoDataFrame
-    assert df.crs == "EPSG:4326"
+    if HAS_PYPROJ:
+        assert df.crs == "EPSG:4326"
     assert len(df) == 2
     assert "address" in df
 
diff --git a/geopandas/tests/test_geodataframe.py b/geopandas/tests/test_geodataframe.py
index 2d707894..d6353d18 100644
--- a/geopandas/tests/test_geodataframe.py
+++ b/geopandas/tests/test_geodataframe.py
@@ -6,8 +6,6 @@ import tempfile
 import numpy as np
 import pandas as pd
 
-from pyproj import CRS
-from pyproj.exceptions import CRSError
 from shapely.geometry import Point, Polygon, box
 
 import geopandas
@@ -72,9 +70,13 @@ class TestDataFrame:
 
     def test_df_init(self):
         assert type(self.df2) is GeoDataFrame
-        assert self.df2.crs == self.crs
+        if compat.HAS_PYPROJ:
+            assert self.df2.crs == self.crs
 
+    @pytest.mark.skipif(not compat.HAS_PYPROJ, reason="Requires pyproj")
     def test_different_geo_colname(self):
+        from pyproj.exceptions import CRSError
+
         data = {
             "A": range(5),
             "B": range(-5, 0),
@@ -195,10 +197,11 @@ class TestDataFrame:
         assert_geoseries_equal(df.geometry, new_geom)
         assert_geoseries_equal(df["geometry"], new_geom)
 
-        # new crs
-        gs = new_geom.to_crs(crs="epsg:3857")
-        df.geometry = gs
-        assert df.crs == "epsg:3857"
+        if compat.HAS_PYPROJ:
+            # new crs
+            gs = new_geom.to_crs(crs="epsg:3857")
+            df.geometry = gs
+            assert df.crs == "epsg:3857"
 
     def test_geometry_property_errors(self):
         with pytest.raises(AttributeError):
@@ -246,6 +249,7 @@ class TestDataFrame:
         with pytest.raises(ValueError, match=msg):
             self.df.rename_geometry("Shape_Area", inplace=True)
 
+    @pytest.mark.skipif(not compat.HAS_PYPROJ, reason="Requires pyproj")
     def test_set_geometry(self):
         geom = GeoSeries([Point(x, y) for x, y in zip(range(5), range(5))])
         original_geom = self.df.geometry
@@ -391,14 +395,15 @@ class TestDataFrame:
         assert_geodataframe_equal(res1, df)
         assert_geodataframe_equal(res2, df)
 
-        # assert crs is / is not preserved on mixed dataframes
-        df_nocrs = df.copy()
-        df_nocrs.crs = None
-        res1, res2 = df.align(df_nocrs)
-        assert_geodataframe_equal(res1, df)
-        assert res1.crs is not None
-        assert_geodataframe_equal(res2, df_nocrs)
-        assert res2.crs is None
+        if compat.HAS_PYPROJ:
+            # assert crs is / is not preserved on mixed dataframes
+            df_nocrs = df.copy()
+            df_nocrs.crs = None
+            res1, res2 = df.align(df_nocrs)
+            assert_geodataframe_equal(res1, df)
+            assert res1.crs is not None
+            assert_geodataframe_equal(res2, df_nocrs)
+            assert res2.crs is None
 
         # mixed GeoDataFrame / DataFrame
         df_nogeom = pd.DataFrame(df.drop("geometry", axis=1))
@@ -419,15 +424,16 @@ class TestDataFrame:
         assert_geodataframe_equal(res1, exp1)
         assert_geodataframe_equal(res2, exp2)
 
-        df2_nocrs = df2.copy()
-        df2_nocrs.crs = None
-        exp2_nocrs = exp2.copy()
-        exp2_nocrs.crs = None
-        res1, res2 = df1.align(df2_nocrs)
-        assert_geodataframe_equal(res1, exp1)
-        assert res1.crs is not None
-        assert_geodataframe_equal(res2, exp2_nocrs)
-        assert res2.crs is None
+        if compat.HAS_PYPROJ:
+            df2_nocrs = df2.copy()
+            df2_nocrs.crs = None
+            exp2_nocrs = exp2.copy()
+            exp2_nocrs.crs = None
+            res1, res2 = df1.align(df2_nocrs)
+            assert_geodataframe_equal(res1, exp1)
+            assert res1.crs is not None
+            assert_geodataframe_equal(res2, exp2_nocrs)
+            assert res2.crs is None
 
         df2_nogeom = pd.DataFrame(df2.drop("geometry", axis=1))
         exp2_nogeom = pd.DataFrame(exp2.drop("geometry", axis=1))
@@ -436,6 +442,7 @@ class TestDataFrame:
         assert type(res2) == pd.DataFrame
         assert_frame_equal(res2, exp2_nogeom)
 
+    @pytest.mark.skipif(not compat.HAS_PYPROJ, reason="Requires pyproj")
     def test_to_json(self):
         text = self.df.to_json(to_wgs84=True)
         data = json.loads(text)
@@ -598,7 +605,10 @@ class TestDataFrame:
     def test_from_dict(self):
         data = {"A": [1], "geometry": [Point(0.0, 0.0)]}
         df = GeoDataFrame.from_dict(data, crs=3857)
-        assert df.crs == "epsg:3857"
+        if compat.HAS_PYPROJ:
+            assert df.crs == "epsg:3857"
+        else:
+            assert df.crs is None
         assert df._geometry_column_name == "geometry"
 
         data = {"B": [1], "location": [Point(0.0, 0.0)]}
@@ -613,7 +623,10 @@ class TestDataFrame:
 
         df = GeoDataFrame.from_features(features, crs=crs)
         validate_boro_df(df, case_sensitive=True)
-        assert df.crs == crs
+        if compat.HAS_PYPROJ:
+            assert df.crs == crs
+        else:
+            assert df.crs is None
 
     def test_from_features_unaligned_properties(self):
         p1 = Point(1, 1)
@@ -938,8 +951,10 @@ class TestDataFrame:
         assert self.df.crs == unpickled.crs
 
     def test_estimate_utm_crs(self):
-        assert self.df.estimate_utm_crs() == CRS("EPSG:32618")
-        assert self.df.estimate_utm_crs("NAD83") == CRS("EPSG:26918")
+        pyproj = pytest.importorskip("pyproj")
+
+        assert self.df.estimate_utm_crs() == pyproj.CRS("EPSG:32618")
+        assert self.df.estimate_utm_crs("NAD83") == pyproj.CRS("EPSG:26918")
 
     def test_to_wkb(self):
         wkbs0 = [
@@ -1372,8 +1387,9 @@ class TestConstructor:
         y_col = df["location", "y"]
 
         gdf = GeoDataFrame(df, crs=crs, geometry=points_from_xy(x_col, y_col))
-        assert gdf.crs == crs
-        assert gdf.geometry.crs == crs
+        if compat.HAS_PYPROJ:
+            assert gdf.crs == crs
+            assert gdf.geometry.crs == crs
         assert gdf.geometry.dtype == "geometry"
         assert gdf._geometry_column_name == "geometry"
         assert gdf.geometry.name == "geometry"
@@ -1395,8 +1411,9 @@ class TestConstructor:
         y_col = df["foo", "location", "y"]
 
         gdf = GeoDataFrame(df, crs=crs, geometry=points_from_xy(x_col, y_col))
-        assert gdf.crs == crs
-        assert gdf.geometry.crs == crs
+        if compat.HAS_PYPROJ:
+            assert gdf.crs == crs
+            assert gdf.geometry.crs == crs
         assert gdf.geometry.dtype == "geometry"
         assert gdf._geometry_column_name == "geometry"
         assert gdf.geometry.name == "geometry"
@@ -1417,12 +1434,14 @@ class TestConstructor:
         df["geometry"] = GeoSeries.from_xy(x_col, y_col)
         df2 = df.copy()
         gdf = df.set_geometry("geometry", crs=crs)
-        assert gdf.crs == crs
+        if compat.HAS_PYPROJ:
+            assert gdf.crs == crs
         assert gdf._geometry_column_name == "geometry"
         assert gdf.geometry.name == "geometry"
         # test again setting with tuple col name
         gdf = df2.set_geometry(("geometry", "", ""), crs=crs)
-        assert gdf.crs == crs
+        if compat.HAS_PYPROJ:
+            assert gdf.crs == crs
         assert gdf._geometry_column_name == ("geometry", "", "")
         assert gdf.geometry.name == ("geometry", "", "")
 
@@ -1434,6 +1453,7 @@ class TestConstructor:
         assert_geodataframe_equal(df, expected)
 
 
+@pytest.mark.skipif(not compat.HAS_PYPROJ, reason="pyproj not available")
 def test_geodataframe_crs():
     gdf = GeoDataFrame(columns=["geometry"])
     gdf.crs = "IGNF:ETRS89UTM28"
@@ -1452,6 +1472,7 @@ def test_geodataframe_nocrs_json():
     assert "crs" not in gdf_geojson
 
 
+@pytest.mark.skipif(not compat.HAS_PYPROJ, reason="pyproj not available")
 def test_geodataframe_crs_json():
     gdf = GeoDataFrame(columns=["geometry"])
     gdf.crs = 25833
@@ -1465,6 +1486,7 @@ def test_geodataframe_crs_json():
     assert "crs" not in gdf_geointerface
 
 
+@pytest.mark.skipif(not compat.HAS_PYPROJ, reason="pyproj not available")
 @pytest.mark.parametrize(
     "crs",
     ["+proj=cea +lon_0=0 +lat_ts=45 +x_0=0 +y_0=0 +ellps=WGS84 +units=m", "IGNF:WGS84"],
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
index 5dbc2e57..ba934c18 100644
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -24,6 +24,7 @@ from geopandas import GeoDataFrame, GeoSeries
 from geopandas.base import GeoPandasBase
 from geopandas.testing import assert_geodataframe_equal
 from geopandas.tests.util import assert_geoseries_equal, geom_almost_equals, geom_equals
+from geopandas._compat import HAS_PYPROJ
 
 
 def assert_array_dtype_equal(a, b, *args, **kwargs):
@@ -415,6 +416,7 @@ class TestGeomMethods:
         expected = Series(np.array([0.5, np.nan]), index=self.na_none.index)
         self._test_unary_real("area", expected, self.na_none)
 
+    @pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not available")
     def test_area_crs_warn(self):
         with pytest.warns(UserWarning, match="Geometry is in a geographic CRS"):
             self.g4.area
@@ -517,6 +519,7 @@ class TestGeomMethods:
         expected = Series(np.array([2 + np.sqrt(2), np.nan]), index=self.na_none.index)
         self._test_unary_real("length", expected, self.na_none)
 
+    @pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not available")
     def test_length_crs_warn(self):
         with pytest.warns(UserWarning, match="Geometry is in a geographic CRS"):
             self.g4.length
@@ -619,6 +622,7 @@ class TestGeomMethods:
         expected = Series(np.array([0, 0, 0, 0, val, np.nan, np.nan]), self.g0.index)
         assert_array_dtype_equal(expected, self.g0.distance(self.g9, align=False))
 
+    @pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not available")
     def test_distance_crs_warning(self):
         with pytest.warns(UserWarning, match="Geometry is in a geographic CRS"):
             self.g4.distance(self.p0)
@@ -850,6 +854,7 @@ class TestGeomMethods:
         points = GeoSeries([point for i in range(3)])
         assert_geoseries_equal(polygons.centroid, points)
 
+    @pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not available")
     def test_centroid_crs_warn(self):
         with pytest.warns(UserWarning, match="Geometry is in a geographic CRS"):
             self.g4.centroid
@@ -1070,6 +1075,7 @@ class TestGeomMethods:
         with pytest.raises(ValueError):
             self.g5.interpolate(distances)
 
+    @pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not available")
     def test_interpolate_crs_warning(self):
         g5_crs = self.g5.copy()
         g5_crs.crs = 4326
@@ -1202,6 +1208,7 @@ class TestGeomMethods:
         result = s.buffer(np.array([0, 0, 0]))
         assert_geoseries_equal(result, s)
 
+    @pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not available")
     def test_buffer_crs_warn(self):
         with pytest.warns(UserWarning, match="Geometry is in a geographic CRS"):
             self.g4.buffer(1)
diff --git a/geopandas/tests/test_geoseries.py b/geopandas/tests/test_geoseries.py
index acdd287d..b9f81338 100644
--- a/geopandas/tests/test_geoseries.py
+++ b/geopandas/tests/test_geoseries.py
@@ -10,7 +10,6 @@ from numpy.testing import assert_array_equal
 import pandas as pd
 from pandas.testing import assert_index_equal
 
-from pyproj import CRS
 from shapely.geometry import (
     GeometryCollection,
     LineString,
@@ -28,6 +27,7 @@ import geopandas._compat as compat
 from geopandas.testing import assert_geoseries_equal, geom_almost_equals
 
 from geopandas.tests.util import geom_equals
+from geopandas._compat import HAS_PYPROJ
 from pandas.testing import assert_series_equal
 import pytest
 
@@ -81,6 +81,7 @@ class TestSeries:
         assert a1["B"].equals(a2["B"])
         assert a1["C"] is None
 
+    @pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not available")
     def test_align_crs(self):
         a1 = self.a1
         a1.crs = "epsg:4326"
@@ -208,6 +209,7 @@ class TestSeries:
         assert np.all(self.g3.contains(self.g3.representative_point()))
         assert np.all(self.g4.contains(self.g4.representative_point()))
 
+    @pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not available")
     def test_transform(self):
         utm18n = self.landmarks.to_crs(epsg=26918)
         lonlat = utm18n.to_crs(epsg=4326)
@@ -218,20 +220,24 @@ class TestSeries:
             self.landmarks.to_crs(crs=None, epsg=None)
 
     def test_estimate_utm_crs__geographic(self):
-        assert self.landmarks.estimate_utm_crs() == CRS("EPSG:32618")
-        assert self.landmarks.estimate_utm_crs("NAD83") == CRS("EPSG:26918")
+        pyproj = pytest.importorskip("pyproj")
+        assert self.landmarks.estimate_utm_crs() == pyproj.CRS("EPSG:32618")
+        assert self.landmarks.estimate_utm_crs("NAD83") == pyproj.CRS("EPSG:26918")
 
     def test_estimate_utm_crs__projected(self):
-        assert self.landmarks.to_crs("EPSG:3857").estimate_utm_crs() == CRS(
+        pyproj = pytest.importorskip("pyproj")
+        assert self.landmarks.to_crs("EPSG:3857").estimate_utm_crs() == pyproj.CRS(
             "EPSG:32618"
         )
 
+    @pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not available")
     def test_estimate_utm_crs__out_of_bounds(self):
         with pytest.raises(RuntimeError, match="Unable to determine UTM CRS"):
             GeoSeries(
                 [Polygon([(0, 90), (1, 90), (2, 90)])], crs="EPSG:4326"
             ).estimate_utm_crs()
 
+    @pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not available")
     def test_estimate_utm_crs__missing_crs(self):
         with pytest.raises(RuntimeError, match="crs must be set"):
             GeoSeries([Polygon([(0, 90), (1, 90), (2, 90)])]).estimate_utm_crs()
@@ -267,6 +273,7 @@ class TestSeries:
         assert self.g1.__geo_interface__["type"] == "FeatureCollection"
         assert len(self.g1.__geo_interface__["features"]) == self.g1.shape[0]
 
+    @pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not available")
     def test_proj4strings(self):
         # As string
         reprojected = self.g3.to_crs("+proj=utm +zone=30")
@@ -417,6 +424,13 @@ class TestSeries:
         expected = GeoSeries([Point(0, 2, -1), Point(3, 5, 4)])
         assert_geoseries_equal(expected, GeoSeries.from_xy(x, y, z))
 
+    @pytest.mark.skipif(HAS_PYPROJ, reason="pyproj installed")
+    def test_set_crs_pyproj_error(self):
+        with pytest.raises(
+            ImportError, match="The 'pyproj' package is required for set_crs"
+        ):
+            self.g1.set_crs(3857)
+
 
 @pytest.mark.filterwarnings("ignore::UserWarning")
 def test_missing_values():
@@ -448,6 +462,7 @@ def test_isna_empty_geoseries():
     assert_series_equal(result, pd.Series([], dtype="bool"))
 
 
+@pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not available")
 def test_geoseries_crs():
     gs = GeoSeries()
     gs.crs = "IGNF:ETRS89UTM28"
diff --git a/geopandas/tests/test_merge.py b/geopandas/tests/test_merge.py
index 0f0b942e..1abe513f 100644
--- a/geopandas/tests/test_merge.py
+++ b/geopandas/tests/test_merge.py
@@ -1,10 +1,9 @@
 import warnings
 
 import pandas as pd
-import pyproj
 import pytest
 
-from geopandas._compat import PANDAS_GE_21
+from geopandas._compat import PANDAS_GE_21, HAS_PYPROJ
 from geopandas.testing import assert_geodataframe_equal
 from pandas.testing import assert_index_equal
 
@@ -62,6 +61,7 @@ class TestMerging:
         assert isinstance(res, GeoSeries)
         assert isinstance(res.geometry, GeoSeries)
 
+    @pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not available")
     def test_concat_axis0_crs(self):
         # CRS not set for both GeoDataFrame
         res = pd.concat([self.gdf, self.gdf])
@@ -103,6 +103,7 @@ class TestMerging:
                 [self.gdf, self.gdf.set_crs("epsg:4326"), self.gdf.set_crs("epsg:4327")]
             )
 
+    @pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not available")
     def test_concat_axis0_unaligned_cols(self):
         # https://github.com/geopandas/geopandas/issues/2679
         gdf = self.gdf.set_crs("epsg:4326").assign(
@@ -137,6 +138,8 @@ class TestMerging:
             pd.concat([single_geom_col, partial_none_case])
 
     def test_concat_axis0_crs_wkt_mismatch(self):
+        pyproj = pytest.importorskip("pyproj")
+
         # https://github.com/geopandas/geopandas/issues/326#issuecomment-1727958475
         wkt_template = """GEOGCRS["WGS 84",
         ENSEMBLE["World Geodetic System 1984 ensemble",
@@ -204,10 +207,11 @@ class TestMerging:
         with pytest.raises(ValueError, match=expected_err2):
             pd.concat([df2, df2], axis=1)
 
-        # Check that two geometry columns is fine, if they have different names
-        res3 = pd.concat([df2.set_crs("epsg:4326"), self.gdf], axis=1)
-        # check metadata comes from first df
-        self._check_metadata(res3, geometry_column_name="geom", crs="epsg:4326")
+        if HAS_PYPROJ:
+            # Check that two geometry columns is fine, if they have different names
+            res3 = pd.concat([df2.set_crs("epsg:4326"), self.gdf], axis=1)
+            # check metadata comes from first df
+            self._check_metadata(res3, geometry_column_name="geom", crs="epsg:4326")
 
     @pytest.mark.filterwarnings("ignore:Accessing CRS")
     def test_concat_axis1_geoseries(self):
diff --git a/geopandas/tests/test_op_output_types.py b/geopandas/tests/test_op_output_types.py
index ef2dec0f..7e2a9e70 100644
--- a/geopandas/tests/test_op_output_types.py
+++ b/geopandas/tests/test_op_output_types.py
@@ -1,5 +1,4 @@
 import pandas as pd
-import pyproj
 import pytest
 
 from shapely.geometry import Point
@@ -9,6 +8,8 @@ from geopandas import GeoDataFrame, GeoSeries
 from geopandas.testing import assert_geodataframe_equal
 import geopandas
 
+pyproj = pytest.importorskip("pyproj")
+
 crs_osgb = pyproj.CRS(27700)
 crs_wgs = pyproj.CRS(4326)
 
diff --git a/geopandas/tests/test_overlay.py b/geopandas/tests/test_overlay.py
index 8e9dae0d..1b5a9163 100644
--- a/geopandas/tests/test_overlay.py
+++ b/geopandas/tests/test_overlay.py
@@ -8,7 +8,7 @@ from shapely.geometry import Point, Polygon, LineString, GeometryCollection, box
 
 import geopandas
 from geopandas import GeoDataFrame, GeoSeries, overlay, read_file
-from geopandas._compat import PANDAS_GE_20
+from geopandas._compat import PANDAS_GE_20, HAS_PYPROJ
 
 from geopandas.testing import assert_geodataframe_equal, assert_geoseries_equal
 import pytest
@@ -345,6 +345,7 @@ def test_geoseries_warning(dfs):
         overlay(df1, df2.geometry, how="union")
 
 
+@pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not available")
 def test_preserve_crs(dfs, how):
     df1, df2 = dfs
     result = overlay(df1, df2, how=how)
@@ -356,6 +357,7 @@ def test_preserve_crs(dfs, how):
     assert result.crs == crs
 
 
+@pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not available")
 def test_crs_mismatch(dfs, how):
     df1, df2 = dfs
     df1.crs = 4326
diff --git a/geopandas/tests/test_pandas_methods.py b/geopandas/tests/test_pandas_methods.py
index 85c0c73c..754d924a 100644
--- a/geopandas/tests/test_pandas_methods.py
+++ b/geopandas/tests/test_pandas_methods.py
@@ -305,17 +305,18 @@ def test_convert_dtypes(df):
     res2 = df[["value1", "value2", "geometry"]].convert_dtypes()
     assert_geodataframe_equal(expected1[["value1", "value2", "geometry"]], res2)
 
-    # Test again with crs set and custom geom col name
-    df2 = df.set_crs(epsg=4326).rename_geometry("points")
-    expected2 = GeoDataFrame(
-        pd.DataFrame(df2).convert_dtypes(), crs=df2.crs, geometry=df2.geometry.name
-    )
-    res3 = df2.convert_dtypes()
-    assert_geodataframe_equal(expected2, res3)
+    if compat.HAS_PYPROJ:
+        # Test again with crs set and custom geom col name
+        df2 = df.set_crs(epsg=4326).rename_geometry("points")
+        expected2 = GeoDataFrame(
+            pd.DataFrame(df2).convert_dtypes(), crs=df2.crs, geometry=df2.geometry.name
+        )
+        res3 = df2.convert_dtypes()
+        assert_geodataframe_equal(expected2, res3)
 
-    # Test geom last, geom_col=geometry
-    res4 = df2[["value1", "value2", "points"]].convert_dtypes()
-    assert_geodataframe_equal(expected2[["value1", "value2", "points"]], res4)
+        # Test geom last, geom_col=geometry
+        res4 = df2[["value1", "value2", "points"]].convert_dtypes()
+        assert_geodataframe_equal(expected2[["value1", "value2", "points"]], res4)
 
 
 def test_to_csv(df):
@@ -660,6 +661,8 @@ def test_groupby_groups(df):
 @pytest.mark.parametrize("crs", [None, "EPSG:4326"])
 @pytest.mark.parametrize("geometry_name", ["geometry", "geom"])
 def test_groupby_metadata(crs, geometry_name):
+    if crs and not compat.HAS_PYPROJ:
+        pytest.skip("requires pyproj")
     # https://github.com/geopandas/geopandas/issues/2294
     df = GeoDataFrame(
         {
@@ -771,6 +774,8 @@ def test_apply_convert_dtypes_keyword(s):
 @pytest.mark.parametrize("crs", [None, "EPSG:4326"])
 def test_apply_no_geometry_result(df, crs):
     if crs:
+        if not compat.HAS_PYPROJ:
+            pytest.skip("requires pyproj")
         df = df.set_crs(crs)
     result = df.apply(lambda col: col.astype(str), axis=0)
     assert type(result) is pd.DataFrame
diff --git a/geopandas/tests/test_plotting.py b/geopandas/tests/test_plotting.py
index 4ecb4ab0..9b918285 100644
--- a/geopandas/tests/test_plotting.py
+++ b/geopandas/tests/test_plotting.py
@@ -1079,6 +1079,7 @@ def _setup_class_geographic_aspect(naturalearth_lowres, request):
 
 
 @pytest.mark.usefixtures("_setup_class_geographic_aspect")
+@pytest.mark.skipif(not compat.HAS_PYPROJ, reason="pyproj not available")
 class TestGeographicAspect:
     def test_auto(self):
         ax = self.north.geometry.plot()
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
 
diff --git a/geopandas/tests/test_testing.py b/geopandas/tests/test_testing.py
index b119f72b..5038530b 100644
--- a/geopandas/tests/test_testing.py
+++ b/geopandas/tests/test_testing.py
@@ -10,6 +10,7 @@ from geopandas import GeoDataFrame, GeoSeries
 from geopandas.array import from_shapely
 
 from geopandas.testing import assert_geodataframe_equal, assert_geoseries_equal
+from geopandas._compat import HAS_PYPROJ
 import pytest
 
 s1 = GeoSeries(
@@ -102,9 +103,10 @@ def test_geodataframe():
         assert_geodataframe_equal(df1, df3)
 
     assert_geodataframe_equal(df5, df4, check_like=True)
-    df5.geom2.crs = 3857
-    with pytest.raises(AssertionError):
-        assert_geodataframe_equal(df5, df4, check_like=True)
+    if HAS_PYPROJ:
+        df5.geom2.crs = 3857
+        with pytest.raises(AssertionError):
+            assert_geodataframe_equal(df5, df4, check_like=True)
 
 
 def test_equal_nans():
@@ -119,6 +121,7 @@ def test_no_crs():
     assert_geodataframe_equal(df1, df2)
 
 
+@pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not available")
 def test_ignore_crs_mismatch():
     df1 = GeoDataFrame({"col1": [1, 2], "geometry": s1.copy()}, crs="EPSG:4326")
     df2 = GeoDataFrame({"col1": [1, 2], "geometry": s1}, crs="EPSG:31370")
diff --git a/geopandas/tools/tests/test_clip.py b/geopandas/tools/tests/test_clip.py
index cac8e09b..4092d67a 100644
--- a/geopandas/tools/tests/test_clip.py
+++ b/geopandas/tools/tests/test_clip.py
@@ -15,6 +15,7 @@ from shapely.geometry import (
 
 import geopandas
 from geopandas import GeoDataFrame, GeoSeries, clip
+from geopandas._compat import HAS_PYPROJ
 
 from geopandas.testing import assert_geodataframe_equal, assert_geoseries_equal
 import pytest
@@ -396,6 +397,7 @@ def test_clip_multipoly_keep_slivers(multi_poly_gdf, single_rectangle_gdf):
     assert "GeometryCollection" in clipped.geom_type[0]
 
 
+@pytest.mark.skipif(not HAS_PYPROJ, reason="pyproj not available")
 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(4326))
diff --git a/geopandas/tools/tests/test_sjoin.py b/geopandas/tools/tests/test_sjoin.py
index beeecb02..a7eaf839 100644
--- a/geopandas/tools/tests/test_sjoin.py
+++ b/geopandas/tools/tests/test_sjoin.py
@@ -107,6 +107,7 @@ class TestSpatialJoin:
         joined = sjoin(left, right, how=how, lsuffix=lsuffix, rsuffix=rsuffix)
         assert set(joined.columns) == expected_cols | {"geometry"}
 
+    @pytest.mark.skipif(not compat.HAS_PYPROJ, reason="pyproj not available")
     @pytest.mark.parametrize("dfs", ["default-index", "string-index"], indirect=True)
     def test_crs_mismatch(self, dfs):
         index, df1, df2, expected = dfs