GDAL and GeoPandas Fail Inside Docker: How to Fix the Build
Problem statement
It works on your laptop. In the container it does not, and the error is never about your code.
At build time:
error: subprocess-exited-with-error
Γ Building wheel for fiona (pyproject.toml) did not run successfully.
fatal error: gdal.h: No such file or directory
Or:
ERROR: Failed building wheel for shapely
... fatal error: geos_c.h: No such file or directory
At import time, after a build that appeared to succeed:
ImportError: libgdal.so.34: cannot open shared object file: No such file or directory
Or the strangest one β everything imports, and a format that works locally is missing:
import fiona
print("GPKG" in fiona.supported_drivers) # False
All four come from the same thing: the C libraries and the Python bindings must match, and the container is where that assumption breaks.
Quick answer
Start from an image that already has the native stack, and install Python packages that match it:
FROM ghcr.io/osgeo/gdal:ubuntu-small-3.9.2
RUN apt-get update && apt-get install -y --no-install-recommends \
python3-pip python3-venv \
&& rm -rf /var/lib/apt/lists/*
RUN python3 -m venv /venv
ENV PATH="/venv/bin:$PATH"
# match the image's GDAL exactly
RUN GDAL_VERSION=$(gdal-config --version) && \
pip install --no-cache-dir \
"gdal==${GDAL_VERSION}.*" \
--no-build-isolation
RUN pip install --no-cache-dir geopandas rasterio pyproj shapely
| Error | Cause | Fix |
|---|---|---|
gdal.h: No such file |
no GDAL headers; pip is compiling from source | use a GDAL base image, or libgdal-dev |
geos_c.h: No such file |
Alpine, so no manylinux wheel is usable | switch to a Debian/Ubuntu base |
libgdal.so.34: cannot open |
multi-stage copy left the C library behind | copy the runtime libs, or share the base |
| driver missing at runtime | a minimal GDAL build | use the full gdal:ubuntu-full image |
rasterio and fiona disagree on GDAL |
two GDAL builds loaded | pin one source for all of them |
The single most common cause is Alpine. If your base is python:*-alpine, switch to python:*-slim or a GDAL image and most of this disappears.
Step-by-step solution
1. Establish which stack you actually have
Run this inside the container before changing anything:
RUN gdal-config --version || echo "no system GDAL"
RUN geos-config --version || echo "no system GEOS"
RUN proj || true
# and from Python
import geopandas, shapely, pyproj, rasterio, fiona
from shapely import geos_version_string
print(f"shapely {shapely.__version__:<10} GEOS {geos_version_string}")
print(f"pyproj {pyproj.__version__:<10} PROJ {pyproj.proj_version_str}")
print(f"rasterio {rasterio.__version__:<10} GDAL {rasterio.__gdal_version__}")
print(f"fiona {fiona.__version__:<10} GDAL {fiona.__gdal_version__}")
print(f"drivers {sorted(d for d, m in fiona.supported_drivers.items() if 'w' in m)}")
shapely 2.0.6 GEOS 3.12.1-CAPI-1.18.1
pyproj 3.6.1 PROJ 9.4.0
rasterio 1.3.10 GDAL 3.8.4
fiona 1.9.6 GDAL 3.9.2 β two GDAL builds
drivers ['ESRI Shapefile', 'GeoJSON', 'GPKG', ...]
rasterio and fiona reporting different GDAL versions means two GDAL builds are loaded in one process. It usually works, until it does not: which one wins depends on link order, and format support can differ between them.
2. Understand where the native libraries come from
There are exactly three sources, and trouble comes from having more than one:
Wheels bundle their own. shapely, pyproj, rasterio and fiona publish manylinux wheels with the C libraries inside. pip install geopandas on a glibc image works with no system packages at all β and installs several independent copies of GEOS and GDAL.
System packages. apt-get install libgdal-dev python3-gdal provides one shared build. Python packages compiled against it link to it, so everything agrees β but you must build them from source, since PyPI wheels ignore the system libraries.
A GDAL base image. GDAL, GEOS and PROJ are already installed as one tested set, and gdal-config is available so packages can build against them.
The failure modes map cleanly onto mixing these: a wheel-installed rasterio next to a source-built fiona, or a system GDAL 3.9 with a wheel that bundles 3.8.
3. Fix "gdal.h: No such file or directory"
pip install gdal has no wheel on PyPI, so it always compiles, and compiling needs headers:
# β
on a GDAL base image β headers are already there
FROM ghcr.io/osgeo/gdal:ubuntu-small-3.9.2
RUN apt-get update && apt-get install -y --no-install-recommends python3-pip \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir "gdal==$(gdal-config --version).*" --no-build-isolation
# β
on a plain Debian base β install the dev package
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
libgdal-dev gcc g++ \
&& rm -rf /var/lib/apt/lists/*
ENV CPLUS_INCLUDE_PATH=/usr/include/gdal \
C_INCLUDE_PATH=/usr/include/gdal
RUN pip install --no-cache-dir "gdal==$(gdal-config --version).*"
Two details. The Python gdal version must match the system GDAL, which is what $(gdal-config --version) supplies β a mismatch produces an extension module linking against a library with a different ABI, and it fails at import rather than at build. And --no-build-isolation lets the build see the already-installed numpy, without which the GDAL bindings build without NumPy array support and ReadAsArray fails at runtime.
Most projects do not need the gdal Python package at all. geopandas uses pyogrio or fiona, and rasterio has its own bindings β all available as wheels. Install gdal only if you use osgeo.gdal or osgeo.ogr directly.
4. Fix "geos_c.h: No such file" β the Alpine problem
# β this cannot use a wheel
FROM python:3.12-alpine
RUN pip install geopandas
Alpine uses musl libc; PyPI's manylinux wheels are built for glibc. So pip finds no compatible wheel, falls back to the source distribution, and needs GEOS headers plus a C++ toolchain that Alpine does not have.
# β
Debian slim β the wheels install directly
FROM python:3.12-slim
RUN pip install --no-cache-dir geopandas rasterio
If Alpine is genuinely mandated β a policy, a base-image requirement β it is possible and expensive:
FROM python:3.12-alpine
RUN apk add --no-cache --virtual .build gcc g++ musl-dev geos-dev proj-dev gdal-dev \
&& pip install --no-cache-dir shapely pyproj \
&& apk del .build
RUN apk add --no-cache geos proj gdal
That takes fifteen to twenty minutes to build, produces an image no smaller than the Debian one, and breaks whenever Alpine's GEOS moves ahead of what shapely supports. Every version of this is worse than switching base.
5. Fix "libgdal.so.34: cannot open shared object file"
This one appears after a multi-stage build that copied Python packages but not the C libraries they link against:
# β the .so files stay behind in the build stage
FROM ghcr.io/osgeo/gdal:ubuntu-small-3.9.2 AS build
RUN pip install --target=/pkgs rasterio
FROM python:3.12-slim
COPY /pkgs /usr/lib/python3/dist-packages # bindings only
Two correct approaches:
# β
same base for both stages β the libraries are already present
FROM ghcr.io/osgeo/gdal:ubuntu-small-3.9.2 AS build
RUN python3 -m venv /venv && /venv/bin/pip install --no-cache-dir -r requirements.txt
FROM ghcr.io/osgeo/gdal:ubuntu-small-3.9.2
COPY /venv /venv
ENV PATH="/venv/bin:$PATH"
# β
or use only wheel-provided libraries, which travel inside the packages
FROM python:3.12-slim AS build
RUN python3 -m venv /venv && /venv/bin/pip install --no-cache-dir geopandas rasterio
FROM python:3.12-slim
COPY /venv /venv
ENV PATH="/venv/bin:$PATH"
The second works because manylinux wheels ship their .so files inside the package directory (rasterio.libs/, shapely.libs/), so copying the virtualenv copies the libraries too.
Diagnose it with ldd:
docker run --rm gis:latest bash -c \
'ldd /venv/lib/python3*/site-packages/rasterio/_base*.so | grep "not found"'
libgdal.so.34 => not found
6. Fix a missing driver
import fiona
print("FileGDB" in fiona.supported_drivers) # False
Driver support depends on how GDAL was compiled, not on Python. A minimal build omits formats that need extra libraries β FileGDB, NetCDF, HDF5, Oracle, MSSQL, and several others.
# ubuntu-small has the common drivers; ubuntu-full has the rest
FROM ghcr.io/osgeo/gdal:ubuntu-full-3.9.2
Check before you assume it is a code problem:
from osgeo import gdal, ogr
print(f"raster drivers: {gdal.GetDriverCount()}")
print(f"vector drivers: {ogr.GetDriverCount()}")
print("GPKG:", ogr.GetDriverByName("GPKG") is not None)
print("Parquet:", ogr.GetDriverByName("Parquet") is not None)
ubuntu-full is roughly 400 MB larger. Use it when you need the formats and ubuntu-small when you do not β see how to build a small, fast Python GIS Docker image.
Code examples
Example 1: a Dockerfile that verifies itself
# syntax=docker/dockerfile:1.7
ARG GDAL_TAG=ubuntu-small-3.9.2
FROM ghcr.io/osgeo/gdal:${GDAL_TAG} AS build
ENV DEBIAN_FRONTEND=noninteractive PIP_NO_CACHE_DIR=1
RUN apt-get update && apt-get install -y --no-install-recommends \
python3-pip python3-venv python3-dev build-essential \
&& rm -rf /var/lib/apt/lists/*
RUN python3 -m venv /venv
ENV PATH="/venv/bin:$PATH"
# numpy first, so anything building against it can see it
RUN pip install --no-cache-dir "numpy>=1.26,<3"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# fail the build here, not in production
RUN python - <<'PY'
import sys
import geopandas, shapely, pyproj, rasterio, fiona
from shapely import geos_version_string
problems = []
if rasterio.__gdal_version__ != fiona.__gdal_version__:
problems.append(f"GDAL mismatch: rasterio {rasterio.__gdal_version__} "
f"vs fiona {fiona.__gdal_version__}")
need_write = {"GPKG", "GeoJSON", "ESRI Shapefile"}
missing = {d for d in need_write
if "w" not in fiona.supported_drivers.get(d, "")}
if missing:
problems.append(f"drivers not writable: {sorted(missing)}")
from pyproj import Transformer
x, y = Transformer.from_crs(4326, 27700, always_xy=True).transform(-2.2426, 53.4808)
if abs(x - 383_618.507) > 0.05:
problems.append(f"datum grid missing: got x={x:.3f}, expected 383618.507")
print(f"geopandas {geopandas.__version__} GEOS {geos_version_string.split('-')[0]} "
f"PROJ {pyproj.proj_version_str} GDAL {rasterio.__gdal_version__}")
for p in problems:
print(f" β {p}", file=sys.stderr)
sys.exit(1 if problems else 0)
PY
# βββ runtime ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
FROM ghcr.io/osgeo/gdal:${GDAL_TAG}
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends python3 proj-data \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --uid 10001 app
COPY /venv /venv
ENV PATH="/venv/bin:$PATH" PROJ_NETWORK=OFF PYTHONUNBUFFERED=1
WORKDIR /app
COPY src/ ./src/
USER app
ENTRYPOINT ["python", "-m", "src.pipeline"]
=> ERROR [build 7/7] RUN python - <<'PY'
β GDAL mismatch: rasterio 3.8.4 vs fiona 3.9.2
The verification step is the point. It runs during the build, so a broken native stack fails there rather than at 3 a.m. in production. The transformation check catches a missing proj-data β which produces no error, only silently less accurate coordinates.
Installing numpy before everything else matters for any package that builds from source: without it, the build creates an extension without NumPy support and ReadAsArray fails at runtime with an unhelpful error.
Both stages use the same base image, which is what guarantees the shared libraries the copied virtualenv links against are present.
Example 2: diagnosing a container that already fails
#!/usr/bin/env bash
# diagnose-gis-image.sh IMAGE
set -uo pipefail
IMAGE="${1:?usage: $0 IMAGE}"
run() { docker run --rm --entrypoint bash "$IMAGE" -c "$1" 2>&1; }
echo "ββ base βββββββββββββββββββββββββββββββββββββββββββββ"
run 'cat /etc/os-release | grep PRETTY_NAME'
run 'ldd --version 2>&1 | head -1 || echo "musl libc (Alpine) β this is the problem"'
echo "ββ system native stack ββββββββββββββββββββββββββββββ"
run 'gdal-config --version 2>/dev/null || echo "no system GDAL"'
run 'geos-config --version 2>/dev/null || echo "no system GEOS"'
echo "ββ python bindings ββββββββββββββββββββββββββββββββββ"
run 'python3 -c "
import importlib
for name in [\"shapely\",\"pyproj\",\"rasterio\",\"fiona\",\"geopandas\",\"pyogrio\"]:
try:
m = importlib.import_module(name)
extra = \"\"
if name == \"shapely\":
from shapely import geos_version_string as g; extra = f\" GEOS {g}\"
if name == \"pyproj\": extra = f\" PROJ {m.proj_version_str}\"
if name in (\"rasterio\",\"fiona\",\"pyogrio\"):
extra = f\" GDAL {getattr(m, \"__gdal_version__\", \"?\")}\"
print(f\" ok {name:<10} {m.__version__}{extra}\")
except Exception as e:
print(f\" FAIL {name:<10} {type(e).__name__}: {e}\")
"'
echo "ββ unresolved shared libraries ββββββββββββββββββββββ"
run 'for so in $(find / -name "*.so" -path "*site-packages*" 2>/dev/null | head -60); do
missing=$(ldd "$so" 2>/dev/null | grep "not found")
[ -n "$missing" ] && echo " $so"; echo "$missing" | sed "s/^/ /"
done | head -30 || echo " none"'
echo "ββ bundled native libraries βββββββββββββββββββββββββ"
run 'find / -name "libgdal.so*" -o -name "libgeos_c.so*" 2>/dev/null | sort'
echo "ββ writable drivers βββββββββββββββββββββββββββββββββ"
run 'python3 -c "
import fiona
w = sorted(d for d,m in fiona.supported_drivers.items() if \"w\" in m)
print(f\" {len(w)} writable: {w[:12]}\")
" 2>&1 | head -3'
ββ base βββββββββββββββββββββββββββββββββββββββββββββ
PRETTY_NAME="Alpine Linux v3.20"
musl libc (Alpine) β this is the problem
ββ bundled native libraries βββββββββββββββββββββββββ
/usr/lib/libgdal.so.34
/venv/lib/python3.12/site-packages/rasterio.libs/libgdal-8c2e0a0d.so.33
Two findings in one run. Alpine explains any build failure, and two libgdal files at different versions explain any runtime inconsistency β the system's 34 and a wheel-bundled 33, with link order deciding which one a given package uses.
The find for libgdal.so* is the most useful line in the script: one result is healthy, two or more is the root of the strangest symptoms.
Example 3: pinning the whole stack to one source
The durable fix is to make one thing responsible for the native libraries:
# ββ option A: everything from conda-forge ββββββββββββββββββββββββββββββββββ
FROM mambaorg/micromamba:1.5.8
USER root
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
USER $MAMBA_USER
COPY env.lock.yml /tmp/env.yml
RUN micromamba install -y -n base -f /tmp/env.yml && micromamba clean --all --yes
ARG MAMBA_DOCKERFILE_ACTIVATE=1
ENV PROJ_NETWORK=OFF
WORKDIR /app
COPY src/ ./src/
ENTRYPOINT ["/usr/local/bin/_entrypoint.sh", "python", "-m", "src.pipeline"]
# env.yml β one solver decides GDAL, GEOS, PROJ and the Python packages together
name: base
channels: [conda-forge]
dependencies:
- python=3.12
- gdal=3.9.2
- geopandas=1.0.1
- rasterio=1.3.10
- pyproj=3.6.1
- proj-data
# ββ option B: everything from wheels, no system GIS libraries at all βββββββ
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir --only-binary=:all: \
geopandas==1.0.1 rasterio==1.3.10 pyproj==3.6.1 shapely==2.0.6
ENV PROJ_NETWORK=OFF
Option A gives one shared native stack, exact pinning across platforms, and full driver support. It is the right choice when accuracy or format coverage matters.
Option B is simpler and smaller, and --only-binary=:all: is the important flag: pip fails rather than silently falling back to a source build, so a missing wheel is a loud error instead of a twenty-minute compile that may not work. The cost is several bundled copies of GEOS and GDAL, and reduced driver support, since wheel-bundled GDAL builds are minimal.
What both avoid is the mixed case β some libraries from the system, some from wheels β which is where every error in this article lives.
Explanation
Every failure here happens at one seam: Python extension modules are compiled against a specific C library, and both have to be present and compatible at runtime.
A package like rasterio is a Python wrapper around a compiled extension (_base.cpython-312-x86_64-linux-gnu.so) that links against libgdal.so. That link records a soname β libgdal.so.34 β which encodes the ABI version. At import, the dynamic linker looks for a file with that exact soname. If it is absent, you get cannot open shared object file. If a different major version is present, the linker will not substitute it, because the ABI differs.
Wheels solve this by bundling. A manylinux wheel contains its own libgdal.so with a mangled name (libgdal-8c2e0a0d.so.33) inside rasterio.libs/, and the extension's RPATH points there. That is why pip install rasterio works on a bare python:slim image with no system GDAL. It also means several packages can each carry their own copy, which is wasteful and mostly harmless β until two of them are loaded into one process and something depends on which resolves first.
Alpine breaks this because manylinux means glibc. The manylinux standard specifies a glibc baseline; musl is a different libc with a different ABI. pip correctly concludes that no wheel is compatible and falls back to a source build, which needs headers and a toolchain. The failure is not a bug in pip or in shapely β it is the packaging ecosystem correctly refusing to install something that would not run.
Multi-stage builds break it by moving only half the pair. Copying Python packages between stages moves the extension modules; it does not move the system libraries they link against unless those libraries are inside the copied tree. Sharing a base image between stages works because the runtime stage already has them. Copying a whole virtualenv of wheel-installed packages works because the bundled .so files are inside it. Copying site-packages from a GDAL image into a plain Python image satisfies neither condition.
Driver availability is a compile-time property, which surprises people because it looks like a runtime one. GDAL supports FileGDB or NetCDF only if it was built with those libraries present. No Python package can add a driver to an already-compiled GDAL. So a container missing a format needs a different GDAL build, not a different pip install β which is why ubuntu-small and ubuntu-full exist as separate images.
The general lesson is that a container makes implicit environment assumptions explicit, painfully. On a laptop, GDAL arrived through Homebrew or conda years ago and everything found it. A container starts from nothing, so every assumption has to be stated. That is the cost, and the benefit is the same thing: once stated, it is reproducible β the property discussed in reproducible GIS environments explained.
Edge cases or notes
- Alpine cannot use manylinux wheels. Use
python:*-slimor a GDAL base image; there is no good workaround. pip install gdalnever has a wheel and always compiles. Match$(gdal-config --version)exactly.--no-build-isolationis needed when building GDAL bindings against an already-installed NumPy, or array support is missing.rasterio.__gdal_version__ != fiona.__gdal_version__means two GDAL builds; which is used depends on link order.find / -name "libgdal.so*"returning more than one result is the clearest diagnostic there is.ldd <extension>.so | grep "not found"names the missing library directly.- Driver support is compiled in.
ubuntu-smallomits FileGDB, NetCDF and others;ubuntu-fullincludes them. --only-binary=:all:makes pip fail instead of silently compiling, which is what you want in a Dockerfile.LD_LIBRARY_PATHcan paper over a mismatch and hides the real problem. Fix the base instead.- ARM builds need a base that publishes an arm64 tag; not all GDAL images do.
Internal links
- How to build a small, fast Python GIS Docker image β once it builds, making it lean
- How to containerise a Python GIS pipeline with Docker β the wider practice
- Reproducible GIS environments explained β pinning the native stack
- How to set up a Python GIS environment that actually works β the same problem outside a container
- GeoPandas installation fails: how to fix common errors β the non-container version
- Fiona ImportError when using GeoPandas β the same seam, on a laptop
- What GDAL and OGR actually are β what is being linked against
- GIS tests pass locally but fail in CI β the same mismatch, in a runner
FAQ
Why does pip install geopandas fail on Alpine?
Alpine uses musl libc and PyPI's wheels target glibc, so pip finds no compatible wheel and compiles from source β which needs GEOS and GDAL headers and a toolchain. Use a Debian-based image.
Why does gdal.h: No such file or directory appear?
pip install gdal has no wheel and always compiles, which needs GDAL's headers. Use a GDAL base image, or install libgdal-dev, and match the Python package version to gdal-config --version.
Why does the import fail with libgdal.so.34: cannot open shared object file?
A multi-stage build copied the Python extension modules but not the C library they link against. Use the same base image for both stages, or copy a whole virtualenv of wheel-installed packages.
Why is a driver missing in the container but present locally?
Driver support is compiled into GDAL. A minimal build omits FileGDB, NetCDF and others. Use ghcr.io/osgeo/gdal:ubuntu-full-* if you need them.
How do I know if two GDAL versions are installed?
find / -name "libgdal.so*" inside the container. More than one result, or rasterio.__gdal_version__ differing from fiona.__gdal_version__, confirms it.
Should I install GDAL from apt or from pip?
Pick one and be consistent. A GDAL base image plus wheels for everything else is the usual answer; conda-forge for the whole stack is the strictest.
Can I make the build fail early when the stack is broken?
Yes β add a verification step to the Dockerfile that imports everything, compares GDAL versions, checks the drivers you need, and asserts a known coordinate transformation. Example 1 does all four.