How to Set Up a Python GIS Environment That Actually Works

On this page

Problem statement

pip install geopandas succeeds. Then the first script fails β€” here, a version check that older tutorials still print, run in a brand-new environment created exactly as they say, and the same script run from an editor that picked the system Python:

old_check = "import geopandas, shapely, pyproj, rasterio, fiona\nprint('ok')\n"
Path("old_check.py").write_text(old_check)
for label, python in (("fresh .venv", VENV_PY), ("system python3", "/usr/bin/python3")):
    run = subprocess.run([python, "old_check.py"], capture_output=True, text=True)
    print(f"{label:15}", (run.stdout or run.stderr).strip().splitlines()[-1])
fresh .venv     ModuleNotFoundError: No module named 'fiona'
system python3  ModuleNotFoundError: No module named 'geopandas'

The packages installed. The script asked for a package that a current install no longer includes, and the editor ran a different interpreter altogether. Other failures come from the stack underneath: a missing driver, an unreadable PROJ database, two copies of a native library fighting.

Python GIS is unusual: geopandas, rasterio and pyogrio are thin Python wrappers over large C and C++ libraries β€” GEOS, PROJ and GDAL. Getting a working environment is mostly about knowing which copy of each library each package uses, and making sure you are running the Python you installed into.

Quick answer

Pick one installer and use it for everything. Mixing is what breaks.

# Option A β€” pip with wheels. Simplest, works for most people.
python -m venv .venv && source .venv/bin/activate
pip install --only-binary=:all: geopandas rasterio matplotlib

# Option B β€” conda-forge. Use when you need a GDAL driver the wheels omit,
# or PostGIS/GRASS/QGIS integration.
conda create -n gis -c conda-forge --strict-channel-priority \
    python=3.12 geopandas rasterio matplotlib
conda activate gis

Then verify β€” do not assume:

Path("verify_env.py").write_text('''import geopandas, shapely, pyproj, rasterio, pyogrio

print("geopandas", geopandas.__version__)
print("shapely  ", shapely.__version__, "/ GEOS", shapely.geos_version_string)
print("pyproj   ", pyproj.__version__, "/ PROJ", pyproj.proj_version_str)
print("pyogrio  ", pyogrio.__version__, "/ GDAL", pyogrio.__gdal_version_string__)
print("rasterio ", rasterio.__version__, "/ GDAL", rasterio.__gdal_version__)
drivers = pyogrio.list_drivers()
print("writes   ", sorted(d for d in ("GPKG", "GeoJSON", "ESRI Shapefile", "FlatGeobuf", "Parquet") if "w" in drivers.get(d, "")))

assert pyproj.CRS.from_epsg(27700).to_epsg() == 27700, "proj.db is missing"
assert "w" in drivers.get("GPKG", ""), "GDAL built without GeoPackage"
print("environment OK")
''')
print(subprocess.run([VENV_PY, "verify_env.py"], capture_output=True, text=True).stdout)
geopandas 1.2.0
shapely   2.2.0 / GEOS 3.14.1
pyproj    3.8.0 / PROJ 9.8.1
pyogrio   0.13.0 / GDAL 3.12.4
rasterio  1.5.2 / GDAL 3.12.2
writes    ['ESRI Shapefile', 'FlatGeobuf', 'GPKG', 'GeoJSON']
environment OK

If that script runs clean, the environment works. If it does not, the line that fails names the problem.

Symptom Cause Fix
No module named 'fiona' GeoPandas 1.x reads with pyogrio; fiona is optional use pyogrio, or pip install fiona if you need it
No module named 'geopandas' from the IDE IDE using a different interpreter point the IDE at .venv/bin/python
a driver missing GDAL built without it use conda-forge for FileGDB, Oracle and similar
CRSError for an EPSG code PROJ looking at the wrong proj.db use the wheels' bundled data; check pyproj.datadir.get_data_dir()
works locally, not in CI different native versions see tests pass locally but fail in CI

The rule that prevents most of it

A single-installer environment contrasted with a mixed pip and conda one. both halves work aloneOne installerβœ“one GDAL, one GEOS, one PROJβœ“proj.db where pyproj expects itβœ“wheels bundle their own nativesβœ“symbols do not collideβœ“reproducible from a lock filepip inside condaβœ•two GDALs on the library pathβœ•PROJ_DATA points at the wrong oneβœ•whichever loads first winsβœ•ImportError, or a segfaultβœ•or worse: wrong answersThe OS loads one copy per process, and picks for you spatialworkflow.io
Both halves work alone. Together they produce two GDALs and one confused interpreter.

An installer, an isolated environment, verified native libraries

Steps to create and verify a Python GIS environment. five steps, and the fourth is the one people skip1choose one installerpip or conda, not both2create an isolated envvenv or conda env3install with --only-binarynever build from source4verify: PROJ, GEOS, GDAL, driversassume nothing5pin it and point the IDE at itsame interpreterA smoke test beats a version number spatialworkflow.io
Five steps. Verifying is the one people skip and then spend an afternoon on.

1. Choose the installer before you install anything

pip with wheels β€” right for most work. The wheels on PyPI bundle their native libraries: Shapely carries GEOS, pyproj carries PROJ and its database, pyogrio and rasterio each carry GDAL. A plain pip install gets a complete stack with no system packages:

python -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
pip install --only-binary=:all: geopandas rasterio

--only-binary=:all: is the important flag. It makes pip fail rather than fall back to building from source, and a source build is where the "no GDAL headers" errors come from. The setup below built this page's environment that way in about eleven seconds.

conda-forge β€” right when you need something the wheels do not carry: FileGDB, Oracle or MSSQL drivers, GRASS, or a specific GDAL version.

conda create -n gis -c conda-forge --strict-channel-priority python=3.12 geopandas rasterio

--strict-channel-priority prevents conda mixing defaults and conda-forge builds.

Never both. pip install rasterio inside a conda environment that already has GDAL from conda adds a wheel with its own GDAL beside conda's, and you no longer know which one a given call uses.

2. Create an isolated environment, always

python -m venv .venv

Not optional. Installing GIS packages into the system Python competes with whatever your OS package manager has installed. Add .venv/ to .gitignore and never commit it β€” this one is over half a gigabyte:

print(f"{sum(f.stat().st_size for f in Path('.venv').rglob('*') if f.is_file()) / 1e6:,.0f} MB in .venv")
555 MB in .venv

3. Install, then verify the native libraries

Four things matter, and one of them is surprising:

check = '''import pyproj, pyogrio, rasterio, shapely
print("PROJ database:", pyproj.CRS.from_epsg(27700).name)
print("GPKG driver:  ", pyogrio.list_drivers()["GPKG"])
print("GEOS:         ", shapely.geos_version_string, "(make_valid needs >= 3.8)")
print("GDAL:          pyogrio", pyogrio.__gdal_version_string__, "| rasterio", rasterio.__gdal_version__)
'''
print(subprocess.run([VENV_PY, "-c", check], capture_output=True, text=True).stdout)
libs = sorted(p.name for p in Path(".venv").rglob("libgdal*.so*"))
print(libs)
PROJ database: OSGB36 / British National Grid
GPKG driver:   raw
GEOS:          3.14.1 (make_valid needs >= 3.8)
GDAL:          pyogrio 3.12.4 | rasterio 3.12.2

['libgdal-253d08a6.so.38.3.12.4', 'libgdal-61394b7a.so.38.3.12.2']

Two different GDAL versions, in one environment, both working: GeoPandas reads files through pyogrio's copy, rasterio through its own. Each wheel ships a privately renamed library β€” the hash in the file name β€” so the two can be loaded into one process without colliding. That is fine as long as you know it: a driver option or a bug fix that exists in one GDAL may not exist in the other, and "which GDAL am I on?" has two answers.

The native libraries in a fresh pip-installed GeoPandas and rasterio environment, and which wheel carries each. one venv, pip wheels onlyversioncarried byused byGEOS3.14.1shapely wheelgeometry operationsPROJ + proj.db9.8.1pyproj wheelto_crs, EPSG lookupsGDAL for vectors3.12.4pyogrio wheelgpd.read_file / to_fileGDAL for rasters3.12.2rasterio wheelrasterio.openTwo GDAL versions in one environment, each privately renamed, both working spatialworkflow.io
What step 3 found inside the fresh environment.

4. Pin what you have, before you need to reproduce it

pip freeze > requirements.txt                              # minimum
uv pip compile --generate-hashes requirements.in -o requirements.txt   # better: hashes, all transitive pins
conda env export --no-builds > environment.yml
frozen = subprocess.run([VENV_PY, "-m", "pip", "freeze"], capture_output=True, text=True).stdout.splitlines()
print(len(frozen), "packages pinned, e.g.", [line for line in frozen if line.split("==")[0] in ("geopandas", "pyogrio", "rasterio")])
21 packages pinned, e.g. ['geopandas==1.2.0', 'pyogrio==0.13.0', 'rasterio==1.5.2']

Pinning Python packages does not name GEOS, PROJ or GDAL, because those arrive inside the wheels. What it does pin is which wheels, and a given wheel version always bundles the same native versions β€” so it is enough for reproducibility in practice. For byte-level reproducibility across machines, use a container β€” see how to containerise a Python GIS pipeline.

5. Point your editor at the same interpreter

The most common "but it works in the terminal" cause, as the problem statement showed: the system python3 has none of these packages.

for label, python in (("the venv", VENV_PY), ("system", "/usr/bin/python3")):
    where = subprocess.run([python, "-c", "import sys; print(sys.executable, sys.version.split()[0])"],
                           capture_output=True, text=True).stdout.strip()
    print(f"{label:9} {where.replace(str(Path.cwd()), '.')}")
the venv  ./.venv/bin/python 3.14.4
system    /usr/bin/python3 3.14.4
  • VS Code: Command Palette β†’ Python: Select Interpreter β†’ the .venv path.
  • PyCharm: Settings β†’ Project β†’ Python Interpreter β†’ Add β†’ Existing environment.
  • Jupyter: install a kernel from inside the environment, or the notebook uses a different Python entirely:
pip install ipykernel
python -m ipykernel install --user --name gis --display-name "Python (gis)"

6. Add the optional pieces deliberately

pip install --only-binary=:all: \
    mapclassify folium contextily                # classification and basemaps
pip install --only-binary=:all: \
    "psycopg[binary]" sqlalchemy geoalchemy2     # PostGIS
pip install --only-binary=:all: fiona            # only if old code needs engine="fiona"

pyogrio is already there: in GeoPandas 1.x it is a required dependency and the default I/O engine β€” see fiona vs pyogrio.

Bootstrap, verification and a whole-stack smoke test

The data used on this page

No data: the setup builds a fresh virtual environment in the working folder with the Python that runs this page, installing GeoPandas, rasterio and Matplotlib from PyPI wheels only, exactly as the quick answer says. Every check then runs in that environment through VENV_PY.

import subprocess
import sys
from pathlib import Path

VENV_PY = str(Path(".venv/bin/python").absolute())
if not Path(VENV_PY).exists():
    subprocess.run([sys.executable, "-m", "venv", ".venv"], check=True)
    subprocess.run([VENV_PY, "-m", "pip", "install", "-q", "--only-binary=:all:",
                    "geopandas", "rasterio", "matplotlib"], check=True)
print(subprocess.run([VENV_PY, "-c", "import sys, geopandas; print('Python', sys.version.split()[0], '| geopandas', geopandas.__version__)"],
                     capture_output=True, text=True).stdout.strip())
Python 3.14.4 | geopandas 1.2.0

Example 1: a project bootstrap script

#!/usr/bin/env bash
# bootstrap.sh β€” one command from clone to working environment
set -euo pipefail

PYTHON=${PYTHON:-python3}

$PYTHON -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip

pip install --only-binary=:all: -r requirements.txt
python tools/verify_env.py

echo "ready β€” activate with: source .venv/bin/activate"

set -euo pipefail means the script stops at the first failure rather than reporting success after a broken install. requirements.txt is the pinned file from step 4.

Example 2: the verification script, worth keeping

Path("tools").mkdir(exist_ok=True)
Path("tools/verify_env.py").write_text('''# tools/verify_env.py β€” run after install, and as the first step in CI
import sys

REQUIRED_DRIVERS = ["GPKG", "GeoJSON", "ESRI Shapefile"]
MIN_GEOS = (3, 11, 0)

def main() -> int:
    problems = []
    try:
        import geopandas, shapely, pyproj, pyogrio
    except ImportError as exc:
        print(f"import failed: {exc}")
        return 1

    print(f"python    {sys.version.split()[0]}")
    print(f"geopandas {geopandas.__version__}")
    print(f"shapely   {shapely.__version__} / GEOS {shapely.geos_version_string}")
    print(f"pyproj    {pyproj.__version__} / PROJ {pyproj.proj_version_str}")
    print(f"pyogrio   {pyogrio.__version__} / GDAL {pyogrio.__gdal_version_string__}")
    print(f"engine    {geopandas.options.io_engine or 'pyogrio (default)'}")

    if shapely.geos_version < MIN_GEOS:
        problems.append(f"GEOS {shapely.geos_version} < {MIN_GEOS}")
    try:
        pyproj.CRS.from_epsg(27700)
    except Exception as exc:
        problems.append(f"proj.db unusable ({exc}); data dir = {pyproj.datadir.get_data_dir()}")
    drivers = pyogrio.list_drivers()
    missing = [d for d in REQUIRED_DRIVERS if "w" not in drivers.get(d, "")]
    if missing:
        problems.append(f"GDAL cannot write: {missing}")

    if problems:
        print("\\nPROBLEMS:")
        for p in problems:
            print(f"  βœ— {p}")
        return 1
    print("\\nenvironment OK")
    return 0

if __name__ == "__main__":
    sys.exit(main())
''')
for python in (VENV_PY, "/usr/bin/python3"):
    run = subprocess.run([python, "tools/verify_env.py"], capture_output=True, text=True)
    print(f"--- {'venv' if python == VENV_PY else 'system python3'}: exit {run.returncode}")
    print(run.stdout.strip())
--- venv: exit 0
python    3.14.4
geopandas 1.2.0
shapely   2.2.0 / GEOS 3.14.1
pyproj    3.8.0 / PROJ 9.8.1
pyogrio   0.13.0 / GDAL 3.12.4
engine    pyogrio (default)

environment OK
--- system python3: exit 1
import failed: No module named 'geopandas'

Keeping this in the repo pays off twice: once when a new person clones it, and again as the first step in CI, where it turns a confusing mid-suite failure into a clear one at the top of the log β€” and a non-zero exit code a CI job can act on.

Example 3: a smoke test that exercises the whole stack

Path("tools/smoke.py").write_text('''# tools/smoke.py β€” does the stack actually do GIS?
import pathlib, tempfile

import geopandas as gpd
from shapely.geometry import Point

gdf = gpd.GeoDataFrame({"id": [1, 2]}, geometry=[Point(-3.19, 55.95), Point(-3.20, 55.94)], crs="EPSG:4326")

projected = gdf.to_crs(27700)                       # PROJ works
assert projected.crs.is_projected
assert 300_000 < projected.geometry.x.iloc[0] < 400_000

buffered = projected.buffer(50)                     # GEOS works
assert buffered.area.iloc[0] > 7000

with tempfile.TemporaryDirectory() as tmp:
    out = pathlib.Path(tmp) / "smoke.gpkg"
    projected.to_file(out, driver="GPKG")           # GDAL write works
    back = gpd.read_file(out)                       # GDAL read works
    assert back.crs == projected.crs
    assert len(back) == 2

print("smoke test passed β€” PROJ, GEOS and GDAL all working;",
      f"Edinburgh at {projected.geometry.x.iloc[0]:,.0f} E, {projected.geometry.y.iloc[0]:,.0f} N")
''')
print(subprocess.run([VENV_PY, "tools/smoke.py"], capture_output=True, text=True).stdout.strip())
smoke test passed β€” PROJ, GEOS and GDAL all working; Edinburgh at 325,785 E, 673,636 N

Version numbers tell you what is installed. This tells you it works.

GEOS, PROJ and GDAL do the real work

The Python GIS stack annotated with what each installer supplies. who provides which layergeopandas Β· rasterio Β· fionapip or condashapely Β· pyprojpip or condaGEOS Β· PROJ Β· GDALbundled in wheelsproj.dbbundled β€” or missingoperating systemhas its own GDALTwo sources for one layer is the whole problem spatialworkflow.io
Wheels bundle the bottom layers; conda installs them as separate packages. Mixing gives you both.

The wheels keep GEOS, PROJ and GDAL out of each other's way by loading private, renamed copies, which is how two GDALs sat side by side in step 3.

Mixing installers undoes it. A conda GDAL, a system GDAL or a source-built package links against a shared, un-renamed libgdal, found through the operating system's search path; when two of those disagree, the package expecting one calls into an ABI it was not compiled against. Sometimes that is a clean ImportError; sometimes it is a segfault; occasionally it appears to work and returns wrong answers.

The proj.db problem is a variation on the same theme. PROJ needs a data directory. The pyproj wheel bundles one and uses it first β€” in this venv, pointing PROJ_DATA at an empty folder changed nothing. A pyproj built against a system PROJ instead looks through PROJ_DATA (formerly PROJ_LIB) and compiled-in paths, and a container that installs PROJ from apt and pyproj from source can end up with the variable pointing at a database from a different PROJ version β€” hence an EPSG code resolving in one environment and not another.

Apple Silicon, Windows, Docker and PROJ_DATA

  • Apple Silicon: use native arm64 wheels or conda-forge osx-arm64. Rosetta-emulated x86 packages work but are slow, and mixing architectures fails at import.
  • Windows: the old advice about Gohlke's unofficial wheels is obsolete β€” PyPI wheels work. Do not mix them with OSGeo4W.
  • Docker: ghcr.io/osgeo/gdal images contain GDAL but no pip, and a pyogrio wheel installed into one brings its own GDAL anyway β€” see reproducible workflows, where the image said 3.12.2 and GeoPandas read with 3.12.4. Pin the wheels, or build pyogrio from source against the image's GDAL on purpose.
  • PROJ_DATA vs PROJ_LIB: PROJ 9 renamed it. Some tooling still sets the old name; set both if you are debugging.
  • GDAL_DATA matters for some drivers and wheels find their own. If you are setting it by hand, you probably have a mixing problem.
  • geopandas.options.io_engine lets you force pyogrio or fiona (if installed) when the two disagree about a file.
  • Fiona 1.10 requires GDAL β‰₯ 3.4. Old distro GDAL packages are a common cause of a failed source build.

FAQ

pip or conda?

pip with --only-binary=:all: for most work β€” the wheels are complete and it is simpler. conda-forge when you need a GDAL driver, a GIS application binding, or a specific GDAL version the wheels do not offer.

Can I mix pip and conda in one environment?

Avoid it for anything that carries native libraries. If you must, install everything native from conda first and use pip only for pure-Python packages.

Why does EPSG:27700 fail when EPSG:4326 works?

Every EPSG lookup needs proj.db; when some codes resolve and others do not, PROJ is usually reading a database from a different PROJ version. Print pyproj.datadir.get_data_dir() and pyproj.proj_version_str, and prefer the wheel's bundled data.

Do I still need GDAL installed system-wide?

No. Modern wheels bundle everything. A system GDAL is only needed for command-line tools like ogr2ogr, and even then it can live outside the Python environment.

Should I install pyogrio?

It is already there: GeoPandas 1.x requires it and uses it as the default engine. Install fiona only for code that asks for engine="fiona".

How do I know which GDAL is being used?

Ask each package: pyogrio.__gdal_version_string__ for what GeoPandas reads with, rasterio.__gdal_version__ for rasters. With wheels they can legitimately differ β€” this page's environment had 3.12.4 and 3.12.2 β€” because each wheel carries a private copy. Two versions only signal trouble when a shared, system or conda GDAL is involved.

What Python version should I use?

One release behind the newest is the safe choice β€” GIS wheels usually lag a few months behind a new Python release.