Splitting GDAL data into a companion wheel
This page answers one question: proj.db, the datum grids and GDAL’s own data files are architecture-independent yet ship inside every platform wheel, so how do you move them into one companion package without breaking the runtime lookup that finds them? It sits inside the Build Artifact Structuring and Packaging section of the Modern Python Build Tooling & Wheel Configuration reference, and gives you the package layout, the discovery shim and the versioning rule that keeps the two in step.
Context & Root Cause
PROJ’s coordinate database and GDAL’s own data files are pure data: identical bytes on every platform, changing only when the upstream library’s data does. Shipping them inside each platform wheel therefore duplicates them across the matrix — four or five copies per release, downloaded per user, stored per mirror — for no benefit other than that the runtime lookup is trivially satisfied.
The obstacle is that lookup. PROJ finds its database through a compiled-in path, an environment variable, or a directory set through its API, and none of those knows about a second Python package by default. Splitting the data out therefore means taking responsibility for pointing the library at it, which is a few lines of code and one design decision — and doing it wrong produces a DataDirError at import for every user, which is why the split is worth doing deliberately rather than opportunistically.
Solution / Fix
This targets PROJ 9.3.x, GDAL 3.8.x, pyproj 3.6+ and a project already bundling data per bundling proj.db and datum grids in a wheel.
1. Create the data package
geo-data/
pyproject.toml
src/geo_data/__init__.py
src/geo_data/proj/proj.db
src/geo_data/proj/proj.ini
src/geo_data/gdal/ # GDAL's own data files
[project]
name = "geo-data"
version = "9.3.1.1" # tracks the PROJ version it carries
description = "Coordinate reference data for geo-core"
[tool.setuptools.package-data]
geo_data = ["proj/*", "gdal/*"]
2. Expose the directories rather than the files
# src/geo_data/__init__.py
from pathlib import Path
PROJ_DATA = Path(__file__).parent / "proj"
GDAL_DATA = Path(__file__).parent / "gdal"
def proj_data_dir() -> str:
return str(PROJ_DATA)
3. Point the libraries at it from the code package
# geo_core/__init__.py — runs before any transform
import os
import pyproj
import geo_data
pyproj.datadir.set_data_dir(geo_data.proj_data_dir())
os.environ.setdefault("PROJ_DATA", geo_data.proj_data_dir())
os.environ.setdefault("GDAL_DATA", str(geo_data.GDAL_DATA))
from . import _ext # only now is it safe to load the extension
4. Declare the dependency with a compatible range
# geo-core's pyproject.toml
dependencies = ["geo-data>=9.3,<9.4"]
The range mirrors the PROJ minor version the code was built against, which is the compatibility boundary that actually matters — the database schema is versioned against the library that reads it.
Verification
# 1. The platform wheels no longer contain the data
unzip -l dist/geo_core*.whl | grep -c 'proj\.db'
# expected: 0
# 2. The data package is architecture-independent
ls dist/geo_data*.whl
# expected: geo_data-9.3.1.1-py3-none-any.whl
# 3. A clean install finds the database and transforms correctly
docker run --rm -v "$PWD/dist:/d" python:3.12-slim bash -c '
pip install -q --no-index --find-links /d geo-core &&
PROJ_NETWORK=OFF python -c "
import pyproj, geo_core
print(pyproj.datadir.get_data_dir())
print(pyproj.Transformer.from_crs(4326, 3857, always_xy=True).transform(5.0, 52.0))"'
# expected: a path inside site-packages/geo_data, and a coordinate pair
The third check is the one that matters, and the PROJ_NETWORK=OFF is not incidental: with network transforms enabled, a missing database can be papered over by a CDN fetch, and the test would pass while the split was broken for offline users.
Where the Lookup Can Go Wrong
The discovery shim has to run before anything touches PROJ, and there are more ways for that ordering to break than there appear to be.
The second row explains why the shim sets both the API and the environment variable. pyproj.datadir.set_data_dir covers everything reached through pyproj; the environment variable covers code that talks to PROJ’s C API directly, including GDAL when it reprojects. Setting only one leaves a path uncovered, and which path fails depends on what the user’s program does.
The third row is the argument for preferring the API where possible: the environment variable is process-global, so setting it redirects every PROJ consumer in the interpreter, including a package with its own bundled database that now reads yours. setdefault rather than assignment is the compromise — it fills the gap when nothing else has spoken, and does not overrule a package that has.
Versioning the Pair
A split package is two artifacts that have to agree, and the version scheme is what makes the resolver enforce the agreement rather than leaving it to chance.
The middle row is the return on the whole exercise beyond size. Updating datum data — a new grid, a corrected transformation — becomes a release of one architecture-independent wheel rather than a rebuild of the entire platform matrix. For a project that ships data updates more often than code changes, that alone can justify the split.
Pitfalls & Alternatives
Making the data an optional extra. It is tempting, because most users will not think about it, and it produces an install that imports and then fails on the first transform. The data is a hard dependency of a package that cannot function without it.
Splitting the grids but not the database. The database is the mandatory part and the grids are the large optional part, so the useful split is often the reverse of the intuitive one: proj.db in the required data package, optional grid sets as extras or as a second package for users who need offline high accuracy.
Assuming a namespace package is needed. Two ordinary distributions with different top-level names are simpler and avoid a class of install-order problems. Reach for namespace packaging only if you genuinely need the two to share an import path.
Forgetting the data package in a coordinated release. It compiles nothing, so it is easy to leave out of a release checklist — and it is versioned against PROJ, so it belongs in the same batch as the platform wheels whenever PROJ moves.
Frequently Asked Questions
Does this make the first install larger?
No — it makes it the same size, split across two files, and every subsequent platform in the same environment free. A user installing on one machine downloads the same total; a team building four container images downloads the data once per image rather than once per wheel.
What if a user already has PROJ data installed system-wide?
Your shim points the library at yours, which is what you want: a wheel that silently used the system database would produce results depending on their installation rather than on what you shipped. Users who genuinely want the system data can set PROJ_DATA themselves, and setdefault respects that.
Can the data package serve more than one code package?
Yes, and that is one of its advantages: several packages in a project can depend on the same data wheel, and it is installed once. The constraint is that all of them must accept the same version range, which is the coordination the versioning scheme is designed to express.
How large should the data package be?
proj.db alone is around nine megabytes and covers every coordinate reference system. Adding the full grid set takes it past five hundred, which is beyond a typical index’s file limit and defeats the purpose. Regional grid sets as separate optional packages are the workable middle ground.
Does this help with the wheel size limit?
It removes the data from the platform wheels, which for a project that was close to the cap can be enough on its own. For a project well over it, stripping and driver pruning remove more, as handling wheel size limits on PyPI for GDAL sets out.
What happens if the data package is missing at run time?
The import fails with a ModuleNotFoundError naming it, which is a clear message pointing at a dependency that was declared — a much better failure than the DataDirError that comes from data being absent without anything having declared it.
Should the data package depend on the code package?
No — the dependency runs the other way, and reversing it creates a cycle that some resolvers handle poorly. The data package should depend on nothing at all, which is what lets several code packages share it.
Can the split be reversed later?
Yes, and it is disruptive in the same way any dependency change is: users get a package that no longer needs the data wheel while the old one is still installed. Announce it, and keep accepting the data package as a dependency for a release or two.
Related
- Bundling proj.db and datum grids in a wheel — the single-wheel arrangement this one splits.
- Build artifact structuring and packaging — where each kind of payload belongs in an installed package.
- Pinning GDAL and PROJ versions across a wheel set — keeping the data package in step with the code that reads it.