# 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

Source: https://socket.dev/glossary/wheel-python-packaging-format

Last updated: 2026-10-31

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](https://socket.dev/glossary/python-package-index-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](https://socket.dev/glossary/setup-py-python). 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](https://packaging.python.org/en/latest/specifications/binary-distribution-format/), and [pip](https://socket.dev/glossary/pip-python-package-index) 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.

> Anatomy of the wheel filename numpy-2.5.4-cp312-cp312-macosx\_11\_0\_arm64.whl

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](https://pypi.org/pypi/numpy/json) 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:

```bash
$ 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-any
```

Three 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](https://peps.python.org/pep-0658/). 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.

```json
{
  "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:

```bash
$ 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:

```bash
# 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.2
```

The npm ecosystem reached the same conclusion from the other side. [npm v12 made install scripts opt-in](https://socket.dev/blog/npm-12) in July 2026, closing the [install-script](https://socket.dev/glossary/postinstall-scripts) 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](https://socket.dev/glossary/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](https://docs.python.org/3/library/site.html): "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:

```bash
$ 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 directory
```

The 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](https://socket.dev/glossary/malicious-package) below needed install-time code:

- The [Hades wave](https://socket.dev/blog/shai-hulud-descends-to-hades-miasma-pypi-wave) of the [Mini Shai-Hulud](https://socket.dev/supply-chain-attacks/miasma-mini-shai-hulud-supply-chain-attack) campaign shipped 37 malicious wheels across 19 PyPI projects in June 2026. Each carried a `*-setup.pth` file that attempted to fetch the Bun runtime and run an obfuscated JavaScript stealer named `_index.js`.
- A [follow-up wave](https://socket.dev/blog/mini-shai-hulud-miasma-and-hades-worms-target-bioinformatics-and-mcp-developers-via-malicious) moved the trigger into trojanized `.abi3.so` native extensions in bioinformatics packages, which ran the payload on import. Source review of the `.py` files saw nothing.
- The [LiteLLM 1.107.0 compromise](https://socket.dev/blog/litellm-compromise) on October 21, 2026 shipped seven wheels with `cook.pth` and `sitecustomize.py` outside the `litellm/` directory. Starting Python or pip to inspect the damage can run the stealer again.
- The [Lightning compromise](https://socket.dev/blog/lightning-pypi-package-compromised) (versions 2.6.2 and 2.6.3) and the [Telnyx SDK compromise](https://socket.dev/blog/telnyx-python-sdk-compromised) (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.

> Where code runs for an sdist versus a wheel across four phases: resolve, install, interpreter start, import

The 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](https://peps.python.org/pep-0829/) 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`:

```bash
$ 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](https://packaging.python.org/en/latest/specifications/binary-distribution-format/) 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](https://blog.pypi.org/posts/2025-08-07-wheel-archive-confusion-attacks/) 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](https://socket.dev/glossary/lockfile) such as the [pylock.toml format standardized by PEP 751](https://socket.dev/blog/python-adopts-standard-lock-file-format-for-reproducible-installs), or a [PEP 740 digital attestation](https://socket.dev/blog/pypi-introduces-digital-attestations), 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](https://socket.dev/glossary/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](https://socket.dev/blog/ultralytics-pypi-package-compromised-through-github-actions-cache-poisoning) 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](https://socket.dev/blog/litellm-compromise) 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.
