# 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

Source: https://socket.dev/glossary/pip-python-package-index

Last updated: 2026-10-29

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](https://socket.dev/glossary/python-package-index-pypi) (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](https://pypi.org/project/pip/), 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](https://peps.python.org/pep-0668/) lets a distribution mark its interpreter as externally managed, and pip has honored that marker since version 23.0:

```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](https://socket.dev/glossary/virtual-environment-python), 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](https://socket.dev/glossary/wheel-python-packaging-format) 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](https://socket.dev/blog/pypi-fixes-high-severity-issues-found-in-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](https://socket.dev/glossary/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 three points where pip lets package code run: building a source distribution during resolution, interpreter startup through .pth files, and module import. Installing a wheel itself runs no code.

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

| Stage | Trigger | What runs | Seen in | Control |
| --- | --- | --- | --- | --- |
| 1. Resolve: build sdist metadata | pip install, pip download, pip install --dry-run | The sdist's build backend, such as setup.py egg\_info | Our demo sdist, which ran twice on a dry run | --only-binary :all: |
| 2. Install: unpack the wheel | End of pip install | No code. Files are copied into site-packages, including any .pth, sitecustomize.py, or native .so | Every wheel install | Hash-checking mode proves the bytes match the lock, not that they are safe |
| 3. Next interpreter start | Any Python process, including pip itself | import lines in .pth files, then sitecustomize | LiteLLM 1.107.0 (cook.pth), the Hades wave (\*-setup.pth) | None in pip. PEP 829 begins a deprecation in Python 3.15 |
| 4. Import | import package | Module top-level code and native extension initialization | Telnyx 4.87.1 (\_client.py), Hades .abi3.so extensions, torchtriton | None 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](https://socket.dev/glossary/setup-py-python) 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=[])
```

```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](https://socket.dev/glossary/python) entry reproduces this with a one-line hook.

The [October 2026 LiteLLM compromise](https://socket.dev/blog/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](https://socket.dev/blog/mini-shai-hulud-miasma-and-hades-worms-target-bioinformatics-and-mcp-developers-via-malicious) 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](https://peps.python.org/pep-0829/), 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](https://socket.dev/blog/telnyx-python-sdk-compromised) of March 2026, the [TeamPCP](https://socket.dev/blog/teampcp-targeting-security-tools-across-oss-ecosystem) 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](https://socket.dev/glossary/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:

```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](https://pytorch.org/blog/compromised-nightly-dependency/). 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](https://docs.astral.sh/uv/concepts/indexes/) 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](https://pip.pypa.io/en/stable/topics/secure-installs/) opens with the reason: "By default, pip does not perform any checks to protect against remote tampering."

```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](https://socket.dev/glossary/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](https://socket.dev/blog/python-adopts-standard-lock-file-format-for-reproducible-installs), 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
[[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:

| Control | Since | What it stops | What it does not stop |
| --- | --- | --- | --- |
| --only-binary :all: | Long-standing | sdist build code during install, download, and dry run | .pth hooks, import-time payloads, malicious wheels |
| --prefer-binary | Long-standing | A newer sdist outranking an older wheel | Packages that publish no wheel for your platform |
| Hash-checking mode (--require-hashes) | pip 8.0 | Tampered or swapped files, version drift, merged-index confusion for pinned names | A malicious release that was locked before detection; build dependencies |
| --build-constraint with hashes | pip 25.3 | Unpinned, unverified build backends such as setuptools | Anything outside the isolated build environment |
| One --index-url, no --extra-index-url | Always available | Dependency confusion across indexes | Typosquats and compromised packages on that index |
| --uploaded-prior-to P3D | pip 26.0 (durations in 26.1) | Releases younger than the window, which covers most fast takedowns | Malware that outlasts the window |
| Virtual environment (PEP 668) | pip 23.0 | Overwriting the OS's own Python packages | Any malicious code. A venv is not a sandbox |
| Pre-download malware check (sfw pip install) | Socket Firewall Free, September 2025 | Known malicious artifacts, including transitive ones, before download | Cached 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:

```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](https://socket.dev/glossary/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](https://blog.pypi.org/posts/2025-12-31-pypi-2025-in-review). 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](https://docs.socket.dev/docs/socket-firewall-free) 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](https://socket.dev/blog/npm-12) turned off dependency install scripts by default in July 2026, following [pnpm 10](https://socket.dev/blog/pnpm-10-0-0-blocks-lifecycle-scripts-by-default). 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.
