Bundling proj.db and datum grids in a wheel

This page answers one question: your wheel bundles libproj correctly, yet at runtime PyProj raises pyproj.exceptions.DataDirError: Valid PROJ data directory not found, so how do you package proj.db and the datum-shift grids as wheel data and point PROJ at them at import — since auditwheel bundles shared objects but never data files? It sits inside the Build Artifact Structuring and Packaging cluster of the Modern Python Build Tooling & Wheel Configuration reference, and gives you the package_data layout, the runtime path shim, and the size-versus-completeness trade-off.

Why auditwheel bundles libproj but not proj.db, and how the data shim closes the gap The repair tool copies the libproj shared object into the wheel but ignores proj.db and the datum grid files because they are data, not linked libraries. The package therefore ships proj.db as package data and, at import, sets the PROJ data directory to that bundled path so PROJ finds its datum database. auditwheel repair bundles .so only libproj.so → .libs/ ✓ linked library — bundled proj.db → skipped ✗ data file — ignored package_data ship proj/ + set data dir PROJ resolves datum DB found ✓

Context & Root Cause

PROJ splits into two things: libproj, a shared object of transformation code, and a data payload — proj.db, an SQLite database of coordinate reference systems and datum definitions, plus optional .tif grid files for high-accuracy datum shifts. The linked library is what auditwheel and delocate understand; they trace the NEEDED entries of your extension and copy the .so/.dylib into the wheel. They have no notion of data dependencies, so proj.db is silently left behind. At runtime PROJ opens the database by searching a compiled-in path and the PROJ_DATA/PROJ_LIB environment variable — neither of which points inside your wheel — and raises DataDirError.

The fix is orthogonal to library bundling: ship the data as package data and set PROJ’s search directory to the bundled location from Python before any transform runs. This is a packaging concern, which is why it lives under Build Artifact Structuring and Packaging rather than the vendoring guide — the library decision is already made; this is about the files repair tools ignore.

Solution / Fix

This targets PROJ 9.3.x and PyProj 3.6+. The layout ships proj.db inside the package and registers it at import.

1. Stage the data into the package tree

# Copy PROJ's data payload into the importable package before building
mkdir -p src/mypkg/proj_data
cp "$(pkg-config --variable=pkgdatadir proj)"/proj.db src/mypkg/proj_data/
cp "$(pkg-config --variable=pkgdatadir proj)"/proj.ini src/mypkg/proj_data/ 2>/dev/null || true

2. Declare it as package data

# pyproject.toml
[tool.setuptools.package-data]
mypkg = ["proj_data/*.db", "proj_data/*.ini", "proj_data/*.tif"]

[tool.setuptools.packages.find]
where = ["src"]

3. Point PROJ at the bundled data at import

# src/mypkg/__init__.py — runs before any pyproj transform
import os
from pathlib import Path
import pyproj

_data = Path(__file__).with_name("proj_data")
if (_data / "proj.db").exists():
    # pyproj's own resolver — preferred over exporting PROJ_DATA globally
    pyproj.datadir.set_data_dir(str(_data))
    os.environ.setdefault("PROJ_DATA", str(_data))   # covers raw C-API callers

pyproj.datadir.set_data_dir is preferred over exporting PROJ_DATA for the whole process, because a global variable would also redirect any other PROJ-using library in the interpreter.

Verification

# 1. The data file is actually inside the wheel
unzip -l dist/*.whl | grep proj.db
# expected: mypkg/proj_data/proj.db
# 2. A transform that needs the datum DB succeeds in a clean container
docker run --rm -v "$PWD/dist:/d" python:3.12-slim bash -c "
  pip install /d/*.whl &&
  python -c \"import mypkg, pyproj; t=pyproj.Transformer.from_crs(4326,3857); print(t.transform(52.0,5.0))\""
# expected: a coordinate pair, not DataDirError
# 3. Confirm pyproj resolved the bundled directory, not a system one
python -c "import mypkg, pyproj; print(pyproj.datadir.get_data_dir())"
# expected: .../site-packages/mypkg/proj_data

A proj.db inside the wheel, a successful transform in a bare image, and a data-dir path pointing inside the package confirm the bundling. DataDirError in step 2 means the import shim did not run before the transform.

Pitfalls & Alternatives

Relying on PROJ_LIB from the environment. Setting PROJ_LIB in CI makes local tests pass while the shipped wheel still fails for users who never set it. Bundle the data and register it in code.

Bundling the full grid set unconditionally. The optional datum-shift .tif grids run to gigabytes. Ship proj.db (a few MB) always, but treat the high-accuracy grids as opt-in — most users are served by the database alone, and shipping them all bloats the wheel the way a vendored PROJ does in why vendoring PROJ causes wheel bloat. For grids beyond the database, enabling PROJ’s network fetch (PROJ_NETWORK=ON) is the leaner alternative.

Forgetting GDAL’s own data. GDAL has a parallel GDAL_DATA directory (gcs.csv, projection tables). A GDAL-plus-PROJ wheel must bundle and register both, following the identical pattern.