Mastering Core Geospatial Python Libraries
The modern Python geospatial stack replaces legacy desktop workflows with reproducible, scriptable code, and it is the foundation everything else on python-geospatial.com builds on. Five libraries carry almost every production pipeline: GeoPandas DataFrames for tabular-plus-geometry analysis, Shapely geometry operations for the topological primitives, coordinate systems with PyProj for datum-safe transformations, and raster data handling with Rasterio for pixel workflows — with Fiona quietly handling vector I/O underneath. When the raster side grows a third or fourth dimension — time, band, ensemble member — the labelled arrays of xarray and rioxarray raster cubes take over from flat Rasterio reads. This guide establishes the architectural mental model that ties them together: strict CRS management, topology validation, and memory-efficient I/O. Once the stack is ingesting clean data, it feeds the downstream ingestion and processing workflows and the spatial analysis and advanced query techniques that turn geometry into answers.
Ecosystem Architecture & Dependency Management
Every library in this stack is a thin Python wrapper over three compiled C/C++ libraries. GDAL/OGR reads and writes raster and vector formats, PROJ performs coordinate transformations, and GEOS implements the topological predicates. GeoPandas does not reimplement geometry — it calls Shapely, which calls GEOS. Rasterio does not parse GeoTIFF headers itself — it delegates to GDAL. PyProj is a binding to PROJ. Once you internalise that the entire stack resolves down to three shared native libraries, most "impossible" environment bugs become obvious: two Python packages compiled against two different GDAL builds cannot safely share the same process.
This is why binary provenance matters more here than in almost any other Python domain. A wheel from PyPI ships its own bundled copy of GDAL/PROJ/GEOS; a conda-forge build links against the channel's shared libraries. Mixing the two — installing geopandas from conda and rasterio from pip into the same environment — produces ABI mismatches that surface as segfaults, silent projection errors, or PROJ: proj_create: Cannot find proj.db at import time. Pick one channel per environment and stay on it.
For reproducible builds, pin the native stack explicitly and let the solver resolve the wrappers against it:
# environment.yml — one channel, pinned native libraries
name: geo-prod
channels:
- conda-forge
dependencies:
- python=3.12
- gdal=3.9
- proj=9.4
- geos=3.12
- geopandas=1.0
- shapely=2.0
- pyproj=3.6
- rasterio=1.3
- fiona=1.10
- pyogrio=0.9 # vectorized OGR reader used by GeoPandas 1.0
Before any pipeline runs, verify that the interpreter can actually import the stack and that PROJ can find its data directory — the single most common deployment failure. This script is safe to drop into a container health check:
import importlib
REQUIRED_PACKAGES = ["geopandas", "shapely", "pyproj", "rasterio", "fiona"]
def verify_environment() -> None:
missing = []
for pkg in REQUIRED_PACKAGES:
try:
importlib.import_module(pkg)
except ImportError:
missing.append(pkg)
if missing:
raise RuntimeError(f"Missing core dependencies: {', '.join(missing)}")
# Confirm PROJ can locate proj.db — a missing datadir corrupts every transform
import pyproj
if not pyproj.datadir.get_data_dir():
raise RuntimeError("PROJ data directory not found; set PROJ_DATA / PROJ_LIB")
print("Geospatial stack verified. Ready for pipeline execution.")
verify_environment()
Installing GeoPandas on Windows deserves special care, because the wheels historically shipped mismatched GDAL builds; the platform-specific recipe lives in how to install and configure GeoPandas on Windows.
Containerising the stack without losing PROJ
Containers are where the "one channel per environment" rule pays off, and where it is most often broken by a multi-stage build that copies site-packages but not the native data directories. GDAL and PROJ ship data as well as code: proj.db and any datum grids, plus GDAL's own gcs.csv-era support files. Copy the Python packages alone and you get an image that imports cleanly and then produces subtly wrong coordinates, because PROJ silently falls back to a ballpark transformation when it cannot open its database.
Three rules keep an image honest. Install the whole stack in one solve, not in layered RUN steps that each re-resolve dependencies. Set PROJ_DATA (or PROJ_LIB on PROJ below 9.1) and GDAL_DATA explicitly rather than relying on the wheel's compiled-in default, which is wrong the moment the environment is relocated. And make the health check run a real transformation, not an import.
# Dockerfile fragment — one solve, explicit data paths, a real smoke test
FROM mambaorg/micromamba:1.5
COPY environment.yml /tmp/environment.yml
RUN micromamba install -y -n base -f /tmp/environment.yml && micromamba clean --all --yes
ENV PROJ_DATA=/opt/conda/share/proj \
GDAL_DATA=/opt/conda/share/gdal \
GDAL_CACHEMAX=512 \
GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR
# Fails the build if PROJ cannot actually transform, not merely import
RUN python -c "\
import pyproj; \
t = pyproj.Transformer.from_crs('EPSG:4326', 'EPSG:32633', always_xy=True); \
x, y = t.transform(11.34, 44.49); \
assert 690000 < x < 700000 and 4930000 < y < 4940000, (x, y); \
print('PROJ OK', pyproj.proj_version_str)"
The assertion is deliberately a coordinate range rather than an exact value: it is tight enough to catch a broken proj.db or a swapped axis order, and loose enough to survive a PROJ upgrade that refines the transformation by centimetres. Pin the image by digest for anything whose numbers are cited externally.
Core Concepts & Data Model
The mental model that unifies the stack is a two-level hierarchy: individual geometries, and tables of geometries. A single Shapely geometry — a Point, LineString, Polygon, or their Multi* collections — is an immutable object with coordinates but no coordinate reference system of its own. Shapely knows the shape; it does not know where on Earth the shape sits. That knowledge lives one level up.
A GeoDataFrame is an ordinary pandas DataFrame with one special column — a GeoSeries of Shapely geometries — plus a single CRS attached to that column. This is the object you spend most of your time in, and its design explains most of the stack's ergonomics:
import geopandas as gpd
from shapely.geometry import Point
# Air-quality sensors as attribute rows + a geometry column
sensors = gpd.GeoDataFrame(
{
"sensor_id": ["S-01", "S-02", "S-03"],
"pm25": [12.4, 31.8, 8.1],
},
geometry=[Point(11.34, 44.49), Point(11.36, 44.50), Point(11.33, 44.48)],
crs="EPSG:4326", # WGS84 lon/lat — the CRS lives on the column, not the rows
)
print(sensors.geometry.name) # 'geometry' — the active geometry column
print(sensors.crs.to_epsg()) # 4326
print(type(sensors.geometry.iloc[0])) # <class 'shapely.geometry.point.Point'>
Three properties of this model are worth committing to memory. First, the CRS is a property of the GeoSeries, not of each geometry, so reprojecting a table is a single vectorized call. Second, a GeoDataFrame can hold multiple geometry columns (say, a parcel polygon and its centroid) but only one is "active" at a time — the one operations default to. Third, because the attributes are a real pandas DataFrame, every grouping, joining, and aggregation tool you already know applies unchanged; the geometry column just rides along. The full data-model deep dive, including index alignment traps, is in GeoPandas DataFrames explained, and the tradeoff of when a bare Shapely object beats a whole GeoDataFrame is covered in Shapely vs GeoPandas: when to use each.
The raster half of the same model
Rasters follow the identical layering with different nouns, and seeing the parallel makes the whole stack easier to hold in your head. A raster is a NumPy array that knows nothing about the Earth, plus three pieces of metadata that place it: an affine transform mapping array indices to world coordinates, a CRS those coordinates live in, and a nodata value declaring which cells are absent rather than zero. Exactly as a Shapely geometry has no CRS and a GeoSeries supplies one, a NumPy array has no georeferencing and the dataset object supplies it.
import rasterio
with rasterio.open("elevation_utm33n.tif") as src:
dem = src.read(1) # plain ndarray — no CRS, no location
georef = (src.transform, src.crs, src.nodata) # the three things that place it
print(dem.shape, dem.dtype) # (1800, 2400) int16 — just numbers
print(georef[1]) # EPSG:32633 — the array is only meaningful with this
The single most consequential difference from the vector side: the array and its georeferencing are separable, and nothing enforces that they stay together. Slice a NumPy array and the transform silently becomes wrong for the slice; the vector equivalent — filtering a GeoDataFrame — can never desynchronise geometry from attributes, because they are the same object. Every raster bug of the form "the output is shifted by a few hundred metres" is this: a derived array written with the parent's transform. The mechanics of deriving the correct transform, and the rest of the pixel-side workflow, are in raster data handling with Rasterio.
One more axis completes the model. A single raster is two-dimensional plus bands; a cube adds labelled dimensions — time, variable, ensemble member — so that a slice is sel(time="2024-06") rather than a filename convention and an integer index. That is the step from Rasterio to xarray and rioxarray raster cubes, and the honest trigger for taking it is when you notice yourself maintaining a dictionary that maps dates to arrays.
Library Responsibilities & Interoperability
The stack is small enough that the real skill is routing: knowing which library owns a job, and recognising when you have reached past its edge. Each has a clean responsibility, and almost every architectural mistake in this domain is a job done in the wrong tier.
- Fiona and pyogrio own vector I/O. Fiona streams features one at a time, which is what you want when the file exceeds RAM or when you need per-feature control before anything is materialised. pyogrio reads whole layers in vectorized bulk and is the default engine behind GeoPandas 1.0. Neither knows anything about analysis; the choice between streaming and bulk is worked through in GeoPandas vs Fiona for large files.
- Shapely owns geometry, and nothing else. Predicates, set operations, buffers, validity. It has no concept of a CRS, no I/O, and no attributes — which is exactly why it composes so well. If you find yourself writing coordinate arithmetic by hand, Shapely almost certainly has the operation.
- pyproj owns coordinate reference systems. CRS objects, transformation pipelines, datum grids, axis order. It is not a geometry library: you hand it numbers or let GeoPandas and Rasterio call it for you. Hand-rolling a
Transformerover coordinates that are already inside a GeoDataFrame is the most common tier violation in this stack —to_crs()does the same job correctly and vectorized. The formats a CRS can be expressed in, and which to store, are compared in EPSG vs PROJ string vs WKT CRS formats. - GeoPandas owns the table. Index alignment, joins, group-wise aggregation, format round-trips. Reach past it in two directions: down to bare Shapely when there is no table (a single boundary, a handful of geometries in a service), and out to a partitioned or database engine when the table stops fitting.
- Rasterio owns one grid; rioxarray owns a stack of them. The boundary is whether your third axis has labels worth carrying.
You will cross between these tiers constantly, and there are only three bridges worth memorising. Vector into raster: burn geometries onto a grid so downstream work is array arithmetic — the input to zonal summaries. Raster into vector: sample pixel values at points, or polygonise a classified array, so results rejoin the attribute table. In-memory into a database: push the table into PostGIS or DuckDB when the join belongs to a query planner rather than to Python, as in PostGIS integration with Python and DuckDB spatial analytics.
Every one of those bridges has the same failure mode: the two sides disagree about the CRS, and nothing raises. Rasterisation takes a transform rather than a projection, so a mismatched vector burns into empty space; a database column has an SRID that is set once at table creation and never re-checked. Assert the CRS explicitly on both sides of every bridge, and convert once at the boundary rather than repeatedly in the middle.
Key Operations & Vectorized Workflows
Four operations account for the overwhelming majority of real vector work. Each is vectorized in Shapely 2.0 and GeoPandas 1.0 — meaning it runs across the whole column in a single GEOS call rather than a Python for loop — and each has a canonical form worth memorising. The generational leap in throughput from the old element-wise API is detailed in Shapely 1.x vs Shapely 2.0 vectorization.
1. Measurement (area, length, distance). These only return meaningful metric numbers in a projected CRS, never in degrees. Reproject first, then measure:
import geopandas as gpd
parcels = gpd.read_file("parcels.gpkg") # loaded as EPSG:4326
parcels_utm = parcels.to_crs("EPSG:32633") # UTM 33N — metres
parcels_utm["area_m2"] = parcels_utm.geometry.area # correct square metres
2. Spatial predicates and joins. Binary predicates (intersects, within, contains) answer "which of these relate to those" and power the spatial join — the workhorse of location analytics:
# Attach each sensor to the district polygon that contains it
sensors_by_district = gpd.sjoin(
sensors.to_crs("EPSG:32633"),
districts.to_crs("EPSG:32633"),
how="left",
predicate="within",
)
3. Constructive operations (buffer, centroid, convex hull). Buffering a point by a distance produces a polygon — but the distance is in the CRS units, so a 500 m buffer requires a metre-based CRS:
flood_zone = parcels_utm.geometry.buffer(500) # 500-metre buffer, correct only in UTM
4. Overlays (union, intersection, difference). Cutting one polygon layer against another is the set-theoretic core of overlay analysis, and it is where topology errors bite hardest. The dedicated treatment lives in geometric intersections and overlays. Validate geometries first (see below) — an invalid input silently corrupts the whole overlay.
The rule threaded through all four: predicates and topology are unitless and work in any CRS, but anything that returns or consumes a distance — area, length, buffer, nearest — is only correct in an appropriate projected CRS.
CRS / Projection Considerations
Coordinate handling is the single largest source of wrong-but-not-crashing results in this domain, so the stack centralises it in PyProj, which every other library calls for reprojection. Three gotchas recur often enough to treat as reflexes.
Axis order. A geographic CRS such as EPSG:4326 formally defines its axes as (latitude, longitude), but virtually every web API, GeoJSON file, and Shapely coordinate uses (longitude, latitude). Building a Transformer without always_xy=True silently flips your coordinates into the wrong hemisphere. Set it every time:
import pyproj
# Build the Transformer once and reuse it — re-parsing CRS inside a loop is the classic perf trap
to_utm = pyproj.Transformer.from_crs(
"EPSG:4326", "EPSG:32633", always_xy=True # enforce (lon, lat) ordering
)
easting, northing = to_utm.transform(11.34, 44.49) # lon, lon-lat in → metres out
print(f"{easting:.1f} E, {northing:.1f} N")
Deprecated PROJ syntax. The old +init=epsg:4326 string form is rejected by PROJ 6+; use the authority code ("EPSG:4326") or a full WKT2 string. Legacy shapefiles and old tutorials are riddled with the deprecated form — the errors it throws, and their fixes, are catalogued in fixing PyProj CRS transformation errors.
Web Mercator is not a metric CRS. EPSG:3857 (Web Mercator) is fine for slippy-map tiles but distorts area and distance badly away from the equator — a "square metre" near the poles can be off by an order of magnitude. Never compute buffers, areas, or distances in it. For metric work, pick a regional projected CRS; automating that choice per-dataset is covered in choosing a UTM zone automatically in Python.
import pyproj
# EPSG:3857 is valid syntactically — but validity is not suitability
assert pyproj.CRS("EPSG:3857").is_valid # True — yet still wrong for metric analysis
Rasters carry their georeferencing as an affine transform plus a CRS rather than per-pixel coordinates, but the same axis-order and datum discipline applies whenever you reproject a grid; the raster-specific details are in raster data handling with Rasterio.
Production Patterns & Performance
Development-time convenience — read the whole file, operate in memory, write it back — collapses on production-scale data. The patterns below keep pipelines within a memory budget and fast enough to run on a schedule.
Validate topology before expensive operations. Self-intersections, wrong ring orientation, and malformed multipolygons pass silently through I/O and then corrupt overlays and joins. Repair up front with make_valid:
from shapely.validation import make_valid
from shapely.geometry import Polygon
# A self-intersecting "bowtie" polygon
bowtie = Polygon([(0, 0), (1, 1), (0, 1), (1, 0), (0, 0)])
print(bowtie.is_valid) # False
repaired = make_valid(bowtie)
print(repaired.is_valid) # True
print(repaired.geom_type) # MultiPolygon or GeometryCollection
Stream, don't slurp. For vector data that exceeds RAM, iterate features instead of loading the whole layer; for rasters, read windows instead of the full array. Windowed reads keep a multi-gigabyte orthophoto within a fixed memory ceiling:
import rasterio
from rasterio.windows import Window
with rasterio.open("ortho.tif") as src:
window = Window(col_off=0, row_off=0, width=1024, height=1024)
tile = src.read(1, window=window) # only this 1024×1024 block enters memory
profile = src.profile.copy()
profile.update(
width=window.width,
height=window.height,
transform=src.window_transform(window), # keep georeferencing aligned to the tile
)
print(f"Tile shape: {tile.shape}")
Build a spatial index for repeated lookups. GeoPandas exposes an R-tree via .sindex; sjoin uses it automatically, but for hand-rolled nearest-neighbour or point-in-polygon loops, query the index rather than scanning every geometry. Choosing the right reader for large files (pyogrio/Fiona streaming vs a full GeoPandas load) is compared in GeoPandas vs Fiona for large files.
Pick cloud-native formats for both storage and interchange. GeoParquet for columnar vector storage, Cloud-Optimized GeoTIFF (COG) for rasters served over HTTP range requests, and lightweight GeoJSON only for the final hand-off to a browser. Export logic should always validate and reproject to a web-safe CRS before writing the client payload:
import logging
import geopandas as gpd
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
def export_for_web(gdf: gpd.GeoDataFrame, output_path: str) -> None:
if not gdf.is_valid.all():
raise ValueError("Invalid geometries detected before export.")
if gdf.crs is None or gdf.crs.to_epsg() != 4326:
gdf = gdf.to_crs(epsg=4326) # browsers/Leaflet expect WGS84 lon/lat
logging.info("Reprojected to EPSG:4326 for web delivery.")
gdf.to_file(output_path, driver="GeoJSON")
logging.info("Exported %d features to %s", len(gdf), output_path)
# export_for_web(flood_zone_gdf, "output/web_ready.geojson")
Those exports feed straight into the visualization layer described in web mapping and interactive visualization; the ingestion side that produces clean inputs is covered in geospatial data ingestion and processing workflows.
Testing & Verifying Geospatial Code
Spatial pipelines fail quietly more often than they crash, which makes tests unusually valuable and unusually easy to write wrong. Three problems are specific to this stack.
Geometry equality has three meanings, and the default is the strictest one. Under Shapely 2, geom_a == geom_b is structural equality: same type, same coordinates, in the same order. A polygon and the identical polygon with its ring reversed or its start vertex rotated are not equal by that test even though they cover exactly the same ground. geom_equals() asks the topological question — do these occupy the same point set — and returns True for both. geom_equals_exact(other, tolerance) sits between them, comparing coordinates within a numeric tolerance. Pick deliberately, because a test that uses == on the output of an overlay will fail the first time GEOS chooses a different starting vertex.
from shapely import Polygon, normalize
square = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
reversed_ring = Polygon([(0, 0), (0, 10), (10, 10), (10, 0)])
print(square == reversed_ring) # False — structural
print(square.equals(reversed_ring)) # True — topological
print(normalize(square) == normalize(reversed_ring)) # True — canonical form
normalize() is the practical fix: put both sides into GEOS's canonical vertex order first, and structural comparison becomes meaningful and fast.
Floating point means round trips are never exact. Reprojecting to a metric CRS and back does not return the original coordinates bit-for-bit, so an equality assertion on a round trip will fail on some machines and pass on others. Assert within a tolerance chosen from the CRS units — a millimetre in metres, or 1e-9 degrees — and state it in the test so a future reader knows it was a decision rather than an accident.
import geopandas as gpd
from geopandas.testing import assert_geodataframe_equal
from shapely import from_wkt
def test_reprojection_round_trip():
# Build fixtures from WKT in the test file — no binary data, no CRS surprises
parcels = gpd.GeoDataFrame(
{"parcel_id": ["A-01", "A-02"]},
geometry=from_wkt([
"POLYGON ((11.34 44.49, 11.35 44.49, 11.35 44.50, 11.34 44.50, 11.34 44.49))",
"POLYGON ((11.36 44.51, 11.37 44.51, 11.37 44.52, 11.36 44.52, 11.36 44.51))",
]),
crs="EPSG:4326",
)
utm = parcels.to_crs(parcels.estimate_utm_crs())
back = utm.to_crs("EPSG:4326")
# CRS identity is a separate assertion from geometry identity
assert back.crs.equals(parcels.crs)
# ~1e-9 degrees is well under a millimetre; exact equality would be flaky
assert back.geometry.geom_equals_exact(parcels.geometry, tolerance=1e-9).all()
# And the whole-frame helper, which also checks dtypes, index and CRS
assert_geodataframe_equal(back, parcels, check_less_precise=True)
A missing datum grid is a silent numeric change, not an error. If a transformation needs an NTv2 or NADCON grid that PROJ cannot find, it falls back to a lower-accuracy ballpark transformation and returns coordinates that are wrong by a metre or two — plenty to move a point across a parcel boundary, and invisible in every visual check. Guard it with a test that transforms a known control point and asserts the expected result to the precision you require, so a container rebuild that dropped the grid files fails in continuous integration rather than in a report. The mechanics of grid resolution are covered in datum shifts and grid files in PyProj.
Two habits round this out. Build fixtures from WKT strings inside the test module rather than committing shapefiles: the test then documents its own geometry, runs without I/O, and cannot drift from a .prj you forgot to update. And assert on invariants rather than on exact outputs wherever you can — feature counts preserved through a join, total area conserved through a dissolve of non-overlapping parts, every output geometry valid, no CRS lost. Invariants survive a GEOS upgrade; golden numbers do not.
Common Mistakes
- Ignoring axis order. Transforming without
always_xy=Truesilently swaps latitude and longitude and lands your data in the wrong hemisphere. - Measuring in degrees. Calling
.areaor.lengthon an EPSG:4326 layer returns square degrees and degree-lengths — meaningless numbers. Reproject to a projected CRS first. - Using Web Mercator for metric analysis. EPSG:3857 is for tiles, not measurement; its area distortion grows with latitude.
- Skipping geometry validation before joins and overlays. Invalid inputs do not crash — they corrupt output. Run
make_validbefore any overlay. - Mixing conda and pip for the native stack. Two GDAL/PROJ/GEOS builds in one environment cause segfaults and
proj.dberrors. One channel per environment. - Loading whole rasters into RAM. Read windows; a full-array read of a large orthophoto is an out-of-memory error waiting to happen.
- Re-parsing CRS objects inside loops. Build the
Transformer(or CRS object) once and reuse it; construction is expensive. - Using deprecated
+init=epsg:syntax. PROJ 6+ rejects it; use"EPSG:xxxx"authority codes or WKT2. - Trusting a declared CRS. A
.prjfile records what the producer believed, not where the coordinates are. If a layer plots in the Gulf of Guinea, the declaration is wrong, not the data — relabel withset_crs(..., allow_override=True)after establishing the truth, never withto_crs. - Copying a container's site-packages without
proj.db. The import succeeds and the transformations degrade to ballpark accuracy, so nothing raises and every coordinate is off by a metre or two. - Asserting exact geometry equality in tests. Floating-point round trips and GEOS vertex ordering both break
==; compare withgeom_equals_exactand an explicit tolerance instead.
Frequently Asked Questions
Which library should I reach for first — GeoPandas or Shapely? Use GeoPandas whenever your data is tabular (a layer of features with attributes) — it gives you vectorized operations, I/O, and CRS handling for free. Drop to raw Shapely only when you are manipulating one or a handful of standalone geometries with no table around them. The full decision tree is in Shapely vs GeoPandas: when to use each.
How do I get accurate distance and area calculations? Reproject to a projected CRS appropriate for your region — a UTM zone for local extents, or an equal-area projection for continent-scale area work — before measuring. Never measure in EPSG:4326 (degrees) or EPSG:3857 (Web Mercator), and use PyProj to verify the transformation path.
Do I need GDAL's own Python bindings, or is Rasterio enough?
For new projects, prefer Rasterio: it offers a Pythonic, NumPy-native API over the same GDAL engine, with context managers and windowed reads. The trade-offs against the raw osgeo.gdal bindings are laid out in Rasterio vs GDAL Python bindings.
Why does my geospatial import fail with a proj.db or DLL error?
Almost always an environment problem: PROJ cannot find its data directory, or two conflicting GDAL/PROJ builds were installed from different channels. Rebuild the environment from a single channel and confirm pyproj.datadir.get_data_dir() resolves. Windows-specific fixes are in how to install and configure GeoPandas on Windows.
How do I process a dataset that is larger than my RAM? Stream vector features with pyogrio/Fiona or partition with Dask-GeoPandas, and read rasters in windows with Rasterio. Store intermediates as GeoParquet or COG so downstream stages can read only the slices they need. See GeoPandas vs Fiona for large files.
What is the recommended path from raw data to a web map? Validate and reproject with GeoPandas and Shapely, store cloud-native (GeoParquet / COG), export a WGS84 GeoJSON or vector tiles for the client, then render with the tools in web mapping and interactive visualization.
Do I need Rasterio if I am already using rioxarray? Not directly, but keep it installed and keep knowing it, because rioxarray delegates its I/O to Rasterio and every error message you will debug comes from that layer. The practical division is that rioxarray is the right default when the data has a labelled third axis and Rasterio is the right default for single-scene reads, windowed masking, and writing — the two are complementary rather than competing.
How do I decide between an equal-area projection and a UTM zone? By what the number is for and how wide the extent is. UTM preserves shape and local distance well within its own zone and degrades quickly outside it, so it is right for a city, a catchment, or a survey block. An equal-area projection distorts shape but conserves area exactly, which is what you want the moment you are comparing areas across a region wider than a zone — land cover by country, habitat extent by continent. Never resolve the question with Web Mercator, which conserves neither.
Can I mix conda and pip at all in a geospatial environment?
Yes, for pure-Python packages that do not link the native stack — a plotting library, a CLI framework, a client SDK. What you cannot do is install two packages that each bring their own GDAL, PROJ or GEOS. The safe rule is: conda-forge for anything that mentions GDAL, PROJ, GEOS or NumPy's C API in its build, pip for the rest, and pip install --no-deps when you must, so pip cannot quietly pull a wheel that replaces a compiled dependency.
What should I log so a spatial result can be explained six months later? The CRS of every input and of the output, the feature or pixel counts before and after each stage, and the version of the library that produced the geometry. Those three things resolve almost every "why does this number differ from last quarter" question without re-running anything, and they cost a handful of log lines per stage.