Language Runtimes · Packaging and Distribution

Platform Tags and Binary Compatibility in Distributed Artifacts

How wheels encode interpreter, ABI, and platform into a compatibility tag, how that tag is matched against an ordered list of supported tags, and where the encoding leaks.

A source distribution claims to work anywhere a compiler exists. A binary distribution claims to work on a specific combination of interpreter, ABI, and platform, and the claim is encoded in the artifact’s name. Whether an installer selects that artifact is a set comparison between the tags the artifact advertises and the tags the current environment supports. Both sides are strings, and the whole compatibility mechanism reduces to ordering those strings correctly.

What a tag encodes

Python’s wheel tags, specified in PEP 425, take the form {interpreter}-{abi}-{platform}. A pure-Python wheel is py3-none-any, meaning any Python 3, no ABI requirement, any platform. A compiled extension wheel might be cp312-cp312-manylinux_2_17_x86_64. Each field has its own grammar, and the platform field itself contains underscores where the actual platform string would use punctuation, so manylinux_2_17_x86_64 denotes a glibc-based Linux on x86-64 with a minimum glibc version.

The interpreter field distinguishes the implementation and version: cp312 for CPython 3.12, pp39 for PyPy 3.9. The ABI field names the binary interface the extension was built against; cp312 means the CPython 3.12 ABI, while abi3 means the stable ABI introduced for limited-API extensions, which is compatible across a range of versions. none means the artifact contains no compiled code that binds to an interpreter ABI.

The ABI is where the interesting failures live. An extension module compiled against CPython 3.12 uses internal structures whose layout is not part of any stable contract. A patch release generally keeps the ABI, and the stable ABI subset, defined in PEP 384, is a deliberately restricted set of functions and types whose signatures are frozen. Extensions that use only that subset can be built once and loaded by several minor versions, which is what abi3 tags express.

The platform field is a claim about the loader and the C library. manylinux tags, standardised through PEP 513 and later revisions, state a minimum glibc version the artifact requires, enforced by building inside an old container whose glibc is that version. The move to manylinux_2_17-style tags, per PEP 600, replaced a fixed enumeration of distribution names with a glibc-version-based scheme so new architectures did not require a new standard. musllinux tags do the same for musl-based systems, and the two families are not interchangeable: an artifact linked against glibc will not load on a musl system, because the dynamic loader and the symbol names it expects are different.

Matching is an ordered list, not a set membership test

The installer does not check “is this tag in the supported set”. It builds an ordered list of supported tags, most specific first, and assigns each candidate artifact the rank of the earliest tag it matches. The lowest rank wins, and the artifact chosen is therefore determined by list order.

The order matters because a package can publish several artifacts for the same version. Given cp312-cp312-manylinux_2_17_x86_64, cp312-abi3-manylinux_2_17_x86_64, and py3-none-any, an environment that supports all three will prefer whichever appears earliest in its tag list. Implementations rank the artifacts a package publishes, preferring the exact-ABI, version-specific wheel over the stable-ABI one and both over an interpreter-agnostic wheel, so the compiled wheel is chosen over the pure-Python one. That preference is a performance decision: the compiled wheel is faster, and the pure wheel is the fallback.

The supported-tag list is generated from the running interpreter and platform. It is not just the current tag; it includes all tags the environment claims backward compatibility with. A CPython 3.12 interpreter on x86-64 Linux generates cp312-cp312-<platform> for each platform it supports, then cp312-abi3-<platform> and the same platforms for older limited-API interpreters, then py312-none-<platform> and py3-none-<platform>, and finally the platform-independent py312-none-any and py3-none-any. Getting this list wrong produces the classic error where a wheel exists, is compatible, and is not selected.

def supported_tags(py_ver, impl, platforms):
    tags = []
    ranked = [(f"{impl}{py_ver}", [py_ver]),          # exact ABI, then stable ABI
              ("abi3", range(py_ver, 1, -1))]
    for abi, versions in ranked:                      # ABI specificity outermost
        for other in versions:                        # then interpreter version
            for plat in platforms:                    # then platform specificity
                tags.append(f"{impl}{other}-{abi}-{plat}")
    for plat in platforms:                            # interpreter-agnostic wheels
        tags.append(f"py{py_ver}-none-{plat}")
        tags.append(f"py3-none-{plat}")
    return tags + [f"py{py_ver}-none-any", "py3-none-any"]

This omits debug builds, free-threaded builds, and 32-bit variants, all of which contribute their own tag families. The shape is what matters: a long ordered list, regenerated per environment, consulted for each candidate file.

Where the encoding leaks

Tags are metadata about a binary, and metadata can be wrong in both directions.

An artifact may be tagged more broadly than it is. A wheel tagged py3-none-any that actually contains a compiled extension, or that reads platform-specific paths, will install anywhere and fail somewhere. The tag is a declaration by the builder, not a verified property; nothing in the format checks that a none ABI artifact really contains no ABI-bound code. This is a packaging error rather than a format flaw, but it is common enough that installers cannot assume tag accuracy.

An artifact may be tagged more narrowly than necessary, which is harmless for correctness and wasteful for distribution: the package publishes more artifacts than it needs to, and the resolver’s candidate set grows.

C library version claims can be wrong at the system level rather than the artifact level. A manylinux_2_17 artifact requires glibc 2.17 or newer; if it links against a symbol introduced in a later glibc, it will fail to load on the older system despite the tag. Build containers are supposed to prevent this by pinning an old toolchain, and auditing tools can inspect the symbol versions an artifact actually requires. The tag is a promise about the build environment, and only the build environment can keep it.

The most confusing failures are transitive. An extension loads, then fails at import time with an undefined symbol, because a shared library it depends on is not present or is present at the wrong version. The tag mechanism has no vocabulary for shared-library dependencies, so an artifact that bundles its dependencies and one that relies on the system’s copies are indistinguishable by tag.

The trade between fat and thin artifacts

Publishing one artifact per platform multiplies the build matrix: each combination of interpreter version, architecture, and C library family needs its own build, and each must be tested. Publishing a single pure-Python artifact avoids the matrix and costs performance where compiled code matters.

Intermediate strategies exist. The stable ABI collapses the interpreter dimension: one abi3 artifact serves several minor versions, at the cost of restricting the extension to the stable subset and losing access to some optimisations. Bundling dependencies into the artifact collapses the shared-library dimension, at the cost of size and of duplicated copies when several packages bundle the same library. Building from source at install time collapses the platform dimension, at the cost of requiring a compiler and of making installs non-reproducible across machines.

None of these is free, and the choice is per-package rather than ecosystem-wide. A package with a small C accelerator and no external library dependencies takes the stable ABI. A package wrapping a large C library takes per-platform builds and bundles. A package that is pure Python takes none-any and stops thinking about it.

What the installer can and cannot decide

Given a candidate set, the installer can decide which artifacts are claimed compatible. It cannot decide whether a claimed-compatible artifact will actually work, because that depends on runtime conditions it does not model: kernel version, available shared libraries, CPU features, and locale data. CPU features are the sharpest case, since some builds dispatch on instruction sets at load time and others assume them at compile time; a tag does not distinguish.

This is why resolution and installation are separate phases. Resolution chooses a version using metadata, as described in Registry Index Metadata and Resolution Without Downloading Artifacts, and installation selects a file within that version using tags. When a selected artifact fails, the failure is a runtime failure, not a resolution failure, and no amount of constraint solving would have prevented it. The correct response is to fall back to a source build or to a different artifact, which is a decision about artifacts rather than about versions. Because the source build is the fallback, the tags also determine what an environment ends up containing, which is the subject of Prefix-Based Environments and Why Activation Is Only Path Surgery.

References

  1. PEP 425: Compatibility Tags for Built Distributions — Python Software Foundation
  2. PEP 600: Future manylinux Platform Tags for Portable Linux Built Distributions — Python Software Foundation
  3. PEP 384: Defining a Stable ABI — Python Software Foundation
  4. The ELF Format, Tool Interface Standard — Executable and Linking Format specification