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.

How PROJ prefix hints cross the scikit-build-core isolation boundary A diagram split by a vertical dashed wall labelled scikit-build-core build isolation. On the left sits the host environment card listing the env vars PROJ_DIR, CMAKE_PREFIX_PATH and CONDA_PREFIX. The top path shows those host variables being stripped at the wall, so find_package(PROJ) falls back to module mode and aborts with PROJ_DIR-NOTFOUND. The bottom path shows pyproject.toml declaring cmake.define PROJ_DIR, which lives on the isolated build side and survives, so find_package(PROJ CONFIG) resolves PROJConfig.cmake successfully. Crossing the scikit-build-core isolation boundary why find_package(PROJ) fails — and how cmake.define makes it deterministic scikit-build-core build isolation Host environment PROJ_DIR CMAKE_PREFIX_PATH CONDA_PREFIX env vars stripped find_package(PROJ) falls back to module mode PROJ_DIR-NOTFOUND configure aborts ✗ pyproject.toml cmake.define.PROJ_DIR declared in build config find_package(PROJ CONFIG) resolves PROJConfig.cmake found version "9.2" ✓ read on the build side — injected into the CMake cache, so the prefix 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=ON tells CMake to try PROJConfig.cmake before the legacy module; adding the literal CONFIG keyword to find_package makes config mode mandatory.
  • PROJ_DIR defaults to /usr/local but is overridable per-runner with --config-settings=cmake.define.PROJ_DIR=/path; scikit-build-core also forwards CMAKE_PREFIX_PATH from 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 the PROJ::proj imported 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_DIR in 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-tree setup.py build would. The variable evaporates at the isolation boundary; declare it as a cmake.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 pinned build-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-isolation for 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 stale CMakeCache.txt can 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.

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.