Repository: geopandas/geopandas

Issue:
Make pyproj an optional dependency (still required for CRS)
Similarly as we did for fiona, we could make `pyproj` a "soft" dependency: required if you want your GeoDataFrame to have a `.crs` defined, but making it possible to run geopandas without pyproj installed (currently you would get an import error, as we import it top-level)

Discussion:
martinfleis: I think that implementation-wise, this is now ready for a review. It will need a changelog and some documentation but I'd like to get an agreement on behaviour first.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5b30d562..b0a18651 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,9 +11,13 @@ Notes on dependencies:
 API changes:
 
 - `unary_union` is now deprecated and replaced by the `union_all` method (#3007).
+- The `sjoin` method will now preserve the name of the index of the right
+  GeoDataFrame, if it has one, instead of always using `"index_right"` as the
+  name for the resulting column in the return value (#846, #2144).
 
 New methods:
 
+- Added `set_precision` and `get_precision` methods from shapely to GeoSeries/GeoDataframe (#3175).
 - Added `count_coordinates` method from shapely to GeoSeries/GeoDataframe (#3026).
 - Added `minimum_clearance` method from shapely to GeoSeries/GeoDataframe (#2989).
 - Added `is_ccw` method from shapely to GeoSeries/GeoDataframe (#3027).
diff --git a/doc/source/docs/reference/geoseries.rst b/doc/source/docs/reference/geoseries.rst
index e7b64cec..6a8fbbee 100644
--- a/doc/source/docs/reference/geoseries.rst
+++ b/doc/source/docs/reference/geoseries.rst
@@ -35,6 +35,8 @@ General methods and attributes
    GeoSeries.z
    GeoSeries.get_coordinates
    GeoSeries.count_coordinates
+   GeoSeries.set_precision
+   GeoSeries.get_precision
 
 Unary predicates
 ----------------
diff --git a/doc/source/getting_started/install.rst b/doc/source/getting_started/install.rst
index 286564a1..2abb210b 100644
--- a/doc/source/getting_started/install.rst
+++ b/doc/source/getting_started/install.rst
@@ -101,6 +101,10 @@ as well::
     installing those. Using conda (see above) avoids the need to compile the
     dependencies yourself.
 
+Optional runtime dependencies can also be installed all at once::
+
+    pip install 'geopandas[all]'
+
 Installing from source
 ----------------------
 
@@ -111,6 +115,11 @@ You may install the latest development version by cloning the
     cd geopandas
     pip install .
 
+Development dependencies can be installed using the dev optional
+dependency group::
+
+    pip install '.[dev]'
+
 It is also possible to install the latest development version
 directly from the GitHub repository with::
 
diff --git a/geopandas/_compat.py b/geopandas/_compat.py
index 034d28fb..0032eb47 100644
--- a/geopandas/_compat.py
+++ b/geopandas/_compat.py
@@ -67,3 +67,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 90b119ae..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"""
@@ -525,6 +541,9 @@ class GeometryArray(ExtensionArray):
         out[:] = [shapely.count_coordinates(s) for s in self._data]
         return out
 
+    def get_precision(self):
+        return shapely.get_precision(self._data)
+
     #
     # Unary operations that return new geometries
     #
@@ -646,6 +665,12 @@ class GeometryArray(ExtensionArray):
             shapely.transform(self._data, transformation, include_z), crs=self.crs
         )
 
+    def set_precision(self, grid_size, mode="valid_output"):
+        return GeometryArray(
+            shapely.set_precision(self._data, grid_size=grid_size, mode=mode),
+            crs=self.crs,
+        )
+
     #
     # Binary predicates
     #
@@ -872,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.
@@ -941,6 +967,8 @@ class GeometryArray(ExtensionArray):
         - Prime Meridian: Greenwich
 
         """
+        from pyproj import CRS
+
         if self.crs is None:
             raise ValueError(
                 "Cannot transform naive geometries.  "
@@ -962,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.
 
@@ -1000,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/base.py b/geopandas/base.py
index 93fb80e8..dfa59bc4 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -540,6 +540,56 @@ GeometryCollection
         """
         return _delegate_property("has_z", self)
 
+    def get_precision(self):
+        """Returns a ``Series`` of the precision of each geometry.
+
+        If a precision has not been previously set, it will be 0, indicating regular
+        double precision coordinates are in use. Otherwise, it will return the precision
+        grid size that was set on a geometry.
+
+        Returns NaN for not-a-geometry values.
+
+        Examples
+        --------
+        >>> from shapely.geometry import Point
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...         Point(0, 1),
+        ...         Point(0, 1, 2),
+        ...         Point(0, 1.5, 2),
+        ...     ]
+        ... )
+        >>> s
+        0          POINT (0 1)
+        1      POINT Z (0 1 2)
+        2    POINT Z (0 1.5 2)
+        dtype: geometry
+
+        >>> s.get_precision()
+        0    0.0
+        1    0.0
+        2    0.0
+        dtype: float64
+
+        >>> s1 = s.set_precision(1)
+        >>> s1
+        0        POINT (0 1)
+        1    POINT Z (0 1 2)
+        2    POINT Z (0 2 2)
+        dtype: geometry
+
+        >>> s1.get_precision()
+        0    1.0
+        1    1.0
+        2    1.0
+        dtype: float64
+
+        See also
+        --------
+        GeoSeries.set_precision : set precision grid size
+        """
+        return Series(self.geometry.values.get_precision(), index=self.index)
+
     #
     # Unary operations that return a GeoSeries
     #
@@ -1056,6 +1106,95 @@ GeometryCollection
         """
         return _delegate_geo_method("remove_repeated_points", self, tolerance=tolerance)
 
+    def set_precision(self, grid_size, mode="valid_output"):
+        """Returns a ``GeoSeries`` with the precision set to a precision grid size.
+
+        By default, geometries use double precision coordinates (``grid_size=0``).
+
+        Coordinates will be rounded if a precision grid is less precise than the input
+        geometry. Duplicated vertices will be dropped from lines and polygons for grid
+        sizes greater than 0. Line and polygon geometries may collapse to empty
+        geometries if all vertices are closer together than ``grid_size``. Spikes or
+        sections in Polygons narrower than ``grid_size`` after rounding the vertices
+        will be removed, which can lead to MultiPolygons or empty geometries. Z values,
+        if present, will not be modified.
+
+        Parameters
+        ----------
+        grid_size : float
+            Precision grid size. If 0, will use double precision (will not modify
+            geometry if precision grid size was not previously set). If this value is
+            more precise than input geometry, the input geometry will not be modified.
+        mode : {'valid_output', 'pointwise', 'keep_collapsed'}, default 'valid_output'
+            This parameter determines the way a precision reduction is applied on the
+            geometry. There are three modes:
+
+            * ``'valid_output'`` (default): The output is always valid. Collapsed
+              geometry elements (including both polygons and lines) are removed.
+              Duplicate vertices are removed.
+            * ``'pointwise'``: Precision reduction is performed pointwise. Output
+              geometry may be invalid due to collapse or self-intersection. Duplicate
+              vertices are not removed.
+            * ``'keep_collapsed'``: Like the default mode, except that collapsed linear
+              geometry elements are preserved. Collapsed polygonal input elements are
+              removed. Duplicate vertices are removed.
+
+        Examples
+        --------
+
+        >>> from shapely import LineString, Point
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...        Point(0.9, 0.9),
+        ...        Point(0.9, 0.9, 0.9),
+        ...        LineString([(0, 0), (0, 0.1), (0, 1), (1, 1)]),
+        ...        LineString([(0, 0), (0, 0.1), (0.1, 0.1)])
+        ...     ],
+        ... )
+        >>> s
+        0                      POINT (0.9 0.9)
+        1                POINT Z (0.9 0.9 0.9)
+        2    LINESTRING (0 0, 0 0.1, 0 1, 1 1)
+        3     LINESTRING (0 0, 0 0.1, 0.1 0.1)
+        dtype: geometry
+
+        >>> s.set_precision(1)
+        0                   POINT (1 1)
+        1             POINT Z (1 1 0.9)
+        2    LINESTRING (0 0, 0 1, 1 1)
+        3            LINESTRING Z EMPTY
+        dtype: geometry
+
+        >>> s.set_precision(1, mode="pointwise")
+        0                        POINT (1 1)
+        1                  POINT Z (1 1 0.9)
+        2    LINESTRING (0 0, 0 0, 0 1, 1 1)
+        3         LINESTRING (0 0, 0 0, 0 0)
+        dtype: geometry
+
+        >>> s.set_precision(1, mode="keep_collapsed")
+        0                   POINT (1 1)
+        1             POINT Z (1 1 0.9)
+        2    LINESTRING (0 0, 0 1, 1 1)
+        3         LINESTRING (0 0, 0 0)
+        dtype: geometry
+
+        Notes
+        -----
+        Subsequent operations will always be performed in the precision of the geometry
+        with higher precision (smaller ``grid_size``). That same precision will be
+        attached to the operation outputs.
+
+        Input geometries should be geometrically valid; unexpected results may occur if
+        input geometries are not. You can check the validity with
+        :meth:`~GeoSeries.is_valid` and fix invalid geometries with
+        :meth:`~GeoSeries.make_valid` methods.
+
+        """
+        return _delegate_geo_method(
+            "set_precision", self, grid_size=grid_size, mode=mode
+        )
+
     def representative_point(self):
         """Returns a ``GeoSeries`` of (cheaply computed) points that are
         guaranteed to be within each geometry.
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index 63051078..fea7f3fc 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)
 
diff --git a/geopandas/geoseries.py b/geopandas/geoseries.py
index 2a5a7480..1a4aaa13 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
@@ -971,7 +970,7 @@ class GeoSeries(GeoPandasBase, Series):
     #
     # Additional methods
     #
-
+    @compat.requires_pyproj
     def set_crs(
         self,
         crs: Optional[Any] = None,
@@ -1049,6 +1048,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 +1153,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/tools/sjoin.py b/geopandas/tools/sjoin.py
index 67ca1775..97425da3 100644
--- a/geopandas/tools/sjoin.py
+++ b/geopandas/tools/sjoin.py
@@ -1,4 +1,6 @@
+from functools import partial
 from typing import Optional
+import warnings
 
 import numpy as np
 import pandas as pd
@@ -146,18 +148,6 @@ def _basic_checks(left_df, right_df, how, lsuffix, rsuffix):
     if not _check_crs(left_df, right_df):
         _crs_mismatch_warn(left_df, right_df, stacklevel=4)
 
-    index_left = "index_{}".format(lsuffix)
-    index_right = "index_{}".format(rsuffix)
-
-    # due to GH 352
-    if any(left_df.columns.isin([index_left, index_right])) or any(
-        right_df.columns.isin([index_left, index_right])
-    ):
-        raise ValueError(
-            "'{0}' and '{1}' cannot be names in the frames being"
-            " joined".format(index_left, index_right)
-        )
-
 
 def _geom_predicate_query(left_df, right_df, predicate, distance):
     """Compute geometric comparisons and get matching indices.
@@ -210,6 +200,101 @@ def _geom_predicate_query(left_df, right_df, predicate, distance):
     return indices
 
 
+def _reset_index_with_suffix(df, suffix, other):
+    """
+    Equivalent of df.reset_index(), but with adding 'suffix' to auto-generated
+    column names.
+    """
+    index_original = df.index.names
+    df_reset = df.reset_index()
+    column_names = df_reset.columns.to_numpy(copy=True)
+    for i, label in enumerate(index_original):
+        # if the original label was None, add suffix to auto-generated name
+        if label is None:
+            new_label = column_names[i]
+            if "level" in new_label:
+                # reset_index of MultiIndex gives "level_i" names, preserve the "i"
+                lev = new_label.split("_")[1]
+                new_label = f"index_{suffix}{lev}"
+            else:
+                new_label = f"index_{suffix}"
+            # check new label will not be in other dataframe
+            if new_label in df.columns or new_label in other.columns:
+                raise ValueError(
+                    "'{0}' cannot be a column name in the frames being"
+                    " joined".format(new_label)
+                )
+            column_names[i] = new_label
+    return df_reset, pd.Index(column_names)
+
+
+def _process_column_names_with_suffix(
+    left: pd.Index, right: pd.Index, suffixes, left_df, right_df
+):
+    """
+    Add suffixes to overlapping labels (ignoring the geometry column).
+
+    This is based on pandas' merge logic at https://github.com/pandas-dev/pandas/blob/
+    a0779adb183345a8eb4be58b3ad00c223da58768/pandas/core/reshape/merge.py#L2300-L2370
+    """
+    to_rename = left.intersection(right)
+    if len(to_rename) == 0:
+        return left, right
+
+    lsuffix, rsuffix = suffixes
+
+    if not lsuffix and not rsuffix:
+        raise ValueError(f"columns overlap but no suffix specified: {to_rename}")
+
+    def renamer(x, suffix, geometry):
+        if x in to_rename and x != geometry and suffix is not None:
+            return f"{x}_{suffix}"
+        return x
+
+    lrenamer = partial(renamer, suffix=lsuffix, geometry=left_df._geometry_column_name)
+    rrenamer = partial(renamer, suffix=rsuffix, geometry=right_df._geometry_column_name)
+
+    # TODO retain index name?
+    left_renamed = pd.Index([lrenamer(lab) for lab in left])
+    right_renamed = pd.Index([rrenamer(lab) for lab in right])
+
+    dups = []
+    if not left_renamed.is_unique:
+        # Only warn when duplicates are caused because of suffixes, already duplicated
+        # columns in origin should not warn
+        dups = left_renamed[(left_renamed.duplicated()) & (~left.duplicated())].tolist()
+    if not right_renamed.is_unique:
+        dups.extend(
+            right_renamed[(right_renamed.duplicated()) & (~right.duplicated())].tolist()
+        )
+    # TODO turn this into an error (pandas has done so as well)
+    if dups:
+        warnings.warn(
+            f"Passing 'suffixes' which cause duplicate columns {set(dups)} in the "
+            f"result is deprecated and will raise a MergeError in a future version.",
+            FutureWarning,
+            stacklevel=4,
+        )
+
+    return left_renamed, right_renamed
+
+
+def _restore_index(joined, index_names, index_names_original):
+    """
+    Set back the the original index columns, and restoring their name as `None`
+    if they didn't have a name originally.
+    """
+    joined = joined.set_index(list(index_names))
+
+    # restore the fact that the index didn't have a name
+    joined_index_names = list(joined.index.names)
+    for i, label in enumerate(index_names_original):
+        if label is None:
+            joined_index_names[i] = None
+    joined.index.names = joined_index_names
+    return joined
+
+
 def _frame_join(join_df, left_df, right_df, how, lsuffix, rsuffix):
     """Join the GeoDataFrames at the DataFrame level.
 
@@ -240,33 +325,29 @@ def _frame_join(join_df, left_df, right_df, how, lsuffix, rsuffix):
     # index in geopandas may be any arbitrary dtype. so reset both indices now
     # and store references to the original indices, to be reaffixed later.
     # GH 352
-    index_left = "index_{}".format(lsuffix)
     left_df = left_df.copy(deep=True)
-    try:
-        left_index_name = left_df.index.name
-        left_df.index = left_df.index.rename(index_left)
-    except TypeError:
-        index_left = [
-            "index_{}".format(lsuffix + str(pos))
-            for pos, ix in enumerate(left_df.index.names)
-        ]
-        left_index_name = left_df.index.names
-        left_df.index = left_df.index.rename(index_left)
-    left_df = left_df.reset_index()
-
-    index_right = "index_{}".format(rsuffix)
+    left_nlevels = left_df.index.nlevels
+    left_index_original = left_df.index.names
+    left_df, left_column_names = _reset_index_with_suffix(left_df, lsuffix, right_df)
+
     right_df = right_df.copy(deep=True)
-    try:
-        right_index_name = right_df.index.name
-        right_df.index = right_df.index.rename(index_right)
-    except TypeError:
-        index_right = [
-            "index_{}".format(rsuffix + str(pos))
-            for pos, ix in enumerate(right_df.index.names)
-        ]
-        right_index_name = right_df.index.names
-        right_df.index = right_df.index.rename(index_right)
-    right_df = right_df.reset_index()
+    right_nlevels = right_df.index.nlevels
+    right_index_original = right_df.index.names
+    right_df, right_column_names = _reset_index_with_suffix(right_df, rsuffix, left_df)
+
+    # if conflicting names in left and right, add suffix
+    left_column_names, right_column_names = _process_column_names_with_suffix(
+        left_column_names,
+        right_column_names,
+        (lsuffix, rsuffix),
+        left_df,
+        right_df,
+    )
+    left_df.columns = left_column_names
+    right_df.columns = right_column_names
+
+    left_index = left_df.columns[:left_nlevels]
+    right_index = right_df.columns[:right_nlevels]
 
     # perform join on the dataframes
     if how == "inner":
@@ -277,15 +358,11 @@ def _frame_join(join_df, left_df, right_df, how, lsuffix, rsuffix):
                 right_df.drop(right_df.geometry.name, axis=1),
                 left_on="_key_right",
                 right_index=True,
-                suffixes=("_{}".format(lsuffix), "_{}".format(rsuffix)),
+                suffixes=(None, None),
             )
-            .set_index(index_left)
             .drop(["_key_right"], axis=1)
         )
-        if isinstance(index_left, list):
-            joined.index.names = left_index_name
-        else:
-            joined.index.name = left_index_name
+        joined = _restore_index(joined, left_index, left_index_original)
 
     elif how == "left":
         join_df = join_df.set_index("_key_left")
@@ -296,15 +373,11 @@ def _frame_join(join_df, left_df, right_df, how, lsuffix, rsuffix):
                 how="left",
                 left_on="_key_right",
                 right_index=True,
-                suffixes=("_{}".format(lsuffix), "_{}".format(rsuffix)),
+                suffixes=(None, None),
             )
-            .set_index(index_left)
             .drop(["_key_right"], axis=1)
         )
-        if isinstance(index_left, list):
-            joined.index.names = left_index_name
-        else:
-            joined.index.name = left_index_name
+        joined = _restore_index(joined, left_index, left_index_original)
 
     else:  # how == 'right':
         joined = (
@@ -316,16 +389,12 @@ def _frame_join(join_df, left_df, right_df, how, lsuffix, rsuffix):
                 left_index=True,
                 right_on="_key_left",
                 how="right",
-                suffixes=("_{}".format(lsuffix), "_{}".format(rsuffix)),
+                suffixes=(None, None),
             )
-            .set_index(index_right)
             .drop(["_key_left", "_key_right"], axis=1)
             .set_geometry(right_df.geometry.name)
         )
-        if isinstance(index_right, list):
-            joined.index.names = right_index_name
-        else:
-            joined.index.name = right_index_name
+        joined = _restore_index(joined, right_index, right_index_original)
 
     return joined
 
diff --git a/pyproject.toml b/pyproject.toml
index 4a46fe0e..3941f7d3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -28,6 +28,28 @@ dependencies = [
     "shapely >= 2.0.0",
 ]
 
+[project.optional-dependencies]
+all = [
+    "psycopg2-binary>=2.8.0",
+    "SQLAlchemy>=1.3",
+    "geopy",
+    "matplotlib>=3.5.0",
+    "mapclassify",
+    "xyzservices",
+    "folium",
+    "GeoAlchemy2",
+    "pyarrow>=8.0.0",
+]
+dev = [
+    "pytest>=3.1.0",
+    "pytest-cov",
+    "pytest-xdist",
+    "codecov",
+    "black",
+    "pre-commit",
+]
+
+
 [project.readme]
 text = """\
 GeoPandas is a project to add support for geographic data to
@@ -69,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 02ab08bc..911f439c 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -6,7 +6,7 @@ shapely>=2.0.0
 packaging
 
 # geodatabase access
-psycopg2>=2.8.0
+psycopg2-binary>=2.8.0
 SQLAlchemy>=1.3
 
 # geocoding
@@ -15,10 +15,13 @@ geopy
 # plotting
 matplotlib>=3.5.0
 mapclassify
+xyzservices
+folium
 
 # testing
 pytest>=3.1.0
 pytest-cov
+pytest-xdist
 codecov
 
 # styling
@@ -27,3 +30,6 @@ pre-commit
 
 # PostGIS writing
 GeoAlchemy2
+
+# parquet
+pyarrow>=8.0.0


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/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/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 9106f027..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)
@@ -1794,3 +1801,62 @@ class TestGeomMethods:
             ],
         )
         assert_geoseries_equal(expected, self.g1.force_3d([1, 2]))
+
+    @pytest.mark.skipif(
+        shapely.geos_version < (3, 9, 5), reason="Empty geom bug in GEOS<3.9.5"
+    )
+    def test_set_precision(self):
+        expected = GeoSeries(
+            [
+                Point(-74, 41, 30.3244),
+                Point(-74, 41, 31.2344),
+                Point(-74, 41),
+                self.pt_empty,
+            ],
+            crs=4326,
+        )
+        assert_geoseries_equal(expected, self.landmarks_mixed_empty.set_precision(1))
+
+        s = GeoSeries(
+            [
+                LineString([(0, 0), (0, 0.1), (0, 1), (1, 1)]),
+                LineString([(0, 0), (0, 0.1), (0.1, 0.1)]),
+            ],
+        )
+        expected = GeoSeries(
+            [
+                LineString([(0, 0), (0, 1), (1, 1)]),
+                LineString(),
+            ],
+        )
+        assert_geoseries_equal(expected, s.set_precision(1))
+
+        expected = GeoSeries(
+            [
+                LineString([(0, 0), (0, 0), (0, 1), (1, 1)]),
+                LineString([(0, 0), (0, 0), (0, 0)]),
+            ]
+        )
+        assert_series_equal(
+            expected.to_wkt(), s.set_precision(1, mode="pointwise").to_wkt()
+        )
+
+        expected = GeoSeries(
+            [
+                LineString([(0, 0), (0, 1), (1, 1)]),
+                LineString([(0, 0), (0, 0)]),
+            ]
+        )
+        assert_series_equal(
+            expected.to_wkt(), s.set_precision(1, mode="keep_collapsed").to_wkt()
+        )
+
+    def test_get_precision(self):
+        expected = Series([0.0, 0.0, 0.0, 0.0], index=self.landmarks_mixed_empty.index)
+        assert_series_equal(expected, self.landmarks_mixed_empty.get_precision())
+        with_precision = self.landmarks_mixed_empty.set_precision(1)
+        expected = Series([1.0, 1.0, 1.0, 1.0], index=with_precision.index)
+        assert_series_equal(expected, with_precision.get_precision())
+        mixed = concat([self.landmarks_mixed_empty, with_precision])
+        expected = Series([0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], index=mixed.index)
+        assert_series_equal(expected, mixed.get_precision())
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_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 a3c83df3..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
@@ -149,7 +150,7 @@ class TestSpatialJoin:
             ].astype("int64")
         if index == "named-index":
             exp[["df1_ix", "df2_ix"]] = exp[["df1_ix", "df2_ix"]].astype("int64")
-            exp = exp.set_index("df1_ix").rename(columns={"df2_ix": "index_right"})
+            exp = exp.set_index("df1_ix")
         if index in ["default-index", "string-index"]:
             exp = exp.set_index("index_left")
             exp.index.name = None
@@ -159,10 +160,7 @@ class TestSpatialJoin:
             )
             exp.index.names = df1.index.names
         if index == "named-multi-index":
-            exp = exp.set_index(["df1_ix1", "df1_ix2"]).rename(
-                columns={"df2_ix1": "index_right0", "df2_ix2": "index_right1"}
-            )
-            exp.index.names = df1.index.names
+            exp = exp.set_index(["df1_ix1", "df1_ix2"])
 
         assert_frame_equal(res, exp)
 
@@ -199,7 +197,7 @@ class TestSpatialJoin:
             res["index_right"] = res["index_right"].astype(float)
         elif index == "named-index":
             exp[["df1_ix"]] = exp[["df1_ix"]].astype("int64")
-            exp = exp.set_index("df1_ix").rename(columns={"df2_ix": "index_right"})
+            exp = exp.set_index("df1_ix")
         if index in ["default-index", "string-index"]:
             exp = exp.set_index("index_left")
             exp.index.name = None
@@ -209,10 +207,7 @@ class TestSpatialJoin:
             )
             exp.index.names = df1.index.names
         if index == "named-multi-index":
-            exp = exp.set_index(["df1_ix1", "df1_ix2"]).rename(
-                columns={"df2_ix1": "index_right0", "df2_ix2": "index_right1"}
-            )
-            exp.index.names = df1.index.names
+            exp = exp.set_index(["df1_ix1", "df1_ix2"])
 
         assert_frame_equal(res, exp)
 
@@ -315,7 +310,7 @@ class TestSpatialJoin:
             res["index_left"] = res["index_left"].astype(float)
         elif index == "named-index":
             exp[["df2_ix"]] = exp[["df2_ix"]].astype("int64")
-            exp = exp.set_index("df2_ix").rename(columns={"df1_ix": "index_left"})
+            exp = exp.set_index("df2_ix")
         if index in ["default-index", "string-index"]:
             exp = exp.set_index("index_right")
             exp = exp.reindex(columns=res.columns)
@@ -326,10 +321,8 @@ class TestSpatialJoin:
             )
             exp.index.names = df2.index.names
         if index == "named-multi-index":
-            exp = exp.set_index(["df2_ix1", "df2_ix2"]).rename(
-                columns={"df1_ix1": "index_left0", "df1_ix2": "index_left1"}
-            )
-            exp.index.names = df2.index.names
+            exp = exp.set_index(["df2_ix1", "df2_ix2"])
+
         if predicate == "within":
             exp = exp.sort_index()
 
@@ -392,6 +385,232 @@ class TestSpatialJoin:
         assert_frame_equal(expected_gdf.sort_index(), joined.sort_index())
 
 
+class TestIndexNames:
+    @pytest.mark.parametrize("how", ["inner", "left", "right"])
+    def test_preserve_index_names(self, how):
+        # preserve names of both left and right index
+        geoms = [Point(1, 1), Point(2, 2)]
+        df1 = GeoDataFrame({"geometry": geoms}, index=pd.Index([1, 2], name="myidx1"))
+        df2 = GeoDataFrame(
+            {"geometry": geoms}, index=pd.Index(["a", "b"], name="myidx2")
+        )
+        result = sjoin(df1, df2, how=how)
+        if how in ("inner", "left"):
+            expected = GeoDataFrame(
+                {"myidx1": [1, 2], "geometry": geoms, "myidx2": ["a", "b"]}
+            ).set_index("myidx1")
+        else:
+            # right join
+            expected = GeoDataFrame(
+                {"myidx2": ["a", "b"], "myidx1": [1, 2], "geometry": geoms},
+            ).set_index("myidx2")
+        assert_geodataframe_equal(result, expected)
+
+        # but also add suffixes if both left and right have the same index
+        df1.index.name = "myidx"
+        df2.index.name = "myidx"
+        result = sjoin(df1, df2, how=how)
+        if how in ("inner", "left"):
+            expected = GeoDataFrame(
+                {"myidx_left": [1, 2], "geometry": geoms, "myidx_right": ["a", "b"]}
+            ).set_index("myidx_left")
+        else:
+            # right join
+            expected = GeoDataFrame(
+                {"myidx_right": ["a", "b"], "myidx_left": [1, 2], "geometry": geoms},
+            ).set_index("myidx_right")
+        assert_geodataframe_equal(result, expected)
+
+    @pytest.mark.parametrize("how", ["inner", "left", "right"])
+    def test_preserve_index_names_multiindex(self, how):
+        # preserve names of both left and right index
+        geoms = [Point(1, 1), Point(2, 2)]
+        df1 = GeoDataFrame(
+            {"geometry": geoms},
+            index=pd.MultiIndex.from_tuples(
+                [("a", 1), ("b", 2)], names=["myidx1", "level2"]
+            ),
+        )
+        df2 = GeoDataFrame(
+            {"geometry": geoms},
+            index=pd.MultiIndex.from_tuples(
+                [("c", 3), ("d", 4)], names=["myidx2", None]
+            ),
+        )
+        result = sjoin(df1, df2, how=how)
+        expected_base = GeoDataFrame(
+            {
+                "myidx1": ["a", "b"],
+                "level2": [1, 2],
+                "geometry": geoms,
+                "myidx2": ["c", "d"],
+                "index_right1": [3, 4],
+            }
+        )
+        if how in ("inner", "left"):
+            expected = expected_base.set_index(["myidx1", "level2"])
+        else:
+            # right join
+            expected = expected_base.set_index(["myidx2", "index_right1"])
+            # if it was originally None, that is preserved
+            expected.index.names = ["myidx2", None]
+        assert_geodataframe_equal(result, expected)
+
+        # but also add suffixes if both left and right have the same index
+        df1.index.names = ["myidx", "level2"]
+        df2.index.names = ["myidx", None]
+        result = sjoin(df1, df2, how=how)
+        expected_base = GeoDataFrame(
+            {
+                "myidx_left": ["a", "b"],
+                "level2": [1, 2],
+                "geometry": geoms,
+                "myidx_right": ["c", "d"],
+                "index_right1": [3, 4],
+            }
+        )
+        if how in ("inner", "left"):
+            expected = expected_base.set_index(["myidx_left", "level2"])
+        else:
+            # right join
+            expected = expected_base.set_index(["myidx_right", "index_right1"])
+            # if it was originally None, that is preserved
+            expected.index.names = ["myidx_right", None]
+        assert_geodataframe_equal(result, expected)
+
+    @pytest.mark.parametrize("how", ["inner", "left", "right"])
+    def test_duplicate_column_index_name(self, how):
+        # case where a left column and the right index have the same name or the
+        # other way around -> correctly add suffix or preserve index name
+        geoms = [Point(1, 1), Point(2, 2)]
+        df1 = GeoDataFrame({"myidx": [1, 2], "geometry": geoms})
+        df2 = GeoDataFrame(
+            {"geometry": geoms}, index=pd.Index(["a", "b"], name="myidx")
+        )
+        result = sjoin(df1, df2, how=how)
+        if how in ("inner", "left"):
+            expected = GeoDataFrame(
+                {"myidx_left": [1, 2], "geometry": geoms, "myidx_right": ["a", "b"]}
+            )
+        else:
+            # right join
+            expected = GeoDataFrame(
+                {"index_left": [0, 1], "myidx_left": [1, 2], "geometry": geoms},
+                index=pd.Index(["a", "b"], name="myidx_right"),
+            )
+        assert_geodataframe_equal(result, expected)
+
+        result = sjoin(df2, df1, how=how)
+        if how in ("inner", "left"):
+            expected = GeoDataFrame(
+                {"geometry": geoms, "index_right": [0, 1], "myidx_right": [1, 2]},
+                index=pd.Index(["a", "b"], name="myidx_left"),
+            )
+        else:
+            # right join
+            expected = GeoDataFrame(
+                {"myidx_left": ["a", "b"], "myidx_right": [1, 2], "geometry": geoms},
+            )
+        assert_geodataframe_equal(result, expected)
+
+    @pytest.mark.parametrize("how", ["inner", "left", "right"])
+    def test_duplicate_column_index_name_multiindex(self, how):
+        # case where a left column and the right index have the same name or the
+        # other way around -> correctly add suffix or preserve index name
+        geoms = [Point(1, 1), Point(2, 2)]
+        df1 = GeoDataFrame({"myidx": [1, 2], "geometry": geoms})
+        df2 = GeoDataFrame(
+            {"geometry": geoms},
+            index=pd.MultiIndex.from_tuples(
+                [("a", 1), ("b", 2)], names=["myidx", "level2"]
+            ),
+        )
+        result = sjoin(df1, df2, how=how)
+        if how in ("inner", "left"):
+            expected = GeoDataFrame(
+                {
+                    "myidx_left": [1, 2],
+                    "geometry": geoms,
+                    "myidx_right": ["a", "b"],
+                    "level2": [1, 2],
+                }
+            )
+        else:
+            # right join
+            expected = GeoDataFrame(
+                {"index_left": [0, 1], "myidx_left": [1, 2], "geometry": geoms},
+                index=pd.MultiIndex.from_tuples(
+                    [("a", 1), ("b", 2)], names=["myidx_right", "level2"]
+                ),
+            )
+        assert_geodataframe_equal(result, expected)
+
+        result = sjoin(df2, df1, how=how)
+        if how in ("inner", "left"):
+            expected = GeoDataFrame(
+                {"geometry": geoms, "index_right": [0, 1], "myidx_right": [1, 2]},
+                index=pd.MultiIndex.from_tuples(
+                    [("a", 1), ("b", 2)], names=["myidx_left", "level2"]
+                ),
+            )
+        else:
+            # right join
+            expected = GeoDataFrame(
+                {
+                    "myidx_left": ["a", "b"],
+                    "level2": [1, 2],
+                    "myidx_right": [1, 2],
+                    "geometry": geoms,
+                },
+            )
+        assert_geodataframe_equal(result, expected)
+
+    @pytest.mark.parametrize("how", ["inner", "left", "right"])
+    def test_conflicting_column_index_name(self, how):
+        # test case where the auto-generated index name conflicts
+        geoms = [Point(1, 1), Point(2, 2)]
+        df1 = GeoDataFrame({"index_right": [1, 2], "geometry": geoms})
+        df2 = GeoDataFrame({"geometry": geoms})
+        with pytest.raises(ValueError, match="'index_right' cannot be a column name"):
+            sjoin(df1, df2, how=how)
+
+    @pytest.mark.xfail
+    @pytest.mark.parametrize("how", ["inner", "left", "right"])
+    def test_conflicting_column_with_suffix(self, how):
+        # test case where the auto-generated index name conflicts
+        geoms = [Point(1, 1), Point(2, 2)]
+        df1 = GeoDataFrame(
+            {"column": [1, 2], "column_right": ["a", "b"], "geometry": geoms}
+        )
+        df2 = GeoDataFrame({"column": [0.1, 0.2], "geometry": geoms})
+
+        result = sjoin(df1, df2, how=how)
+        if how in ("inner", "left"):
+            expected = GeoDataFrame(
+                {1: [1, 2], 2: ["a", "b"], 3: geoms, 4: [0, 1], 5: [0.1, 0.2]}
+            )
+            expected.columns = [
+                "column_left",
+                "column_right",
+                "geometry",
+                "index_right",
+                "column_right",
+            ]
+        else:
+            # right join
+            expected = GeoDataFrame(
+                {1: [0, 1], 2: [1, 2], 3: ["a", "b"], 4: [0.1, 0.2], 5: geoms}
+            )
+            expected.columns = [
+                "index_left",
+                "column_left",
+                "column_right",
+                "column_right",
+                "geometry",
+            ]
+        assert_geodataframe_equal(result, expected)
+
+
 @pytest.mark.usefixtures("_setup_class_nybb_filename")
 class TestSpatialJoinNYBB:
     def setup_method(self):