Python Wheel (.whl)
A Python wheel is a prebuilt .whl ZIP archive that pip unpacks into site-packages without running package code at install time.
Also known as wheel file, built distribution, binary distribution, bdist_wheel
A Python wheel (a .whl file, or built distribution) is a prebuilt ZIP archive that installers like pip unpack directly into site-packages. A wheel contains no setup.py, so installing one runs no package code. That removes install-time execution, but files a wheel drops, such as .pth startup hooks, can still run on the next Python launch.
Python packages reach PyPI in two formats. A source distribution (sdist) is a tarball of source code plus build instructions, and installing it means running the project's build backend, which for setuptools means executing setup.py. A wheel is the output of that build, already laid out the way site-packages expects. The format was approved through PEP 427 in February 2013, and pip prefers a compatible wheel over an sdist of the same version.
Our position: wheels are the strongest install-time control Python has, and most teams leave it half on. pip still builds an sdist whenever no wheel matches, and it does so during pip download and pip install --dry-run as well. Meanwhile, "no code at install" is not "no code before you call the package." The 2026 PyPI compromises we tracked moved execution to interpreter startup and import time, where the wheel format offers no protection.
What is a Python wheel?
#A Python wheel is a ZIP archive whose filename declares which interpreters and platforms it supports. The specification defines the name as {distribution}-{version}(-{build tag})?-{python tag}-{abi tag}-{platform tag}.whl, and installers pick a file by matching those tags against the running interpreter.
The filename in the figure breaks down as follows:
| Segment | Value | Meaning |
|---|---|---|
| distribution | numpy | Project name, normalized |
| version | 2.5.4 | Release version |
| python tag | cp312 | Built for CPython 3.12. py3 means any Python 3 |
| ABI tag | cp312 | Uses the CPython 3.12 ABI. abi3 is the stable ABI shared across versions; none means no ABI dependency |
| platform tag | macosx_11_0_arm64 | macOS 11 or later on Apple silicon. any means pure Python; manylinux_2_28_x86_64 means Linux with glibc 2.28 or later |
| extension | .whl | A ZIP archive |
A pure-Python project ships one wheel per release. Requests 2.34.2 publishes requests-2.34.2-py3-none-any.whl beside its sdist. A project with compiled extensions ships one wheel per supported target. As of October 31, 2026, PyPI's JSON API lists 65 wheels and 1 sdist for numpy 2.5.4. Each of those wheels is a separate artifact with its own hash, and nothing requires their contents to match.
Inside, a wheel holds the files in their installed layout plus a .dist-info directory. This is the real requests wheel:
$ sha256sum requests-2.34.2-py3-none-any.whl
2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 requests-2.34.2-py3-none-any.whl
$ unzip -l requests-2.34.2-py3-none-any.whl | tail -6
4806 2026-05-14 19:21 requests-2.34.2.dist-info/METADATA
91 2026-05-14 19:21 requests-2.34.2.dist-info/WHEEL
9 2026-05-14 19:21 requests-2.34.2.dist-info/top_level.txt
2027 2026-05-14 19:21 requests-2.34.2.dist-info/RECORD
--------- -------
234397 26 files
$ unzip -p requests-2.34.2-py3-none-any.whl requests-2.34.2.dist-info/WHEEL
Wheel-Version: 1.0
Generator: setuptools (82.0.1)
Root-Is-Purelib: true
Tag: py3-none-anyThree files in .dist-info do the work. METADATA holds the core metadata, including every Requires-Dist dependency, written once at build time. WHEEL records the format version and whether the archive unpacks into purelib or platlib. RECORD lists every file with a SHA-256 hash and size.
Because METADATA is static, PyPI can serve it on its own under PEP 658. A resolver reads a wheel's dependencies without downloading the archive, let alone running it. PyPI's JSON simple API shows the asymmetry directly: the wheel advertises core-metadata, the sdist does not.
{
"filename": "requests-2.34.2-py3-none-any.whl",
"core-metadata": {"sha256": "8c384ba3e979480faae2859d3c5e6c1276dd2c3616e322e124d52c8cfc556f27"},
"hashes": {"sha256": "2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"},
"provenance": "https://pypi.org/integrity/requests/2.34.2/requests-2.34.2-py3-none-any.whl/provenance"
},
{
"filename": "requests-2.34.2.tar.gz",
"core-metadata": false,
"hashes": {"sha256": "f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}
}Why do Python wheels avoid install-time code execution?
#Python wheels avoid install-time code execution because installing a wheel is file copying, not building. The specification's install procedure has two steps. "Unpack" extracts the archive into purelib or platlib. "Spread" moves each subdirectory of .data to its destination and rewrites scripts that start with #!python to point at the right interpreter. Neither step loads package code, and the spec states plainly that a wheel "does not contain setup.py or setup.cfg."
An sdist works the other way around. The installer must call the build backend named in pyproject.toml to turn the source into a wheel, and the backend runs whatever the project ships. We tested both paths with a package whose setup.py writes a marker file, using pip 26.2.2, setuptools 84.2.1, and Python 3.13:
$ pip install demo_wheel-1.0.0-py3-none-any.whl
$ cat /tmp/BUILD_RAN
cat: /tmp/BUILD_RAN: No such file or directory
$ pip install demo_wheel-1.0.0.tar.gz
$ cat /tmp/BUILD_RAN
setup.py executed at 1793465117.28
# sdist-only index; nothing is installed in either command
$ pip download --no-deps --no-index --find-links idx -d dl demo-wheel
$ cat /tmp/BUILD_RAN
setup.py executed at 1793465254.24
$ pip install --dry-run --no-index --find-links idx demo-wheel
$ cat /tmp/BUILD_RAN
setup.py executed at 1793465255.78
$ pip install --only-binary :all: --no-index --find-links idx demo-wheel
ERROR: Could not find a version that satisfies the requirement demo-wheel (from versions: none)The pip download and --dry-run results are the ones most teams miss. pip prepares an sdist's metadata by running its build backend, so fetching an sdist "just to look at it" executes the project's code. In our test that happened even though the sdist carried static Metadata 2.4. Downloading wheels to inspect them has no such side effect.
| Wheel | sdist | |
|---|---|---|
| Package code at install | None | Build backend runs (setup.py, build hooks) |
| Package code during pip download or --dry-run | None | Build backend runs to produce metadata |
| Dependencies readable without executing | Yes: static METADATA, served alone via PEP 658 | pip builds metadata first |
| Needs a compiler on the target | No | Often, for extensions |
| Whose build you trust | The publisher's build machine | Your own build environment |
| Verdict | Default for CI, production, and workstations | An explicit, reviewed exception, built in an isolated environment without secrets |
How do you install a Python wheel?
#You install a Python wheel with pip install, either by passing a local .whl file or by naming the package and letting pip pick the matching wheel from the index. pip compares the filename's tags with the running interpreter and rejects a wheel built for another platform: numpy-2.5.4-cp312-cp312-macosx_11_0_arm64.whl is not a supported wheel on this platform.
The flags that matter for security decide whether pip may fall back to an sdist:
# A local file
$ python -m pip install ./requests-2.34.2-py3-none-any.whl
# From the index, refusing every sdist
$ python -m pip install --only-binary :all: requests==2.34.2
# Wheel-only, and only the exact bytes you pinned.
# --require-hashes needs every dependency pinned with a hash, so generate the file:
$ echo "requests==2.34.2" | uv pip compile --generate-hashes --only-binary :all: - -o requirements.txt
$ python -m pip install --only-binary :all: --require-hashes -r requirements.txt
# uv's equivalent of --only-binary :all:
$ uv pip install --no-build requests==2.34.2The npm ecosystem reached the same conclusion from the other side. npm v12 made install scripts opt-in in July 2026, closing the install-script path that almost every recent npm worm used. Python's equivalent already exists, but you must turn it on: --only-binary :all: in pip, or --no-build in uv. We think wheel-only installs should be the default anywhere credentials live. The cost is real: a dependency with no wheel for your platform fails to install, and every wheel you accept is code compiled on someone else's machine. The sections below cover what that trade leaves open.
Code still runs at startup and import
#A wheel runs code at the next interpreter start if it drops a .pth file with an import line or a sitecustomize module, and at import time through module code or native extensions. The installer only copies files. Python's site module then processes every .pth file in site-packages on startup, and the documentation is explicit: "lines starting with import (followed by space or tab) are executed with exec()."
We added a one-line .pth file to the demo wheel and installed it:
$ unzip -p demo_wheel-1.0.0-py3-none-any.whl demo_wheel-startup.pth
import pathlib; pathlib.Path("/tmp/PTH_RAN").write_text("pth executed\n")
$ pip install demo_wheel-1.0.0-py3-none-any.whl
$ cat /tmp/PTH_RAN
cat: /tmp/PTH_RAN: No such file or directory
$ python -c pass && cat /tmp/PTH_RAN
pth executed
$ rm /tmp/PTH_RAN; pip --version > /dev/null && cat /tmp/PTH_RAN
pth executed
$ rm /tmp/PTH_RAN; python -S -c pass && cat /tmp/PTH_RAN
cat: /tmp/PTH_RAN: No such file or directoryThe install was clean. Every Python process after it ran the payload, including pip itself, and nothing imported the package. Only python -S, which disables the site module entirely, skipped it.
These execution paths carried several 2026 PyPI compromises, and none of the malicious packages below needed install-time code:
- The Hades wave of the Mini Shai-Hulud campaign shipped 37 malicious wheels across 19 PyPI projects in June 2026. Each carried a
*-setup.pthfile that attempted to fetch the Bun runtime and run an obfuscated JavaScript stealer named_index.js. - A follow-up wave moved the trigger into trojanized
.abi3.sonative extensions in bioinformatics packages, which ran the payload on import. Source review of the.pyfiles saw nothing. - The LiteLLM 1.107.0 compromise on October 21, 2026 shipped seven wheels with
cook.pthandsitecustomize.pyoutside thelitellm/directory. Starting Python or pip to inspect the damage can run the stealer again. - The Lightning compromise (versions 2.6.2 and 2.6.3) and the Telnyx SDK compromise (4.87.1 and 4.87.2) fired at import. The Telnyx payload sat in
_client.py, which every API call loads, and avoided install hooks that scanners watch.
setup.py executesMETADATA read, or fetched alone via PEP 658#!pythonsite execs import lines in *.pth, imports sitecustomizeHades wave, LiteLLM 1.107.0.so initLightning, Telnyx, bioinformatics .abi3.soThe figure maps four phases to where package code can run. In phase 1, resolve (pip download or pip install --dry-run), an sdist runs its build backend to produce metadata, while a wheel's static METADATA is read without execution. In phase 2, install, an sdist builds a wheel locally with arbitrary build code, while a wheel is unzipped into site-packages and its #!python scripts are rewritten. In phase 3, interpreter start, both formats can run code: site executes import lines in .pth files and imports sitecustomize, as in the Hades wave and LiteLLM 1.107.0. In phase 4, import, both formats run module top-level code and native extension initialization, as in Lightning, Telnyx, and the bioinformatics .abi3.so wave. A wheel closes phases 1 and 2 only.
Python 3.15, released October 9, 2026, begins to restructure phase 3. PEP 829 deprecates import lines in .pth files in favor of .start files that name entry points such as pkg.mod:callable. The schedule is slow: import lines keep running through Python 3.17, are silently ignored in 3.18 and 3.19, and produce a warning from 3.20. On a 3.15 pre-release build (3.15.0b4), our demo .pth still executed, and the deprecation notice appeared only under -v:
$ python3.15 -v -c pass 2>&1 | grep demo_wheel-startup
import lines in .../site-packages/demo_wheel-startup.pth are deprecated, use entry points in a .../site-packages/demo_wheel-startup.start file instead.
Exec'ing from .../site-packages/demo_wheel-startup.pth: import pathlib; pathlib.Path("/tmp/PTH_RAN").write_text("pth executed\n")PEP 829's author, Barry Warsaw, is direct about the limit: "A malicious package can still cause arbitrary code execution via entry points." Startup code becomes auditable, not absent. We think any dependency update that adds startup code, whether a .pth import line, a .start file, or a sitecustomize module, deserves the same review as a new install script on npm.
What does a wheel's RECORD file verify?
#A wheel's RECORD file lists every file in the archive with its SHA-256 hash and size. The specification says "installation will fail if any file in the archive is not both mentioned and correctly hashed in RECORD." Current installers do not enforce that. We edited demo/__init__.py and added an unlisted evil.pth without touching RECORD. pip 26.2.2 and uv 0.11.32 both installed the modified file and the extra .pth without a warning.
The gap is known. Seth Larson, the Python Software Foundation's Security Developer-in-Residence, wrote in August 2025 that "most Python installers today do not do this check." In the same post, PyPI announced it would reject new wheel uploads whose ZIP contents do not match RECORD starting February 1, 2026. That protects files served by PyPI. It does nothing for wheels that come from a mirror, a cache, a direct URL, or a local file.
Enforcement would not settle the question anyway. RECORD sits inside the archive it describes, so it proves internal consistency, not origin. Anyone who modifies a wheel can regenerate RECORD; wheel pack did exactly that for our .pth demo. Integrity that means something comes from outside the archive: the hash in the index, a hash pinned in a lockfile such as the pylock.toml format standardized by PEP 751, or a PEP 740 digital attestation, which PyPI has supported since November 2024.
| Check | Where the hash lives | What it proves | What it does not prove |
|---|---|---|---|
| RECORD | Inside the wheel | Files match the wheel's own manifest, if the installer checks | Who built the wheel or whether it is benign |
| Index hash | PyPI's simple API | The bytes match what PyPI serves | That PyPI's file is safe |
| --require-hashes or pylock.toml | Your repository | You install the same bytes every time | That the bytes were safe when you pinned them |
| PEP 740 attestation | PyPI's integrity API | Which workflow built and uploaded the file | That the workflow's inputs were legitimate |
| Content analysis | Not a hash | What the code in this artifact does | Intent, with certainty |
| Verdict | Outside the wheel | Pin hashes for repeatability and check attestations for origin | Only content analysis tells you what a new wheel does, so run it before the wheel reaches a machine with secrets |
A hash-pinned lockfile guarantees the same bytes, not good bytes. If the malicious version is what you pinned, the hash check passes.
Wheels move trust from your machine to the builder
#A wheel is someone else's build output. You skip running setup.py, and in exchange you accept files, often compiled ones, that you cannot easily compare against the source repository. Package provenance is the ecosystem's answer: an attestation binds a file's hash to the workflow that built and uploaded it, and the simple API response above links the requests wheel to its provenance record.
Provenance records what the build claimed about itself, which is not the same as what it should have built. In the LiteLLM case, provenance attached to the compromised container image recorded the source repository as Berri-AI/litellm, one hyphen away from the legitimate BerriAI/litellm. In the Ultralytics compromise in December 2024, a poisoned GitHub Actions cache let the project's own publishing workflow build and upload malicious releases. A legitimate pipeline fed bad inputs produces a valid-looking artifact.
That is why we analyze each wheel as its own artifact rather than trusting the release as a whole. Socket's AI scanner flagged all seven LiteLLM 1.107.0 wheels seven minutes after publication, and each one is tracked by its own identifier, such as pypi/litellm@1.107.0?artifact_id=cp310-abi3-win-amd64-whl. Native extensions remain the hardest case: a trigger compiled into an .abi3.so file does not appear in any .py file a reviewer or source-only scanner reads.
Nothing in the standard Python toolchain checks that a wheel corresponds to the source it claims to come from, and reproducible wheel builds are still the exception. Until that changes, a wheel is a promise from whoever built it, and the only way to check the promise is to look inside the archive.
