meson-python vs scikit-build-core for spatial packages

This page answers one question: both backends turn a pyproject.toml into a compiled wheel, so which one fits a package that links GDAL, PROJ and GEOS — and what actually differs once the native stack is involved rather than a handful of source files? It sits inside the Integrating CMake with scikit-build-core section of the Modern Python Build Tooling & Wheel Configuration reference, and gives you the comparison, the two minimal builds side by side, and the cases where the answer is clear.

Where the two backends differ for a GDAL-linked package Six properties compared. Dependency discovery uses CMake config packages in one and Meson's dependency function in the other, and GDAL, PROJ and GEOS all ship CMake config packages. Cross-compilation uses a toolchain file or a cross file respectively. Build speed favours Meson for small projects. Ecosystem familiarity favours CMake for projects that also vendor native libraries. Configuration verbosity favours Meson. And the ability to build an external project as part of the build favours CMake. scikit-build-core (CMake) meson-python (Meson) finds GDAL and PROJ via their own CMake config packages pkg-config, or a wrap cross-compilation via a toolchain file a cross file configure speed slower — many probes faster build file verbosity higher lower, more declarative building a vendored library natural — ExternalProject possible via subprojects what most spatial deps ship CMake config packages pkg-config files, usually also

Context & Root Cause

Both backends do the same job: read pyproject.toml, run a native build system, and assemble the result into a wheel with the right tags. For a package with a few C files and no external dependencies the choice is aesthetic. For a package linking GDAL it is not, because the interesting question becomes how each backend’s build system finds and describes third-party native libraries.

That is where the ecosystems diverge. GDAL, PROJ, GEOS and SQLite all install CMake config packages, which carry not just include and library paths but transitive requirements and compile features. Meson’s dependency() reads pkg-config, which those libraries also install, but pkg-config metadata is flatter — it does not describe imported targets or propagate requirements the same way. Neither is wrong; the practical consequence is that CMake’s discovery for this particular stack tends to require less hand-holding.

Solution / Fix

This targets scikit-build-core 0.9+ and meson-python 0.16+, with GDAL 3.8.x and PROJ 9.3.x.

1. The scikit-build-core version

[build-system]
requires = ["scikit-build-core>=0.9", "numpy>=1.23"]
build-backend = "scikit_build_core.build"

[tool.scikit-build]
wheel.py-api = "cp39"
cmake.version = ">=3.28"
cmake_minimum_required(VERSION 3.28)
project(geo_core LANGUAGES C CXX)
find_package(Python REQUIRED COMPONENTS Interpreter Development.Module NumPy)
find_package(GDAL 3.8 CONFIG REQUIRED)
find_package(PROJ 9.3 CONFIG REQUIRED)
python_add_library(_ext MODULE src/_ext.c WITH_SOABI)
target_link_libraries(_ext PRIVATE GDAL::GDAL PROJ::proj Python::NumPy)
install(TARGETS _ext DESTINATION geo_core)

2. The meson-python version

[build-system]
requires = ["meson-python>=0.16", "meson>=1.4", "numpy>=1.23"]
build-backend = "mesonpy"
project('geo_core', 'c', 'cpp', version: '2.4.1',
        default_options: ['c_std=c11', 'cpp_std=c++17'])
py = import('python').find_installation(pure: false)
gdal = dependency('gdal', version: '>=3.8')
proj = dependency('proj', version: '>=9.3')
np = run_command(py, '-c', 'import numpy; print(numpy.get_include())',
                 check: true).stdout().strip()
py.extension_module('_ext', 'src/_ext.c',
  include_directories: include_directories(np),
  dependencies: [gdal, proj],
  limited_api: '3.9',
  install: true, subdir: 'geo_core')

3. Check that discovery resolved what you expect

# CMake records its decision; Meson prints it
grep -E 'GDAL_DIR|PROJ_DIR' build/CMakeCache.txt
meson introspect build --dependencies | python -m json.tool | grep -A2 gdal

Verification

# 1. Both produce the same tags for the same source
ls dist/*.whl
# expected: geo_core-2.4.1-cp39-abi3-<platform>.whl from either backend
# 2. The linkage is equivalent
unzip -o dist/*.whl -d /tmp/w >/dev/null
readelf -d /tmp/w/geo_core/_ext*.so | grep NEEDED | sort
# expected: the same libraries, whichever backend built it
# 3. The wheel imports in a clean container
docker run --rm -v "$PWD/dist:/d" python:3.12-slim bash -c \
  "pip install -q /d/*.whl && python -c 'import geo_core; print(geo_core.__version__)'"

If a migration between backends changes either the tag or the NEEDED list, something in the discovery differed — most often a transitive dependency that the CMake config package propagated and pkg-config did not, or vice versa.

Where the Difference Actually Bites

Four situations separate the two backends in practice, and none of them is about syntax.

Four situations where the backend choice matters for a spatial package Linking a system or conda GDAL favours CMake because GDAL ships a config package that carries transitive requirements. Building a vendored GDAL as part of the build strongly favours CMake because GDAL itself is a CMake project. Cross-compiling is comparable, with a toolchain file and a cross file playing the same role. A small extension with no external native dependencies favours Meson for its shorter build file and faster configure. linking an installed GDAL CMake — the config package carries transitive requirements with pkg-config you sometimes have to name SQLite and TIFF yourself vendoring and building GDAL CMake — GDAL is a CMake project, so it composes directly a Meson wrap for GDAL is possible and is a project of its own cross-compiling comparable — a toolchain file and a cross file do the same work both need the sysroot routing described in the cross-compilation chapter a small extension, no native deps Meson — shorter build file, faster configure, less to get wrong

The second row is the decisive one for projects that build their own GDAL. Because GDAL, PROJ and GEOS are all CMake projects, a CMake-based build can add them as subprojects or external projects and have the dependency graph resolve itself. Doing the same under Meson means maintaining wrap definitions for each, which is a real piece of work whose only benefit is not using CMake.

The first row is subtler and shows up as missing symbols rather than as a configure failure. A CMake config package for GDAL declares that it requires SQLite and TIFF, so linking GDAL::GDAL brings them; pkg-config’s Requires.private covers the static case but the propagation is less consistent across the ecosystem, so a Meson build occasionally needs those dependencies named explicitly.

Migrating Between Them

Neither choice is permanent, and the migration is bounded because both backends read the same manifest for everything except the native build.

What changes and what stays when moving between the two backends Unchanged: the project metadata, the dependencies, the package layout, the tests and the CI matrix. Changed: the build-system requires and backend lines, the native build file, and the way extra arguments are passed to the build system. A note records that because so little changes, the comparison method is the same one used for a setup.py migration — build both and diff the wheels. unchanged [project] metadata and dependencies the package layout under src/ the tests and the validation gate the CI matrix and the repair step most of the project does not know which backend built it changed build-system requires and backend CMakeLists.txt or meson.build how extra arguments are passed the cross-compilation description a bounded change, comparable to the setup.py migration

Because the change is bounded, the safe procedure is the same one used for a legacy migration: keep both build files present for a short period, build the wheel with each, and diff the contents and the linkage before switching the backend line. That comparison is the whole verification, and it takes minutes.

Pitfalls & Alternatives

Choosing on syntax preference alone. The build files are short in both; the ecosystem question — which discovery mechanism your dependencies ship, and whether you build them — dominates.

Assuming Meson cannot do this. It can, and several large scientific projects use it successfully. The extra work for a spatial package is concentrated in discovery and in vendored builds, both of which are surmountable if you have a reason to prefer it.

Forgetting that the abi3 tag is declared per backend. wheel.py-api and limited_api are the respective settings, and a migration that drops one silently publishes version-specific wheels. The tag check after a migration catches it.

Comparing configure times without a cache. CMake’s advantage is that everything after discovery is well-trodden; its disadvantage is a slower configure. With a warm compiler cache the configure difference is a larger share of the total, which is worth measuring on your own project rather than assuming.

Frequently Asked Questions

Which should a brand-new spatial package choose?

scikit-build-core, unless there is a specific reason otherwise. The stack it links ships CMake config packages, the vendoring path is straightforward if you ever need it, and the cross-compilation story is well documented. Meson becomes attractive when the native build is small and self-contained.

Does the choice affect the wheel a user receives?

It should not, and confirming that is the migration test. Same tags, same bundled libraries, same linkage. A difference means the two discoveries disagreed about a dependency, which is worth understanding before switching rather than after.

Can one project support both?

Technically yes, and it doubles the surface that has to be kept correct for no user-visible benefit. Keep both only during a migration window, with a definite end.

What about editable installs?

Both support them and implement them differently, and for a compiled package the important property is whether an edit triggers a rebuild. Check the behaviour you get before concluding it is broken — it is usually configurable, and the defaults differ.

How does each handle passing flags from CI?

Through the backend’s own configuration channel: settings under the backend’s table in the manifest, or the environment variable it documents. Neither inherits an arbitrary shell environment reliably under build isolation, which is the same constraint described in integrating CMake with scikit-build-core.

Is there a performance difference in the compiled output?

No. Both invoke the same compiler with the flags you specify; the differences are entirely in how the build is described and driven. Any measured difference in the artifact points at differing flags rather than at the backend.

Does either backend handle the wheel repair step?

Neither does, and neither should — repair happens after the wheel exists, driven by auditwheel, delocate or delvewheel, usually orchestrated by cibuildwheel. That separation is why a backend migration does not change the platform tag: the tag is computed by the repair tool from the compiled objects, whichever build system produced them.

Which is better for a package that also has to build on Windows?

Comparable, with a slight edge to CMake because MSVC support and the surrounding ecosystem are well trodden there and because GDAL’s own Windows build is CMake-based. Meson supports MSVC perfectly well; the difference is in how much prior art exists for the specific combination.

Can I evaluate both without committing?

Yes, and it is the cheapest way to decide. Write the second build file alongside the first, build a wheel with each, and compare contents, linkage and tags. An afternoon produces a definite answer for your project rather than a general one.

Does the choice affect how contributors get started?

A little, and in a direction worth weighing: contributors to scientific Python projects are more likely to have met CMake than Meson, while Meson’s build files are shorter and easier to read cold. For a project with occasional outside contributions, the second effect often matters more than the first, because reading is what a new contributor does before writing anything.