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 section 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.
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.
How Much Data to Ship
proj.db is about nine megabytes and is not optional — without it PROJ cannot resolve an EPSG code at all. The grids are a different question, and it is the question that decides whether your wheel is 30 MB or 300 MB.
Datum-shift grids are correction surfaces used when transforming between reference frames that are not related by a simple parameter set: NAD27 to NAD83 in North America, OSGB36 to ETRS89 in Britain, vertical datum shifts almost everywhere. Without the relevant grid, PROJ does not fail — it falls back to a lower-accuracy transformation path, typically a seven-parameter Helmert, and returns an answer that is wrong by a metre or several. For a web map that is invisible. For a cadastral survey it is a defect.
For most packages the second row is the right answer: proj.db plus the grids for the regions your users actually work in, chosen deliberately and documented. The fourth row — PROJ_NETWORK=ON, where PROJ fetches grids from a CDN and caches them — is attractive and has a sharp edge: a transform that silently changes behaviour depending on whether the machine has internet access is very hard to debug, and in a CI environment it makes results non-reproducible. If you enable it, make it opt-in rather than the default, and never enable it in tests.
Whatever you ship, expose it. A one-line helper that reports the data directory and whether a given grid is present converts a class of “my coordinates are slightly wrong” reports into a single command:
def proj_data_report():
import pyproj
from pathlib import Path
d = Path(pyproj.datadir.get_data_dir())
grids = sorted(p.name for p in d.glob("*.tif"))
return {"data_dir": str(d), "proj_db": (d / "proj.db").exists(),
"grid_count": len(grids), "grids": grids[:10],
"proj_version": pyproj.proj_version_str}
Where PROJ Looks for Its Data
PROJ resolves its data directory through an ordered search, and knowing the order explains both why the error happens and why setting a global environment variable is the wrong remedy.
Note the difference between steps one and two carefully: setting PROJ_DATA fixes your package and simultaneously redirects any other PROJ-linked library in the same interpreter — including one that bundled a different PROJ version and needs its own database.
Keeping the Database and the Library in Step
proj.db is versioned against the PROJ library that reads it, and a mismatch is one of the more confusing failures in this area because it can be partial. PROJ checks the database schema version at open time and will refuse a database that is too new; a database that is too old may open and then lack coordinate operations the library expects, producing CRSError for particular EPSG codes while everything else works.
The rule that avoids it is to take proj.db from the same build as the libproj you bundle, in the same step, rather than from a package installed separately. In practice that means copying from $(pkg-config --variable=pkgdatadir proj) during the build — as the earlier step does — and never from a system PROJ or a downloaded release archive that happens to be to hand.
Two operational habits reinforce it. Record the PROJ version and the database’s own schema version alongside the wheel, so a mismatch shows up in a diff rather than in a user’s traceback. And add a functional assertion to the validation gate that exercises a grid-based transform, not just a Helmert one — the failure mode of a stale database is that ordinary transforms keep working while specific datum pipelines stop resolving.
# Fails if the bundled database cannot serve a grid-based transformation
python - <<'PY'
import pyproj
t = pyproj.Transformer.from_crs("EPSG:4277", "EPSG:4258", always_xy=True) # OSGB36 → ETRS89
x, y = t.transform(-1.5, 53.8)
assert abs(x + 1.4988) < 0.01 and abs(y - 53.8004) < 0.01, (x, y)
print("grid-based transform ok:", pyproj.proj_version_str)
PY
Frequently Asked Questions
Why does the error appear at import rather than at first use?
Because the binding initialises a PROJ context eagerly, and building that context opens the database. That is convenient for diagnosis — the failure arrives immediately rather than in the middle of a pipeline — but it means the traceback points at the import line and looks like a linking problem when it is a data problem.
Can the data live outside the package directory?
It can, and it makes the package fragile. Anything outside the installed package is not guaranteed to be present after installation, is not covered by the wheel’s record of files, and will not be removed on uninstall. Keeping the data inside the package is what makes the path computable from the module’s own location.
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.
Related
- Build Artifact Structuring and Packaging — the parent guide on wheel layout,
package_data, and stagingdist/. - Why vendoring PROJ causes wheel bloat — the size trade-off that governs how much grid data to ship.
- Configuring pixi environments for wheel building — where
GDAL_DATA/PROJ_LIBcome from during the build itself.