Fixing CMake find_package for PROJ
When a geospatial wheel build dies with CMake Error: Could not find a package configuration file provided by "PROJ", the problem is almost never a missing PROJ install — it is that the isolated build environment never told CMake where PROJConfig.cmake lives. This page maps each exact find_package(PROJ) failure to its fix and the pyproject.toml change that makes resolution deterministic across CI runners. It sits under Integrating CMake with scikit-build-core, part of the broader Modern Python Build Tooling & Wheel Configuration reference.
Context & Root Cause
The core failure vector is path leakage. The scikit-build-core backend runs CMake inside a hermetic, throwaway build directory and a fresh isolated virtualenv, deliberately stripping host environment variables so builds are reproducible. PROJ ships a modern CMake package config (PROJConfig.cmake) rather than relying solely on the legacy FindPROJ.cmake module, so find_package(PROJ) must locate that config file through CMAKE_PREFIX_PATH or PROJ_DIR. When build isolation discards those variables, CMake silently falls back to module mode, fails to find PROJ_LIBRARY/PROJ_INCLUDE_DIR, and aborts.
The same breakage appears under cross-compilation sysroots, where the host prefix does not match the target, and under strict Conda or pixi environment activation, where $CONDA_PREFIX is the only place PROJConfig.cmake exists but is never forwarded into the generator context. The fix is always to declare the prefix explicitly rather than hope it survives isolation.
Solution / Fix
1. Match the exact error signature
Start from the verbatim CMake log line and follow it to the remediation. These signatures (PROJ 9.x, CMake ≥3.25, scikit-build-core ≥0.9) match real CI failures:
| Error Signature | Root Cause | Immediate Fix |
|---|---|---|
CMake Error: Could not find a package configuration file provided by "PROJ" |
PROJ_DIR/CMAKE_PREFIX_PATH not propagated into the isolated CMake generator context |
Pass prefix paths via cmake.define or cmake.args in pyproject.toml |
Could NOT find PROJ (missing: PROJ_LIBRARY PROJ_INCLUDE_DIR) |
Fell back to legacy FindPROJ.cmake module mode; pkg-config unavailable or sysroot mismatched |
Force CONFIG mode and verify PROJConfig.cmake exists under $PREFIX/lib/cmake/proj |
Could not find a configuration file for package "PROJ" that is compatible with requested version "9.2" |
Version floor in find_package(PROJ 9.2 REQUIRED) exceeds the installed PROJ, or a stale CMakeCache.txt |
Lower the version floor, clear the build cache, and re-pin the runtime PROJ |
PROJ_DIR-NOTFOUND |
Environment variable unset, stripped by the CI sandbox, or overwritten by build isolation | Declare PROJ_DIR as a cmake.define hint so the backend injects it |
2. Diagnose the runner prefix
Before editing pyproject.toml, confirm the runner actually exposes PROJ’s CMake config and resolve a deterministic prefix:
# 1. Verify PROJ pkg-config metadata is registered
pkg-config --modversion proj 2>/dev/null || echo "pkg-config missing or PROJ not registered"
# 2. Locate the CMake package config export
find /opt /usr/local "$CONDA_PREFIX" "$PIXI_PROJECT_ENV" -name "PROJConfig.cmake" 2>/dev/null | head -n 3
# 3. Resolve a single deterministic prefix for CMake
export PROJ_DIR="$(pkg-config --variable=prefix proj 2>/dev/null || echo /usr/local)"
export CMAKE_PREFIX_PATH="${PROJ_DIR}:${CMAKE_PREFIX_PATH}"
On manylinux/manyarm Docker images PROJ is usually staged under /usr/local; the pinned manylinux base images you build in determine that path. Under Conda or pixi the only valid prefix is the environment root, so resolve it explicitly before the build step:
# Conda / pixi explicit prefix resolution
export PROJ_DIR="${CONDA_PREFIX:-${PIXI_PROJECT_ENV:-/usr/local}}"
3. Forward the prefix through pyproject.toml
The durable fix is to make the prefix part of the build configuration so it survives isolation. This [tool.scikit-build] block enforces CONFIG mode and injects PROJ_DIR as a CMake cache variable; the wider pyproject.toml contract is covered in mastering pyproject.toml for spatial wheels:
[build-system]
requires = ["scikit-build-core>=0.9.0", "pybind11>=2.10"]
build-backend = "scikit_build_core.build"
[project]
name = "spatial-extension"
version = "1.0.0"
requires-python = ">=3.9"
[tool.scikit-build]
cmake.version = ">=3.25"
cmake.args = ["-DCMAKE_FIND_PACKAGE_PREFER_CONFIG=ON"]
wheel.packages = ["src/spatial_extension"]
# Define the cmake.define table ONCE. Declaring cmake.define both as an inline
# table and as a [section] is invalid TOML. Override per-runner with:
# pip wheel . --config-settings=cmake.define.PROJ_DIR=/opt/proj
[tool.scikit-build.cmake.define]
PROJ_DIR = "/usr/local"
PROJ_USE_STATIC_LIBS = "OFF"
CMAKE_FIND_DEBUG_MODE = "OFF"
The matching line in CMakeLists.txt must request config mode outright so a stray FindPROJ.cmake on the module path cannot satisfy the build silently:
find_package(PROJ 9.2 CONFIG REQUIRED)
target_link_libraries(spatial_extension PRIVATE PROJ::proj)
Key points:
CMAKE_FIND_PACKAGE_PREFER_CONFIG=ONtells CMake to tryPROJConfig.cmakebefore the legacy module; adding the literalCONFIGkeyword tofind_packagemakes config mode mandatory.PROJ_DIRdefaults to/usr/localbut is overridable per-runner with--config-settings=cmake.define.PROJ_DIR=/path; scikit-build-core also forwardsCMAKE_PREFIX_PATHfrom the environment, so either lever guarantees resolution when CI strips variables.- Never hardcode absolute paths inside
CMakeLists.txt; always consume the injected${PROJ_DIR}or thePROJ::projimported target. Whether you link a system PROJ at all is a separate decision covered in vendoring PROJ and GDAL vs system libraries.
Verification
Confirm the configure step resolved PROJ before you trust the wheel. The expected output anchors each check:
# 1. Confirm find_package resolved a real PROJConfig.cmake (not module fallback)
pip wheel . --no-build-isolation -w dist/ 2>&1 | grep -i "Found PROJ"
# Expected: -- Found PROJ: /usr/local/lib/cmake/proj/PROJConfig.cmake (found version "9.2.1")
# 2. Confirm the built extension links the intended libproj (Linux)
ldd dist/spatial_extension*.so | grep -i proj
# Expected: libproj.so.25 => /usr/local/lib/libproj.so.25
# 3. Confirm the wheel imports and PROJ initialises
pip install --no-index --find-links dist/ spatial-extension
python -c "import spatial_extension; print('import OK')"
# Expected: import OK
On macOS swap step 2 for otool -L dist/spatial_extension*.so | grep -i proj; on Windows use dumpbin /DEPENDENTS dist\spatial_extension*.pyd | findstr /i proj. If step 1 prints nothing, CMake never entered config mode — re-check that PROJConfig.cmake exists at $PROJ_DIR/lib/cmake/proj. For redistributable wheels, follow with auditwheel show dist/*.whl (Linux) or delocate-wheel (macOS) to confirm the libproj dependency is captured.
Pitfalls & Alternatives
- Exporting
PROJ_DIRin a shell step and assuming the build sees it. scikit-build-core launches CMake in an isolated subprocess that does not inherit ad-hoc shell exports the way an in-treesetup.pybuild would. The variable evaporates at the isolation boundary; declare it as acmake.define(or pass--config-settings) so the backend injects it into the cache instead. - Adding
--no-build-isolation“to make it find PROJ.” This appears to work because the build now inherits your activated environment, but it also drops the pinnedbuild-system.requires, so CI silently links against whatever PROJ happens to be on the runner and produces non-reproducible wheels. Keep isolation on and forward the prefix explicitly; reserve--no-build-isolationfor local debugging only. - Bumping the
find_package(PROJ X.Y REQUIRED)floor to silence a version error. Raising the requested version does not install a newer PROJ — it just changes which mismatch you hit, and a staleCMakeCache.txtcan keep reporting the old version anyway. Pin the floor to the PROJ actually present in your base image, then delete the build directory so the cache is rebuilt. GDAL pulls PROJ transitively, so align both as shown in optimizing scikit-build-core for GDAL.
Related
- Integrating CMake with scikit-build-core — the parent reference on how the backend translates
pyproject.tomlinto CMake invocations and cache variables. - Optimizing scikit-build-core for GDAL — the sibling page covering
find_package(GDAL), ABI pinning, andccachefor the heavier GDAL build. - Vendoring PROJ and GDAL vs system libraries — whether the PROJ you resolve here should be bundled into the wheel or left to the host.
- manylinux and manyarm Docker base images — where
PROJConfig.cmakelands inside the container prefixes this page resolves.
Further reading: PROJ’s version-specific CMake export behaviour is documented in the upstream PROJ CMake integration reference, and build-dependency declaration in the PyPA packaging specifications.