Safeguard
Engineering

Python Wheels vs Source Distributions: Security Implications

Installing an sdist runs someone else's code on your machine; installing a wheel doesn't. That one difference drives most PyPI malware — and most of the right defenses.

Tomas Lindgren
Platform Engineer
6 min read

The security difference between Python wheels and source distributions is execution: installing an sdist runs the package's build code (setup.py or a PEP 517 backend) on your machine at install time, while installing a wheel is a pure archive extraction that executes nothing until you import it. That single asymmetry explains why the overwhelming majority of PyPI malware ships its payload in an sdist install hook, and it should drive concrete pip policy — not a vague preference for wheels, but flags that enforce one.

What actually happens on pip install

For a wheel (.whl), pip verifies the file, unpacks it into site-packages, checks the RECORD manifest hashes, rewrites entry-point scripts, done. No package-supplied code runs.

For an sdist (.tar.gz), pip must build the package: it creates an isolated build environment, installs the declared build backend, and executes it — historically python setup.py, arbitrary Python by definition. The ctx and colourama incidents in 2022 are the canonical demonstration: typosquatted and hijacked packages whose setup-time code stole AWS credentials from anyone who so much as installed them. No import required. pip download, used by some "safe inspection" workflows, will also happily build an sdist to resolve metadata — inspection that executes the suspect is not inspection.

The enforcement flag has existed for years and almost nobody sets it:

pip install --only-binary :all: -r requirements.txt

That refuses sdists outright. In a Docker build or CI environment, it converts "install-time code execution" from a standing risk into a hard error. When something in your tree genuinely has no wheel — rarer every year, with over 95% of top-package downloads served as wheels — you'll get an explicit failure naming the package, and you can make a deliberate exception instead of an accidental one.

Wheels are safer to install, not safer, full stop

Before wheels get too much credit: a wheel's payload just moves from install time to import time. Malware in a wheel still runs the moment your application (or your test suite, or your IDE's indexer) imports it. What you gain with wheels is real but specific:

  • The install step itself is inert — build machines that install-but-never-import (artifact promotion, image assembly) stay clean.
  • Wheels are static archives, so scanners can fully enumerate contents without executing anything. An SCA scanner can unpack, fingerprint, and pattern-match a wheel deterministically; analyzing an sdist's behavior means analyzing arbitrary code.
  • No compiler toolchain in your runtime image, shrinking both the image and its CVE surface.

What you lose is source correspondence. A wheel is whatever the publisher's build produced — there's no structural guarantee it matches the sdist or the GitHub repo you reviewed. Compiled extension modules (.so files in manylinux wheels) make eyeball verification effectively impossible. This is precisely the gap PyPI's new attestations (PEP 740, launched this month) address: wheels published via Trusted Publishing can now carry a signed attestation binding the artifact to the exact source repo and workflow that built it. Adoption is just starting, but it's the first mechanism that lets you verify a wheel's provenance rather than assume it.

Hash-checking mode: pin bytes, not just versions

Version pinning stops surprise upgrades; it doesn't stop the same version being different bytes than last time (registry compromise, mirror tampering, or a maintainer replay attack in ecosystems that allow it). pip's hash-checking mode pins the artifacts themselves:

pip-compile --generate-hashes -o requirements.txt requirements.in
pip install --require-hashes -r requirements.txt

Every requirement now carries --hash=sha256:... entries for each acceptable file, and pip rejects anything that doesn't match. Two operational notes. First, --require-hashes requires hashes for everything, transitives included — which is why you generate the file with pip-compile (or uv, which does this dramatically faster) rather than by hand. Second, hash-pinning interacts with the wheel/sdist question: if you hash only the wheel for a package, an environment that can't use that wheel fails closed instead of silently falling back to an unhashed sdist. That's the correct failure.

A policy that fits real pipelines

EnvironmentPolicy
Production images / CI--only-binary :all: + --require-hashes
Developer machinesHash-pinned installs from the same compiled requirements
The 1-2 packages with no wheelBuild the wheel yourself once, host on your internal index, hash-pin it
New/updated dependenciesScan before merge, not after deploy

The third row deserves emphasis because it's the standard objection ("we depend on something sdist-only"). Building it yourself — pip wheel <package> in a sandboxed builder, publish to your internal index — means the untrusted build code runs exactly once, in a throwaway environment you control, instead of on every install everywhere. And keeping an inventory of exactly which artifacts you blessed is what SBOM tooling like SBOM Studio is for; Safeguard tracks the hash-level lineage so the "where did this wheel come from" question has an answer eleven months later.

The residual risks worth naming

Being honest about what none of the above stops: a malicious version of a legitimate package, published through legitimate channels with valid wheels and fresh hashes, updated into your lockfile by a routine dependency bump. Defenses there are process, not flags — a cooldown period before adopting brand-new releases, registry-anomaly monitoring, and reviewing lockfile diffs for new packages with suspicious traits (fresh accounts, obfuscated code, install hooks appearing in a package that never had them). Wheels-only policy shrinks the blast radius of that scenario from "every machine that installed" to "every process that imported," which is smaller but rarely empty.

Frequently asked questions

Are wheels always safer than source distributions?

Safer to install, yes — nothing executes at install time. The code still runs at import, so wheels reduce attack surface (inert installs, statically scannable artifacts) rather than eliminating it. Malicious wheels exist; they just detonate later.

Does pip's build isolation make sdists safe?

No. PEP 517 build isolation protects the build environment's dependency graph, not your machine — the build backend still executes with your user's privileges, network access, and credentials. Isolation prevents dependency pollution, not exfiltration.

How do I handle a dependency that only ships an sdist?

Build the wheel once yourself in a sandboxed, network-restricted builder, publish it to your internal index, and hash-pin it. You've converted per-install code execution by an untrusted party into a one-time build you supervised.

Do PyPI attestations replace hash checking?

They're complementary. Hashes guarantee you get the same bytes every time; attestations tell you where those bytes came from — the source repository and CI workflow that produced them. Use hashes now everywhere, and start verifying attestations as publisher adoption grows.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.