GDAL and GeoPandas Fail Inside Docker: How to Fix the Build
On this page
Problem statement
It works on your laptop. In the container it does not, and the error is never about your code.
At build time, on python:3.12-alpine:
FileNotFoundError: [Errno 2] No such file or directory: 'gdal-config'
error: metadata-generation-failed
Or, asking for the GDAL bindings on python:3.12-slim:
Could not find gdal-config. Make sure you have installed the GDAL native library and development headers.
At import time, after a build that succeeded:
ImportError: libexpat.so.1: cannot open shared object file: No such file or directory
Or the quiet one β everything imports, and a format you need is not there:
from osgeo import ogr
print(ogr.GetDriverByName("Parquet") is not None) # False in the ubuntu-small GDAL image
Every one of these was produced by a real build, reproduced in Example 4. They all 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.12.2
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"
RUN pip install --no-cache-dir numpy setuptools wheel
# match the image's GDAL exactly
RUN GDAL_VERSION=$(gdal-config --version) && \
pip install --no-cache-dir "gdal==${GDAL_VERSION}.*" --no-build-isolation
# build rasterio against the same GDAL instead of installing its bundled copy
RUN pip install --no-cache-dir --no-binary rasterio geopandas rasterio pyproj shapely
Without --no-binary rasterio, this image ends up with two GDALs: measured, osgeo reported 3.12.2 and the rasterio wheel 3.12.4.
| Error | Cause | Fix |
|---|---|---|
No such file or directory: 'gdal-config' |
pip is building a GDAL binding from source and there is no GDAL | use a GDAL base image, or libgdal-dev |
| the same, on Alpine | pyogrio, rasterio and fiona publish no musl wheels | switch to a Debian/Ubuntu base |
libexpat.so.1: cannot open |
the wheel expects a system library the slim image lacks | apt-get install libexpat1 |
libgdal.so.38: cannot open |
a 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 ubuntu-full image |
rasterio and fiona disagree on GDAL |
two GDAL builds loaded | pin one source for all of them |
Which stack, where the libraries come from, each error fixed
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.1.2 GEOS 3.13.1
pyproj 3.8.0 PROJ 9.8.1
rasterio 1.5.1 GDAL 3.12.4
fiona 1.10.1 GDAL 3.9.2 β two GDAL builds
drivers ['CSV', 'DGN', 'DXF', 'ESRI Shapefile', 'FlatGeobuf', 'GML', 'GPKG', 'GPX', 'GeoJSON', 'GeoJSONSeq', 'MapInfo File', 'OGR_GMT', 'OpenFileGDB', 'SQLite']
That is pip install geopandas rasterio fiona pyogrio on python:3.12-slim, with libexpat1 added so the imports work, in September 2026: the current fiona wheel bundles GDAL 3.9.2 and the rasterio wheel 3.12.4. Different versions mean 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 three sources, and trouble comes from having more than one: wheels that bundle their own copies (pip install geopandas on a glibc image needs no system packages and installs several independent GEOS and GDAL builds), system packages that Python packages must then be compiled against, and a GDAL base image with GDAL, GEOS, PROJ and gdal-config already installed as one tested set.
The failure modes map cleanly onto mixing these: two wheels that bundle different GDALs, or a system GDAL 3.12.2 next to a wheel that bundles 3.12.4.
3. Fix "Could not find gdal-config"
pip install gdal has no wheel on PyPI, so it always compiles, and compiling needs GDAL's gdal-config and headers:
# β
on a GDAL base image β headers are already there
FROM ghcr.io/osgeo/gdal:ubuntu-small-3.12.2
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 && /venv/bin/pip install --no-cache-dir numpy setuptools wheel
RUN /venv/bin/pip install --no-cache-dir --no-build-isolation "gdal==$(gdal-config --version).*"
# β
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 numpy setuptools wheel && \
pip install --no-cache-dir --no-build-isolation "gdal==$(gdal-config --version).*"
Both built, and the second gave osgeo 3.10.3 β Debian 13's GDAL, not the newest one. 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 the Alpine build
# β this cannot use a GDAL wheel
FROM python:3.12-alpine
RUN pip install geopandas
Alpine uses musl libc; PyPI's manylinux wheels are built for glibc. shapely and pyproj now also publish musllinux wheels, and on python:3.12-alpine they installed in seconds β shapely 2.1.2 with GEOS 3.13.1. But pyogrio, rasterio and fiona do not: with --only-binary=:all: pip reported No matching distribution found for rasterio, and without it pip falls back to the source distribution and stops at gdal-config. geopandas depends on pyogrio, so it fails the same way.
# β
Debian slim β the wheels install directly
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends libexpat1 \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir geopandas rasterio
If Alpine is genuinely mandated, install GDAL from Alpine's packages and build the GDAL-based bindings against it:
FROM python:3.12-alpine
RUN apk add --no-cache --virtual .build gcc g++ musl-dev gdal-dev \
&& pip install --no-cache-dir geopandas \
&& apk del .build
RUN apk add --no-cache gdal
That trades a wheel install for a compile on every build, and ties you to whichever GDAL Alpine ships. Switching base is almost always cheaper.
5. Fix "libgdal.so.38: 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.12.2 AS build
RUN apt-get update && apt-get install -y --no-install-recommends python3-pip python3-dev build-essential
RUN pip install --break-system-packages --no-binary rasterio --target=/pkgs rasterio
FROM python:3.12-slim
COPY /pkgs /usr/local/lib/python3.12/site-packages # bindings only
Built for real, import rasterio in the second stage raised ImportError: libgdal.so.38: cannot open shared object file. rasterio was compiled against the build image's GDAL, and only the Python package crossed over.
Two correct approaches:
# β
same base for both stages β the libraries are already present
FROM ghcr.io/osgeo/gdal:ubuntu-small-3.12.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.12.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
RUN apt-get update && apt-get install -y --no-install-recommends libexpat1 \
&& rm -rf /var/lib/apt/lists/*
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 β all except the few system libraries a wheel expects the base to provide, which is why libexpat1 appears.
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.38 => not found
6. Fix a missing driver
from osgeo import ogr
print(ogr.GetDriverByName("Parquet") is not None) # False in ubuntu-small
Driver support depends on how GDAL was compiled, not on Python. A minimal build omits formats that need extra libraries β Parquet, netCDF, HDF5 and several others.
# ubuntu-small has the common drivers; ubuntu-full has more
FROM ghcr.io/osgeo/gdal:ubuntu-full-3.12.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)
Measured for GDAL 3.12.2, ubuntu-small had 191 raster and 71 vector drivers without Parquet or netCDF, and ubuntu-full 217 and 85 with both β but at 2,109 MB against 358 MB. Neither includes the proprietary FileGDB driver; the open-source OpenFileGDB driver reads and writes File Geodatabases in both. 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.
A self-checking Dockerfile and a failing container diagnosed
Example 1: a Dockerfile that verifies itself
# syntax=docker/dockerfile:1.7
ARG GDAL_TAG=ubuntu-small-3.12.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 .
# build rasterio and fiona against the image's GDAL instead of their bundled copies
RUN pip install --no-cache-dir --no-binary rasterio,fiona -r requirements.txt
# the OSGB grid the transformation check below needs, into pyproj's data directory
RUN pyproj sync --file uk_os_OSTN15_NTv2_OSGBtoETRS.tif
# 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_997.286) > 0.05 or abs(y - 398_256.533) > 0.05:
problems.append(f"datum grid missing: got ({x:.3f}, {y:.3f}), expected (383997.286, 398256.533)")
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"]
geopandas 1.2.0 GEOS 3.13.1 PROJ 9.8.1 GDAL 3.12.4
β GDAL mismatch: rasterio 3.12.4 vs fiona 3.9.2
β datum grid missing: got (383997.434, 398258.316), expected (383997.286, 398256.533)
That is what the check printed on the first build, before the two lines marked above were added β installing requirements.txt (geopandas, rasterio, fiona) as plain wheels, with no grid. With them, the same build passed and printed geopandas 1.2.0 GEOS 3.13.1 PROJ 9.8.1 GDAL 3.12.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 datum grid β which produces no error, only coordinates 1.8 m out at this point in Manchester, the difference between the OSTN15 grid and the fallback Helmert transformation.
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")
if [ -n "$missing" ]; then echo " $so"; echo "$missing" | sed "s/^/ /"; fi
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="Debian GNU/Linux 13 (trixie)"
ββ python bindings ββββββββββββββββββββββββββββββββββ
ok shapely 2.1.2 GEOS 3.13.1
ok pyproj 3.8.0 PROJ 9.8.1
FAIL rasterio ImportError: libexpat.so.1: cannot open shared object file: No such file or directory
FAIL fiona ImportError: libexpat.so.1: cannot open shared object file: No such file or directory
ok geopandas 1.2.0
ok pyogrio 0.13.0 GDAL (3, 12, 4)
ββ bundled native libraries βββββββββββββββββββββββββ
/usr/local/lib/python3.12/site-packages/fiona.libs/libgdal-fiona-e8f6bdb0.so.35.3.9.2
/usr/local/lib/python3.12/site-packages/fiona.libs/libgeos_c-fiona-3b303efa.so.1.17.2
/usr/local/lib/python3.12/site-packages/pyogrio.libs/libgdal-253d08a6.so.38.3.12.4
/usr/local/lib/python3.12/site-packages/rasterio.libs/libgdal-77dd9dd6.so.38.3.12.4
/usr/local/lib/python3.12/site-packages/rasterio.libs/libgeos_c-0be36666.so.1.20.5
/usr/local/lib/python3.12/site-packages/shapely.libs/libgeos_c-abcdd5fa.so.1.19.2
An excerpt of one run, against python:3.12-slim with pip install geopandas rasterio fiona pyogrio and nothing else. Two findings. rasterio and fiona fail to import for want of libexpat.so.1, which the unresolved-libraries section names file by file, while pyogrio, which bundles it, imports fine. And one plain install left three GDAL libraries and three GEOS libraries on disk β fiona's GDAL 3.9.2 beside two copies of 3.12.4 β which is how a single process ends up with two GDALs.
The find for GDAL and GEOS libraries is the most useful line in the script: one result per library is healthy, several is the root of the strangest symptoms. The pattern matters β wheel copies are renamed libgdal-<hash>.so.<n>, so libgdal.so* alone finds only a system install.
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:2.9.0
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.12.4
- geopandas=1.2.0
- rasterio=1.5.0
- pyproj=3.8.0
- 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 libexpat1 \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir --only-binary=:all: \
geopandas==1.2.0 rasterio==1.5.1 pyproj==3.8.0 shapely==2.1.2
ENV PROJ_NETWORK=OFF
Option A gives one shared native stack, exact pinning across platforms, and full driver support. Built for real, osgeo, rasterio and pyogrio all reported GDAL 3.12.4. The pins are what the solver chose from gdal=3.12: writing rasterio=1.5.1 by hand, the newest on conda-forge, made the environment unsolvable. Let the solver pick, then lock what it picked. It is the right choice when accuracy or format coverage matters.
Option B is simpler and smaller β measured without libexpat1 it built cleanly and then failed at import rasterio β 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.
Example 4 β every error on this page, reproduced
import json
import pathlib
import subprocess
SCENARIOS = {
"alpine: pip install geopandas": ("FROM python:3.12-alpine\nRUN pip install --no-cache-dir geopandas\n", None),
"slim: pip install gdal": ("FROM python:3.12-slim\nRUN pip install --no-cache-dir gdal\n", None),
"slim: wheels only": ("FROM python:3.12-slim\nRUN pip install --no-cache-dir geopandas rasterio fiona pyogrio\n",
"import rasterio"),
"slim: wheels + libexpat1": ("FROM python:3.12-slim\nRUN apt-get update && apt-get install -y "
"--no-install-recommends libexpat1\n"
"RUN pip install --no-cache-dir geopandas rasterio fiona pyogrio\n",
"import rasterio, fiona; print('rasterio GDAL', rasterio.__gdal_version__, "
"'| fiona GDAL', fiona.__gdal_version__)"),
"slim + libgdal-dev: gdal": ("FROM python:3.12-slim\nRUN apt-get update && apt-get install -y "
"--no-install-recommends libgdal-dev gcc g++\n"
"RUN pip install --no-cache-dir numpy setuptools wheel && pip install "
"--no-cache-dir --no-build-isolation \"gdal==$(gdal-config --version).*\"\n",
"from osgeo import gdal; print('osgeo', gdal.__version__)"),
"multi-stage, bindings only": ("FROM ghcr.io/osgeo/gdal:ubuntu-small-3.12.2 AS build\n"
"RUN apt-get update && apt-get install -y --no-install-recommends "
"python3-pip python3-dev build-essential\n"
"RUN pip install --break-system-packages --no-binary rasterio --target=/pkgs rasterio\n"
"FROM python:3.12-slim\n"
"COPY --from=build /pkgs /usr/local/lib/python3.12/site-packages\n",
"import rasterio"),
}
KEYS = ("gdal-config", "Error", "error:", "GDAL")
def build(name, dockerfile):
ctx = pathlib.Path("docker") / name.replace(" ", "_").replace(":", "").replace(",", "")
ctx.mkdir(parents=True, exist_ok=True)
(ctx / "Dockerfile").write_text(dockerfile)
tag = "gis-demo-" + ctx.name.lower().replace("+", "")
out = subprocess.run(["docker", "build", "--progress=plain", "-t", tag, str(ctx)],
capture_output=True, text=True)
return tag, out.returncode, out.stdout + out.stderr
for name, (dockerfile, probe) in SCENARIOS.items():
tag, code, log = build(name, dockerfile)
if code != 0:
lines = [l.split(" ", 2)[-1].strip() for l in log.splitlines() if "gdal-config" in l]
line = next((l for l in lines if l.startswith("Could not find")),
next((l for l in lines if l.startswith("FileNotFoundError")), "?"))
print(f"{name:30} build failed: {line}")
continue
run = subprocess.run(["docker", "run", "--rm", tag, "python", "-c", probe], capture_output=True, text=True)
result = (run.stdout.strip() or run.stderr.strip().splitlines()[-1])
print(f"{name:30} {result}")
for tag in ("ubuntu-small-3.12.2", "ubuntu-full-3.12.2"):
image = f"ghcr.io/osgeo/gdal:{tag}"
subprocess.run(["docker", "pull", "-q", image], capture_output=True)
drivers = subprocess.run(["docker", "run", "--rm", image, "python3", "-c",
"from osgeo import gdal, ogr; print(gdal.GetDriverCount(), ogr.GetDriverCount(), "
"ogr.GetDriverByName('Parquet') is not None, gdal.GetDriverByName('netCDF') is not None, "
"ogr.GetDriverByName('FileGDB') is not None)"], capture_output=True, text=True).stdout.split()
size = json.loads(subprocess.run(["docker", "image", "inspect", image],
capture_output=True, text=True).stdout)[0]["Size"]
print(f"{tag:20} {size / 1e6:6,.0f} MB raster {drivers[0]} vector {drivers[1]} "
f"Parquet {drivers[2]} netCDF {drivers[3]} FileGDB {drivers[4]}")
alpine: pip install geopandas build failed: FileNotFoundError: [Errno 2] No such file or directory: 'gdal-config'
slim: pip install gdal build failed: Could not find gdal-config. Make sure you have installed the GDAL native library and development headers.
slim: wheels only ImportError: libexpat.so.1: cannot open shared object file: No such file or directory
slim: wheels + libexpat1 rasterio GDAL 3.12.4 | fiona GDAL 3.9.2
slim + libgdal-dev: gdal osgeo 3.10.3
multi-stage, bindings only ImportError: libgdal.so.38: cannot open shared object file: No such file or directory
ubuntu-small-3.12.2 358 MB raster 191 vector 71 Parquet False netCDF False FileGDB False
ubuntu-full-3.12.2 2,109 MB raster 217 vector 85 Parquet True netCDF True FileGDB False
Each scenario is a two-line Dockerfile, so any of them can be rebuilt when base images move on. They will: this page's errors were all different a year earlier, when libgdal.so.34 and Debian 12 were current.
Extension modules need their C libraries at runtime
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.38 for GDAL 3.12 β 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-77dd9dd6.so.38.3.12.4) 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.
LD_LIBRARY_PATH and ARM images
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
- 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. shapely and pyproj also publish musl wheels, but pyogrio β which geopandas depends on β does not, so pip compiles it and stops at No such file or directory: 'gdal-config'. Use a Debian-based image.
Why does Could not find gdal-config appear?
pip install gdal has no wheel and always compiles, which needs GDAL's gdal-config and 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.38: 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 Parquet, netCDF and others. Use ghcr.io/osgeo/gdal:ubuntu-full-* if you need them β it is 2,109 MB against 358 MB.
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.