Building against NumPy 2 without breaking NumPy 1
This page answers one question: your spatial wheel was built when NumPy 1 was current, users are now on NumPy 2, and you need one wheel that works on both — so what exactly do you change in the source, the build requirements and the runtime dependency? It sits inside the NumPy ABI and Array Interop in Spatial Extensions section of the Geospatial C-Extension Fundamentals & ABI Architecture reference, and gives you the three edits and the two-cell test matrix that proves them.
Context & Root Cause
NumPy’s C-API is versioned separately from the package version, and an extension records which API version it was compiled for. The compatibility rule is the reverse of the intuitive one: an extension compiled against older API declarations works with newer NumPy releases, while one compiled without declaring a target inherits the API of whatever NumPy was installed at build time — and then demands that version or later at runtime.
Two things went wrong across the ecosystem during the NumPy 2 transition. Packages built before the transition, with no declared target, produced binaries that refused to load under NumPy 2 with a message about a changed dtype size. And packages that reacted by adding numpy<2 to their runtime dependencies made the problem structural: because such a cap propagates through the dependency graph, a single spatial package with one can prevent an entire environment from upgrading. The fix for both is small; the second is mostly a matter of resisting a defensive instinct.
Solution / Fix
This targets NumPy 1.23 as a floor and NumPy 2.x at runtime, with scikit-build-core 0.9+, meson-python 0.16+ or setuptools 69+.
1. Declare the API target in the source
/* Before any NumPy header. The floor is a decision, not a default. */
#define NPY_NO_DEPRECATED_API NPY_1_23_API_VERSION
#define NPY_TARGET_VERSION NPY_1_23_API_VERSION
#include <numpy/arrayobject.h>
NPY_NO_DEPRECATED_API turns any use of a removed or deprecated C entry point into a compile error, which is how the removals surface as a build failure on your machine rather than as an import failure on a user’s.
2. Build against a recent NumPy, depend on a range
[build-system]
requires = ["scikit-build-core>=0.9", "numpy>=1.23"]
build-backend = "scikit_build_core.build"
[project]
dependencies = ["numpy>=1.23"] # lower bound only — no upper cap
3. Fix the Python-level removals
# NumPy 2 removed a set of long-deprecated aliases
- arr = np.array(vals, dtype=np.float_)
+ arr = np.array(vals, dtype=np.float64)
- if np.NaN in arr: ...
+ if np.isnan(arr).any(): ...
- from numpy.lib import NumpyVersion
+ from packaging.version import Version
4. Test both majors as two matrix cells
- name: Verify on both NumPy majors
run: |
for spec in "numpy==1.26.*" "numpy>=2.0"; do
python -m venv /tmp/v && /tmp/v/bin/pip install -q "$spec" dist/*.whl
/tmp/v/bin/python -c "import geo_core; geo_core.roundtrip_check()"
rm -rf /tmp/v
done
Verification
# 1. The wheel imports under both majors
for v in "numpy==1.26.4" "numpy>=2.0"; do
python -m venv /tmp/nv && /tmp/nv/bin/pip install -q "$v" dist/*.whl
/tmp/nv/bin/python -c "import numpy, geo_core; print(numpy.__version__, 'ok')"
rm -rf /tmp/nv
done
# 2. No NumPy symbol is dynamically linked — the API is dispatched at import
unzip -o dist/*.whl -d /tmp/w >/dev/null
nm -D --undefined-only /tmp/w/**/_ext*.so | grep -ci numpy
# expected: 0
# 3. The metadata carries no upper bound
python -c "
from importlib.metadata import requires
print([r for r in requires('geo-core') if 'numpy' in r])"
# expected: ['numpy>=1.23'] — nothing with a < in it
The second check is the one that reassures people who expect an ABI change to be visible in the linkage. NumPy’s C-API is reached through a function-pointer table populated by import_array(), so a correctly built extension references no NumPy symbols at all — which is precisely why the compile-time declaration is what determines compatibility.
What Actually Broke, and What Did Not
The transition generated a great deal of noise relative to the number of real changes, and separating the two makes the migration much shorter.
The scalar-promotion row is the one that can produce a behavioural difference rather than an error, and it matters for raster work. Under the newer rules, mixing a Python integer with a small-width array no longer promotes the array’s dtype in every case it used to, so an expression combining a uint8 band with a large constant can overflow where it previously widened. The exposure is narrow but real; a test that exercises band arithmetic on the smallest dtype you support catches it.
Everything in the right-hand column matters because it is where a spatial binding spends its time. Reading a band, wrapping a buffer, applying a mask, transforming coordinate arrays — none of these changed, which is why the migration is short once the aliases are dealt with.
The Upper-Bound Problem
The most consequential decision in this whole area is a single line of metadata, and it is worth understanding why the ecosystem is so consistent about it.
There is a legitimate use for an upper bound: a known, documented incompatibility with a specific released version, added with a comment naming the issue and removed as soon as it is fixed. What causes the harm is the speculative cap added because a major version sounded risky, and left in place for years because nobody remembers why it is there.
If you are unsure whether your package works on a new major, the answer is a matrix cell, not a cap. Two minutes of CI produces a definite answer, and the definite answer is nearly always yes once the aliases are fixed.
Pitfalls & Alternatives
Setting the target macro after including numpy/arrayobject.h. The header configures itself on first inclusion; a later definition does nothing. Put it in the same file, above the include, or define it in the build system.
Pinning the build requirement to an old NumPy instead of declaring a target. It works — an old NumPy produces an old-API binary — and it means the build cannot benefit from newer tooling and will eventually fail to install on a modern Python. Declaring the target is the supported approach.
Assuming Cython handles the removals. Cython manages the C-level compatibility well; the Python-level aliases in your own .py files are yours. Run the test suite under both majors rather than inferring from a successful compile.
Testing only the newest NumPy. The floor is the half of the range that breaks silently: an extension accidentally compiled with a newer target still imports on the machine that built it, and fails only for the users on the older version you claim to support.
Frequently Asked Questions
Which NumPy floor should a spatial package choose?
The oldest release that the rest of the spatial stack still supports — in practice something in the 1.23 to 1.26 range. Going lower buys compatibility with environments that other geospatial packages have already dropped, and going higher excludes users for no gain.
Do I need to rebuild for every NumPy release?
No, and that is the point of declaring a target: one artifact serves every NumPy from the floor upward, including majors that did not exist when it was built. Rebuilds are for your own changes, not for NumPy’s.
How do I know whether an existing wheel is forward compatible?
Install it in an environment with a newer NumPy and import it. There is no metadata that records the compiled target, so the empirical test is the answer — which is why the two-cell matrix earns its place permanently rather than only during a migration.
What if a dependency of mine caps NumPy?
Then your environment inherits the cap regardless of what you declare, and the useful action is to raise it with that project — ideally with evidence that their package works uncapped. Adding a matching cap of your own propagates the problem rather than containing it.
Does Cython need separate treatment?
It manages the C-level compatibility itself, provided a recent version is used, and it does nothing about aliases in your own Python modules. Running the test suite under both majors is what covers the second half, and it is the check that most projects find something with.
How do I audit a large codebase for removed aliases?
A grep for the handful of removed names finds most of them in minutes, and a run of the test suite under the new major finds the rest. The list is short enough that a systematic search is faster than adopting a tool for the purpose.
Related
- NumPy ABI and array interop in spatial extensions — the parent guide on both binary contracts.
- Building abi3 wheels with Py_LIMITED_API — the same “declare a floor” pattern applied to CPython.
- Mastering pyproject.toml for spatial wheels — where the build requirement and the runtime dependency each belong.