Back to glossary

pip (Python package installer)

pip is Python's default package installer. It downloads packages from PyPI or another index and can run their code at build, startup, or import time.

Also known as pip3, python -m pip, pip install, Pip Installs Packages

pip (run as pip3 or python -m pip) is Python's default package installer. It resolves requirements against an index such as PyPI, downloads wheels or source distributions, and installs them. pip also executes package code: building a source distribution runs it, and an installed wheel can run code at every interpreter startup. Every pip install is a trust decision.

The fact most pip guides leave out: pip runs a source distribution's build code even for pip download and pip install --dry-run, and its resolver will pick a newer source distribution over an older wheel. We think pip's defaults treat code execution as a packaging detail rather than a trust boundary. The controls that fix this exist, but every one of them is opt-in.

What is pip in Python?

#

pip is the package installer maintained by the Python Packaging Authority (PyPA). It is a client, not a registry. The registry is the Python Package Index (PyPI), which pip queries by default. The two names are often confused.

pip ships with CPython through the ensurepip module, a change made in Python 3.4 by PEP 453. As of October 2026, the current release is pip 26.2.2, published to PyPI on October 20, 2026. pip uses calendar versioning: 26.0 shipped in January 2026, and new feature releases arrive roughly every three months.

Run pip as python -m pip rather than bare pip. The module form binds pip to the interpreter you named, so packages land in that interpreter's site-packages and not in whichever pip happens to be first on PATH.

Debian 12 and Ubuntu 24.04, among other distributions, refuse a bare pip install into the system Python. PEP 668 lets a distribution mark its interpreter as externally managed, and pip has honored that marker since version 23.0:

Plain text
$ python3 -m pip install requests
error: externally-managed-environment

× This environment is externally managed
╰─> This Python is managed by the OS. Use a venv.

note: If you believe this is a mistake, please contact your Python installation or OS distribution provider. You can override this, at the risk of breaking your Python installation or OS, by passing --break-system-packages.
hint: See PEP 668 for the detailed specification.

The fix is a virtual environment, not --break-system-packages. PEP 668 protects the operating system's own Python tools from being overwritten. It does nothing to vet the package you were about to install.

How does pip install a package?

#

pip installs a package in five steps: query the index, rank candidates, obtain metadata, download and verify, then unpack. Each step makes a trust assumption worth knowing.

1. Query the index. pip asks the index for every file published under a name, using the Simple Repository API. PyPI answers in JSON when asked to. This is the real response for requests 2.32.5, trimmed to its two files:

Bash
curl -s -H 'Accept: application/vnd.pypi.simple.v1+json' https://pypi.org/simple/requests/
JSON
{
  "filename": "requests-2.32.5-py3-none-any.whl",
  "hashes": { "sha256": "2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6" },
  "core-metadata": { "sha256": "65b5a08da81f4915513e760965ff14b7518f65b7bb3efe0dab364bbcc4d40cb0" },
  "requires-python": ">=3.9",
  "upload-time": "2025-08-18T20:46:00.542304Z",
  "yanked": false
},
{
  "filename": "requests-2.32.5.tar.gz",
  "hashes": { "sha256": "dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf" },
  "core-metadata": false,
  "requires-python": ">=3.9",
  "upload-time": "2025-08-18T20:46:02.573425Z",
  "yanked": false
}

2. Rank candidates. pip collects candidates from every configured index and --find-links location, then picks the highest version that satisfies the requirement. A wheel beats a source distribution only when both have the same version. pip's backtracking resolver, the default since pip 20.3 in November 2020, repeats this for every dependency until the whole tree is consistent.

3. Obtain metadata. The resolver needs each candidate's dependencies before it can choose. A wheel's metadata is static, and PyPI serves it as a separate file (the core-metadata hash above), so pip can read it without downloading the wheel. A source distribution has "core-metadata": false. The only way to learn its dependencies is to run its build backend.

4. Download and verify. pip checks the downloaded file against the index-provided sha256. That check catches corruption in transit, not tampering, because the same server supplied both the file and the hash. Since pip 26.2, pip also refuses a wheel whose embedded METADATA disagrees with the separate .metadata file the resolver used, on name, version, or dependencies. That stops an index or proxy from showing the resolver one dependency list and installing another. It does not touch a different gap on PyPI itself. The 2026 PyPI security audit found that PyPI never checks a wheel's embedded metadata against the upload-form metadata shown on the project page and JSON API. pip reads the wheel. Tools that read the JSON API can see a different dependency tree, a Python form of manifest confusion.

5. Unpack. Installing a wheel is a file copy: unzip into site-packages, write a RECORD of every file, generate console-script launchers. No package code runs during this step. The files it writes are another matter.

Is it safe to install packages with pip?

#

Installing packages with pip is safe only if you trust every package in the resolved tree, because pip gives package code three separate chances to run. Only the first happens during pip install. The other two happen later, in whatever Python process starts next, including pip itself. A console script is a fourth path, but running one is a deliberate act.

The figure shows the four stages of a pip-managed package's life and which of them execute package code:

StageTriggerWhat runsSeen inControl
1. Resolve: build sdist metadatapip install, pip download, pip install --dry-runThe sdist's build backend, such as setup.py egg_infoOur demo sdist, which ran twice on a dry run--only-binary :all:
2. Install: unpack the wheelEnd of pip installNo code. Files are copied into site-packages, including any .pth, sitecustomize.py, or native .soEvery wheel installHash-checking mode proves the bytes match the lock, not that they are safe
3. Next interpreter startAny Python process, including pip itselfimport lines in .pth files, then sitecustomizeLiteLLM 1.107.0 (cook.pth), the Hades wave (*-setup.pth)None in pip. PEP 829 begins a deprecation in Python 3.15
4. Importimport packageModule top-level code and native extension initializationTelnyx 4.87.1 (_client.py), Hades .abi3.so extensions, torchtritonNone in pip. Inspect artifact contents before install

Build time: source distributions run before anything is installed

#

A source distribution (sdist) is a tarball that must be built into a wheel. Building means running the project's build backend, which for setuptools projects means executing setup.py with your user's permissions. pip runs that build during resolution, because step 3 needs the sdist's dependency list.

Combine that with the ranking rule from step 2. We put demo-sdist 0.9 as a wheel and 1.0 as an sdist side by side in a local --find-links directory. The sdist's setup.py appends a line to a marker file every time it runs:

Python
import os, sys
from setuptools import setup
with open("/tmp/sdist-ran.txt", "a") as f:
    f.write(f"setup.py executed: argv={sys.argv[1:2]} uid={os.getuid()}\n")
setup(name="demo-sdist", version="1.0", py_modules=[])
Plain text
$ python -m pip install --dry-run --find-links mix demo-sdist
Would install demo-sdist-1.0
$ cat /tmp/sdist-ran.txt
setup.py executed: argv=['egg_info'] uid=0
setup.py executed: argv=['dist_info'] uid=0

pip 26.2.2 picked the newer sdist over the older wheel and executed its build code twice, on a dry run. "Dry run" means nothing is installed; it does not mean nothing runs. pip download behaved the same way. Adding --no-deps to pip download skipped the build in our test, because pip no longer needed the dependency list.

An attacker who controls a project's upload credentials can publish the next version as an sdist only. Every resolver that accepts that version will execute it, even in projects that have shipped wheels for years. --prefer-binary flips the ranking, and pip chose the 0.9 wheel. --only-binary :all: refuses sdists entirely: pip reported "No matching distribution found" and nothing ran.

Startup: .pth files run in every Python process

#

A wheel runs no code while pip installs it, but it can drop files into site-packages that run later. The sharpest of these is a .pth file. Python's site module reads every .pth file in site-packages at startup and passes any line that begins with import to exec(). The package never has to be imported. The next interpreter to start runs the line, and that interpreter is often pip itself. Our Python supply chain security entry reproduces this with a one-line hook.

The October 2026 LiteLLM compromise used this path. LiteLLM 1.107.0, published to PyPI at 11:39:51 UTC on October 21, 2026, shipped seven wheels carrying cook.pth and a sitecustomize.py that launched a credential collector and a remote Python loader. Socket's scanner flagged the release at 11:46:30 UTC, under seven minutes later. Our write-up warned that "starting Python or pip in an affected environment to inspect or repair the installation can execute the payload again." Running pip uninstall litellm to clean up is one of those starts.

The Mini Shai-Hulud, Miasma, and Hades waves on PyPI used *-setup.pth hooks to launch a JavaScript stealer through Bun. By June 8, 2026, we had tracked 60 malicious PyPI artifacts across 37 packages in that campaign.

CPython has started to close this door. PEP 829, accepted in April 2026 for Python 3.15, deprecates import lines in .pth files in favor of declared entry points. Its author, Barry Warsaw, states the problem directly: "import lines are executed using exec() during interpreter startup, which opens a broad attack surface." The deprecation is slow by design. Under the PEP's expected schedule, import lines keep executing through Python 3.17.

Import time: where attackers moved

#

Import is the third execution point: module top-level code runs when Python imports it. In the Telnyx Python SDK compromise of March 2026, the TeamPCP payload sat in src/telnyx/_client.py, which every import telnyx loads. Our analysis noted that this placement sidesteps scanners that only watch install-time hooks. The Hades wave went further and hid its launcher in compiled .abi3.so extensions, which a review of the .py files would not see.

Blocking build-time execution is necessary, and --only-binary :all: does it. It is not sufficient on PyPI, because every 2026 compromise described above executed after install. The only check that covers all three points is analysis of what the artifact contains before it reaches site-packages.

Why does --extra-index-url cause dependency confusion?

#

--extra-index-url causes dependency confusion because pip merges every index into one candidate pool and installs the highest version, regardless of which index served it. pip's documentation is explicit: "There is no priority in the locations that are searched."

We reproduced the failure with two local indexes. The internal index serves acme-billing 1.4.2. A public index, standing in for PyPI, serves an attacker's acme-billing 99.0.0:

Plain text
$ python -m pip install --dry-run \
    --index-url file://$PWD/internal \
    --extra-index-url file://$PWD/public acme-billing
Looking in indexes: file:///home/claude/demo/internal, file:///home/claude/demo/public
Processing ./public/acme-billing/acme_billing-99.0.0-py3-none-any.whl
Would install acme-billing-99.0.0

This is the mechanism behind the PyTorch nightly compromise. Between December 25 and December 30, 2022, Linux users who installed PyTorch-nightly with pip received a malicious torchtriton from PyPI instead of PyTorch's own index. PyTorch's advisory summarized the design flaw: pip "will install their version by default." The payload read SSH keys and the first 1,000 files in $HOME, then exfiltrated them over DNS.

uv chose the opposite default. Its documentation says uv "will stop at the first index on which a given package is available," and the pip-style behavior is available only under an option uv names unsafe-best-match.

Two fixes work for pip. The first is architectural: use exactly one --index-url, pointed at a proxy or artifact repository that serves internal names exclusively and mirrors public ones. The second is pinning with hashes. With acme-billing==1.4.2 and its hash in the requirements file, the same command resolved to 1.4.2 and ignored 99.0.0. Hashes do not fix the index configuration. They make it irrelevant for the packages you pinned.

Hash-checking mode and lock files

#

pip's hash-checking mode verifies that every downloaded file matches a sha256 you recorded earlier. It has existed since pip 8.0 in January 2016. It turns on when any requirement carries --hash, or explicitly with --require-hashes, and it is all-or-nothing. pip's own secure installs guide opens with the reason: "By default, pip does not perform any checks to protect against remote tampering."

Plain text
$ cat req-hashed.txt
acme-billing==1.4.2 --hash=sha256:46b28770c76dc8f892b9a885fc77148b692c09e206191f6024e026d7719b96da

$ # append one line to the wheel's __init__.py, then:
$ python -m pip install --dry-run --index-url file://$PWD/internal -r req-hashed.txt
Processing ./internal/acme-billing/acme_billing-1.4.2-py3-none-any.whl (from -r req-hashed.txt (line 1))
ERROR: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE. If you have updated the package versions, please update the hashes. Otherwise, examine the package contents carefully; someone may have tampered with them.
    acme-billing==1.4.2 from file:///home/claude/demo/internal/acme-billing/acme_billing-1.4.2-py3-none-any.whl (from -r req-hashed.txt (line 1)):
        Expected sha256 46b28770c76dc8f892b9a885fc77148b692c09e206191f6024e026d7719b96da
             Got        9ec6dd3e619093eccaced0b521ec444cb55213e876d9c1737a57483ba4a1d9e4

Hash-checking mode also requires every transitive dependency to be pinned and hashed. A requirements.txt that hashes only requests==2.32.5 fails as soon as pip reaches charset_normalizer<4,>=2. In practice that means generating the file with a tool, not writing it by hand.

Build dependencies are a gap. We hash-pinned our demo sdist and ran pip install --dry-run in hash-checking mode. pip verified the sdist, then installed its build requirement, setuptools>=64, into the isolated build environment unpinned and unhashed: it fetched setuptools 84.2.1, the latest at the time. Hash-checking mode for your requirements does not extend to the tools that build them. pip 25.3 added --build-constraint, and hashes in that file are enforced: a wrong hash for setuptools failed the build in our test. That second file is easy to miss.

pylock.toml is the standard lock format, still experimental in pip. PEP 751, accepted March 31, 2025, defines pylock.toml with mandatory hashes. pip 25.1 added pip lock to write one, and pip 26.1 can install from one with -r pylock.toml. As of pip 26.2.2, both print an "experimental" warning. Tools such as uv and Poetry write their own lock files today.

TOML, also INI
[[packages]]
name = "certifi"
version = "2026.10.23"

[[packages.wheels]]
name = "certifi-2026.10.23-py3-none-any.whl"
url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl"

[packages.wheels.hashes]
sha256 = "e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1"

Hashes are integrity, not trust. A hash proves you received the same bytes you locked. If you locked LiteLLM 1.107.0 during its first hour, hash-checking mode would reinstall the malicious wheels perfectly on every rebuild. Hashes also cover only what is in the file. A RUN pip install somepackage line in a Dockerfile, a CI step that installs a linter, or a pipx run command never touches your lock file, and a clean lock file proves nothing about them.

pip security controls compared

#

Each control below closes one path and leaves others open. The table compares them as of pip 26.2.2:

ControlSinceWhat it stopsWhat it does not stop
--only-binary :all:Long-standingsdist build code during install, download, and dry run.pth hooks, import-time payloads, malicious wheels
--prefer-binaryLong-standingA newer sdist outranking an older wheelPackages that publish no wheel for your platform
Hash-checking mode (--require-hashes)pip 8.0Tampered or swapped files, version drift, merged-index confusion for pinned namesA malicious release that was locked before detection; build dependencies
--build-constraint with hashespip 25.3Unpinned, unverified build backends such as setuptoolsAnything outside the isolated build environment
One --index-url, no --extra-index-urlAlways availableDependency confusion across indexesTyposquats and compromised packages on that index
--uploaded-prior-to P3Dpip 26.0 (durations in 26.1)Releases younger than the window, which covers most fast takedownsMalware that outlasts the window
Virtual environment (PEP 668)pip 23.0Overwriting the OS's own Python packagesAny malicious code. A venv is not a sandbox
Pre-download malware check (sfw pip install)Socket Firewall Free, September 2025Known malicious artifacts, including transitive ones, before downloadCached artifacts, unscanned versions; AI-flagged artifacts only warn

Our verdict: for CI and production images, use one index, --only-binary :all:, and a hashed lock file, plus hashed build constraints for anything that must build from source. Add a cooldown of at least three days to unattended upgrades. On developer machines, ad-hoc installs never touch a lock file, so a check that runs before every download is the control that actually applies.

The cooldown control deserves a closer look. --uploaded-prior-to arrived in pip 26.0 in January 2026, and pip 26.1 added duration syntax. With a 10-day window on October 29, 2026, pip skipped the 26.2.2 release from nine days earlier:

Plain text
$ python -m pip install --dry-run --no-deps --ignore-installed pip
Would install pip-26.2.2
$ python -m pip install --dry-run --no-deps --ignore-installed --uploaded-prior-to P10D pip
Would install pip-26.2.1

A dependency cooldown works because registries remove malware quickly once it is reported. In 2025, PyPI handled 66% of more than 2,000 malware reports within 4 hours and 92% within 24 hours. A three-day window (P3D) puts most takedowns before you resolve. Malware that nobody reports is just as dangerous on day four. The filter depends on upload-time in the index response. Against an HTTP index that lacks it, pip 26.2.2 failed closed with "does not provide upload-time metadata" rather than silently ignoring the flag. That is the right default.

Socket Firewall Free adds a check that does not depend on metadata the attacker controls. Prefixing the command, as in sfw pip install requests, runs pip behind an ephemeral local proxy that asks Socket's API about each artifact before pip downloads it, including transitive dependencies. It has documented limits. It cannot block artifacts already in pip's cache, so the documentation says to run pip cache purge first. The free version blocks confirmed malware but only warns on AI-detected malware awaiting human review, and it does not block unscanned versions.

What pip still gets wrong

#

pip's defaults still assume that installing a package and running it are separate decisions. They are not. npm v12 turned off dependency install scripts by default in July 2026, following pnpm 10. pip has no equivalent default. It builds any sdist the resolver selects, prefers a newer sdist over an older wheel, and runs that build during pip download and --dry-run. We think pip should prefer wheels by default and require explicit opt-in before building an sdist for a project that already publishes wheels. The breakage would be real, mostly for packages without wheels for every platform. It would also be visible and fixable, which silent execution is not.

The harder problem is that pip has no concept of who published an artifact. As of pip 26.2.2, it does not verify PyPI's PEP 740 attestations at install time. Provenance alone would not have been enough anyway. The LiteLLM 1.107.0 container image carried build provenance, and that provenance recorded the source repository as Berri-AI/litellm, one hyphen away from the real BerriAI/litellm. The provenance existed and pointed at the wrong repository. On the PyPI side, the index served the wheels correctly and their hashes matched. Every check pip performs passed, and the payload still ran at the next interpreter start. Until Python's packaging tools can tell a maintainer's release from a release made with a maintainer's stolen credentials, the only defense that sees all three execution points is reading what is inside the wheel before it lands.