How to Set Up Build Caching for C-Extensions
Compiling geospatial C-extensions such as the GDAL, PROJ, Rasterio, or PyGEOS bindings can take 15–40 minutes per CI run, and without a cache every matrix entry recompiles the same object files — this page shows exactly how to wire ccache/sccache, build-directory caching, and a deterministic cache key into a spatial wheel pipeline so unchanged C/C++ sources are never recompiled. It sits under Async Build Execution and Cache Strategies, part of the broader Modern Python Build Tooling & Wheel Configuration reference.
Each cache layer short-circuits the work the layer below it would otherwise repeat:
Context & Root Cause
The recompilation tax comes from build isolation, not from a slow compiler. Modern backends — scikit-build-core, meson-python, and setuptools — run under PEP 517, so pip spins up a fresh temporary environment, installs build dependencies, compiles the extension, and then deletes the directory on success. Nothing about the compilation survives, so the next matrix entry (cp311, a different arch, the same commit on a rerun) starts from zero.
Geospatial packages make this worse: a single pyproj or rasterio build pulls in thousands of GDAL/PROJ/GEOS header lines and heavy C++17/20 templates, so the per-file compile cost is high. The fix is to persist three independent caches across runs — the compiler object cache (ccache/sccache), the backend build directory (_skbuild/, build/, meson-private/), and the system headers/.so files — while keeping the PEP 517 isolation boundary intact so the wheel stays reproducible.
Solution / Fix
Prerequisites: ccache ≥4.6 (or sccache ≥0.7), pip ≥23.1, build ≥1.0, and a pinned manylinux Docker base image so the glibc target never drifts under the cache.
1. Inject a compiler wrapper before any build command
ccache is the manylinux standard; sccache is preferable when you want a shared S3/Redis backend across parallel matrix jobs. Export the wrapper into CC/CXX before pip or build runs:
# Install and configure ccache (Ubuntu/Debian manylinux)
apt-get update && apt-get install -y ccache
export CC="ccache gcc"
export CXX="ccache g++"
export CCACHE_DIR="${HOME}/.ccache"
export CCACHE_MAXSIZE="2G"
export CCACHE_COMPRESS="1"
export CCACHE_COMPRESSLEVEL="9"
# Critical for reproducible spatial wheels: ignore __TIMESTAMP__ variations
export CCACHE_SLOPPINESS="time_macros,locale,include_file_mtime"
On Alpine / musllinux runners, swap gcc/g++ for musl-gcc or the clang equivalents. The CCACHE_SLOPPINESS directive is non-negotiable for spatial packages: without time_macros, the __TIMESTAMP__/__DATE__ macros embedded in some PROJ and GDAL headers change every run and force a 0% hit rate.
2. Restore the caches before the wrapper runs
Cache restoration must happen first, keyed on the source hashes that actually affect compilation — never on a timestamp. Use one actions/cache step for the compiler cache, the pip download cache, and the backend build directory:
- name: Restore ccache and build artifacts
uses: actions/cache@v4
with:
path: |
~/.ccache
~/.cache/pip
${{ github.workspace }}/.build-cache
key: ${{ runner.os }}-geo-ccache-${{ hashFiles('pyproject.toml', 'src/**/*.c', 'src/**/*.cpp', 'src/**/*.pyx') }}
restore-keys: |
${{ runner.os }}-geo-ccache-
The restore-keys fallback gives you a partial hit when only a subset of Cython/C++ files changed, so the cache survives ordinary commits. Caching ~/.cache/pip alongside ~/.ccache also avoids re-downloading build dependencies such as Cython, numpy, or pybind11 on every run.
3. Route compilation through the cache without breaking isolation
A PEP 517 backend ignores host-level CC/CXX unless you pass the launcher explicitly or relax isolation. For scikit-build-core and meson-python, inject the CMake compiler launchers through --config-settings so isolation stays intact:
# pip (PEP 517 compliant): launchers reach CMake through config-settings
pip wheel . \
--config-settings=cmake.define.CMAKE_C_COMPILER_LAUNCHER=ccache \
--config-settings=cmake.define.CMAKE_CXX_COMPILER_LAUNCHER=ccache
You can pin the same two launchers permanently in pyproject.toml so every contributor and CI runner benefits without remembering the flags:
[tool.scikit-build.cmake.define]
CMAKE_C_COMPILER_LAUNCHER = "ccache"
CMAKE_CXX_COMPILER_LAUNCHER = "ccache"
Reserve python -m build --wheel --no-isolation for cases where you have already installed every [build-system] dependency in the runner; it lets the host CC="ccache gcc" reach the compiler directly but trades away the reproducibility guarantee, so prefer the launcher route above for release builds.
4. Tune the system-dependency layer
The slowest cold builds spend their time fetching and rebuilding PROJ/GDAL/GEOS. Bake those into the manylinux base image (or a layer on top of it) and pin the versions, so the .pc files and .so libraries are already present and ccache only has to compile your extension against them:
FROM quay.io/pypa/manylinux_2_28_x86_64:latest
ENV GDAL_VERSION=3.8.4 PROJ_VERSION=9.3.1
RUN yum install -y ccache proj-devel sqlite-devel curl-devel zlib-devel
ENV CCACHE_DIR=/root/.ccache CCACHE_MAXSIZE=2G \
CCACHE_SLOPPINESS=time_macros,include_file_mtime
Verification
Confirm the cache is actually being hit before you trust the speedup. Run these after the second build of the same commit (ccache ≥4.6):
# 1. Inspect the cache statistics
ccache -s
# Expected after run 2: "cacheable calls" with direct + preprocessed
# hits well above 60%, "cache miss" near 0 for unchanged sources.
# 2. Measure the wall-clock delta across two identical builds
time python -m build --wheel
# Expected: 70-85% shorter than the cold build for unchanged C/C++ sources.
# 3. Prove the cache did not break determinism
SOURCE_DATE_EPOCH=1700000000 PYTHONHASHSEED=0 python -m build --wheel
sha256sum dist/*.whl
# Expected: identical hash on a rebuild of the same commit.
If ccache -s still shows a near-zero hit rate on the second run, the cache directory was not restored (check the actions/cache key) or CCACHE_SLOPPINESS is missing time_macros.
Where a Compiler Cache Fits
A compiler cache sits between the build system and the compiler, and it is the only cache in the pipeline whose key is computed from content rather than declared by you. That property is what makes it both safe and easy to enable.
That last sentence is the reason to prefer a compiler cache over a hand-rolled prefix cache wherever both would work. A prefix cache is only as correct as the key you wrote; a compiler cache is correct by construction, and the worst outcome of a bad configuration is a miss.
Making It Survive Across CI Runs
A compiler cache only helps if its store persists between jobs, and that is the part that needs configuration.
For most spatial projects the first option is enough, and the second becomes worthwhile once the store outgrows the provider’s per-entry limit — which a full GDAL build reaches sooner than people expect. Whichever you choose, print the cache statistics at the end of the job: a hit rate that quietly drops to zero is otherwise invisible until someone notices the build got slower.
Pitfalls & Alternatives
- Caching across
manylinuxtags. Reusing one~/.ccachebetween amanylinux_2_17and amanylinux_2_28job poisons it: objects compiled against the newer glibc surface asABI mismatch: expected manylinux_2_17_x86_64, got manylinux_2_28_x86_64after auditwheel repair. Namespace the cache key by platform tag, and weigh the glibc-vs-musl split in manylinux2014 vs musllinux for spatial libs. - Reaching for
--no-build-isolationto “makeCCstick.” It does route compilation throughccache, but a backend that expected an isolated environment now finds host packages and the build stops being reproducible. PassCMAKE_C_COMPILER_LAUNCHERthrough--config-settingsinstead and keep isolation on. - Not invalidating the cache when GDAL/PROJ change. A bumped
GDAL_VERSIONwith a stale object cache links against old headers and fails at runtime withundefined reference to 'proj_create'. FoldGDAL_VERSION/PROJ_VERSIONinto the cache key (or pin them in the image as above) so a system-dependency change forces a clean recompile.
Related
- Async Build Execution and Cache Strategies — the parent topic covering matrix parallelization and DAG execution that this caching layer plugs into.
- Optimizing scikit-build-core for GDAL — where the compiler-launcher flags live alongside the rest of the CMake tuning for spatial builds.
- Configuring pixi environments for wheel building — pinning the build toolchain so cache keys stay stable across runners.