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
An installer, an isolated environment, verified native libraries
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.
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
.venvpath. - 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 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/gdalimages contain GDAL but nopip, 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_DATAvsPROJ_LIB: PROJ 9 renamed it. Some tooling still sets the old name; set both if you are debugging.GDAL_DATAmatters for some drivers and wheels find their own. If you are setting it by hand, you probably have a mixing problem.geopandas.options.io_enginelets you forcepyogrioorfiona(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.
Internal links
- GeoPandas installation fails: how to fix common errors β when this page's advice arrives too late
- GIS tests pass locally but fail in CI β the same problem across two machines
- How to explore a spatial dataset you have never seen before β the next thing to do once this works
- Fiona vs pyogrio: how GeoPandas reads and writes files β which engine to install
- What GDAL and OGR actually are β the layer this page keeps referring to
- How to containerise a Python GIS pipeline with Docker β reproducibility beyond pinning
- How to make a GIS workflow reproducible in Python β pinning as a practice
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.