Back to glossary

Python Package Index (PyPI)

PyPI is the Python Package Index, the PSF-run public registry that pip, uv, and other installers download Python packages from by default.

Also known as pypi.org

The Python Package Index (PyPI) is the public registry for Python packages, run by the Python Software Foundation at pypi.org. Installers such as pip and uv query its Simple API for a project's files, then download wheels or source archives. Anyone can publish, and PyPI reviews packages after reports, so malware is removed after release rather than blocked before it.

That last property decides how much you can trust PyPI. PyPI verifies that an authorized account uploaded a file and that the file's bytes never change. It does not check what the code does. In 2025, PyPI handled 66% of malware reports within four hours and 92% within 24 hours (PyPI's 2025 review). That is fast, and it is still a window. In the LiteLLM 1.107.0 compromise on October 21, 2026, the malicious wheels went public on PyPI at 11:39:51 UTC and Socket's scanner flagged them at 11:46:30 UTC. Any environment that installed the release before PyPI pulled it handed a credential stealer to the next Python process it started.

Our position: PyPI is a transport with strong integrity guarantees and no behavioral guarantees. Integrity comes from PyPI. Safety has to be checked by the consumer, per artifact, before install.

What is PyPI?

#

PyPI is the default package index for Python tooling. pip, uv, Poetry, and Pipenv all resolve package names against https://pypi.org/simple/ unless configured otherwise. PyPI is the server, not the installer. People confuse the two constantly, but they are separate projects with separate threat models.

As of October 29, 2026, the pypi.org front page lists 928,480 projects, 10,033,184 releases, 22,455,825 files, and 1,134,935 users. In 2025 it served 2.56 trillion requests and 1.92 exabytes of data, an average of about 81,000 requests per second, according to the same review. The service runs on Warehouse, an open source Pyramid application whose upload code we quote below.

PyPI hosts two kinds of artifact. A source distribution (sdist) is a .tar.gz that the installer must build locally, which runs the project's build backend. A wheel is a prebuilt ZIP archive that the installer unpacks without a build step. Most projects publish both. The difference decides when third-party code first runs on your machine.

What is PyPI used for?

#

PyPI is used to install published Python packages and to publish new ones. Installing is anonymous: pip install requests downloads files without an account. Publishing requires an account with two-factor authentication, which PyPI has enforced since January 1, 2024, or a CI identity through Trusted Publishing.

Maintainers build their sdist and wheels, then upload them to https://upload.pypi.org/legacy/ with twine or uv publish. The endpoint is still called legacy because its replacement, the Upload 2.0 API in PEP 694, was not yet standardized as of July 2026.

The read side is an open standard. Any server that implements the Simple Repository API can stand in for PyPI, which is how private indexes, mirrors, and caching proxies work. Mixing a private index with PyPI through --extra-index-url is the classic setup for dependency confusion: pip merges candidates from every configured index and picks the best version, regardless of which index served it.

How does pip find and download a package from PyPI?

#

pip finds a package on PyPI by fetching the project's Simple API page, then downloading the chosen file from a separate file host. Here is the request a current installer makes for sampleproject, the Python Packaging Authority's reference package. The response is real and trimmed to one of its six files:

Bash
$ curl -s -H 'Accept: application/vnd.pypi.simple.v1+json' \
    https://pypi.org/simple/sampleproject/
{
  "meta": { "_last-serial": 25862117, "api-version": "1.4" },
  "name": "sampleproject",
  "project-status": { "status": "active" },
  "versions": ["1.2.0", "1.3.0", "1.3.1", "2.0.0", "3.0.0", "4.0.0"],
  "files": [
    {
      "filename": "sampleproject-4.0.0.tar.gz",
      "url": "https://files.pythonhosted.org/packages/48/8c/c18d25735962870ccb6d1cd2ac7bde40008a332211055e260cb7ec4c6bab/sampleproject-4.0.0.tar.gz",
      "hashes": { "sha256": "0ace7980f82c5815ede4cd7bf9f6693684cec2ae47b9b7ade9add533b8627c6b" },
      "requires-python": ">=3.9",
      "size": 5760,
      "upload-time": "2024-11-06T22:37:10.868088Z",
      "yanked": false,
      "core-metadata": false,
      "provenance": "https://pypi.org/integrity/sampleproject/4.0.0/sampleproject-4.0.0.tar.gz/provenance"
    }
  ]
}

Each field answers a security question for the installer:

  • hashes.sha256 is the digest the installer checks after download. Hash-checking mode and lockfiles pin this value.
  • yanked is false or a reason string, defined by PEP 592.
  • project-status is the lifecycle marker from PEP 792, added in API version 1.4.
  • upload-time, from PEP 700, is what makes install-time cooldowns possible.
  • core-metadata is set on wheels whose METADATA file PyPI serves separately under PEP 658. The sdist above has none.
  • provenance points to the file's PEP 740 attestations, covered below.

The JSON form comes from PEP 691, which replaced HTML scraping with a format the standard library can parse. PyPI still serves the original HTML page, with the same data packed into anchors and URL fragments.

Project names are normalized before lookup. PEP 503 lowercases names and treats runs of -, _, and . as one separator. PyPI redirects every variant to the canonical URL:

Bash
$ curl -sI https://pypi.org/simple/Python_DateUtil/ | grep -iE '^(HTTP|location)'
HTTP/2 301
location: https://pypi.org/simple/python-dateutil/

Normalization closes one class of typosquatting: Python_DateUtil cannot be registered as a project separate from python-dateutil. Names that normalize differently but read alike, such as python-dateutils, remain open. Since 2025, PyPI flags likely typosquats when a project is created, according to its 2025 review.

The file itself lives on files.pythonhosted.org, and its path is not arbitrary. In /packages/48/8c/c18d2573…/sampleproject-4.0.0.tar.gz, the hex string is the file's BLAKE2b-256 digest, 488cc18d2573…, which the JSON API reports as digests.blake2b_256. PyPI's file host is content-addressed: different bytes always get a different URL.

Figure 1 shows the four requests behind installing sampleproject 4.0.0. First, the installer sends GET https://pypi.org/simple/sampleproject/ with the Accept header application/vnd.pypi.simple.v1+json. The response carries Cache-Control: max-age=600 and lists every file with its SHA-256, yanked flag, upload time, Requires-Python, project status, and provenance URL. Second, for a wheel, the resolver fetches <file URL>.metadata from files.pythonhosted.org to read Requires-Dist without downloading the wheel. Third, it downloads the wheel from its content-addressed path, /packages/d7/73/c16e…/sampleproject-4.0.0-py3-none-any.whl, and checks the SHA-256 from step one. Fourth, and optionally, a client can fetch https://pypi.org/integrity/sampleproject/4.0.0/<file>/provenance to get Sigstore-signed attestations naming the publisher. pip does not make the fourth request.

What PyPI checks when a package is uploaded

#

PyPI's upload handler is warehouse/forklift/legacy.py. We read it on the main branch on October 29, 2026. Seven of its gates matter most for security, in the order the handler runs them:

GateRuleRejection
1. UploaderUser accounts need a verified primary email and 2FA. Trusted Publishing tokens skip these user checks because no user is involved.Permission or 2FA error
2. Size100 MiB per file and 10 GiB per project by default. Organization projects default to double in the code. Admins can raise a project's file limit to 1 GiB.File too large. / Project size too large.
3. DigestClient-sent MD5, SHA-256, and BLAKE2b-256 digests must match the bytes received.The digest supplied does not match a digest calculated from the uploaded file.
4. DuplicateThe same filename with identical bytes returns 200 OK and changes nothing. The same filename with different bytes fails.File already exists
5. Filename historyA filename that ever existed can never be uploaded again, even after deletion.This filename was previously used by a file that has since been deleted.
6. Release ageNew files cannot join a release older than 14 days.Uploading new files to releases older than 14 days is not allowed.
7. Artifact metadataA wheel must contain {name}-{version}.dist-info/METADATA. Its dependency fields are checked only for direct URL references.does not contain the required METADATA file

The size limits are the MAX_FILESIZE and MAX_PROJECT_SIZE constants in warehouse/constants.py. Gate 4 is why twine upload --skip-existing works: re-uploading an identical file is a no-op.

Gates 4 through 6 make PyPI releases close to immutable. A published filename can never point at different bytes. A deleted filename can never come back. Since July 2026, a stolen token also cannot add a file to an old release. Before that change, an attacker could attach a wheel for a new platform tag to a long-stable version. An unhashed pip install pkg==1.2.3 on a platform that used to fall back to the sdist would then pick the new wheel.

PSF Security Developer-in-Residence Seth Larson's announcement says users "should not yet rely on this behavior" until PEP 694 defines release semantics (PyPI). The source comment is more specific. The 14-day check "explicitly doesn't protect against users deleting and recreating releases in the UI." It stops compromised tokens and workflows, not a compromised account.

None of the seven gates looks at behavior. The upload handler does not execute, diff, or classify code. Every check verifies identity, integrity, or format. Review of what a package does happens afterward, driven by reports.

PyPI keeps two copies of a wheel's dependencies

#

PyPI stores two descriptions of a wheel's dependencies, and nothing forces them to agree. The first comes from the upload request. twine and uv send name, version, and Requires-Dist as form fields, and Warehouse builds the project page and the JSON API from them. The second is the METADATA file inside the wheel. Warehouse extracts it and serves it verbatim at <file URL>.metadata. pip 22.3 and later read that file during resolution.

Trail of Bits found the gap in PyPI's second external security audit, conducted February 23 to March 20, 2026. Its scenario: a wheel declares no dependencies in the upload form and Requires-Dist: evil-helper>=1.0 in its own METADATA. The project page and JSON API show zero dependencies, and pip installs evil-helper. PyPI accepted the finding rather than fixing it, citing changes to core upload behavior and backfills.

Warehouse has since narrowed the gap without closing it. As of October 29, 2026, validate_artifact_dependencies parses the wheel's METADATA but rejects only direct URL references. Its docstring names the problem: "the form is supplied by the uploader and can omit dependencies that the artifact still declares." You can read both sources yourself:

Bash
$ curl -s https://pypi.org/pypi/sampleproject/json | jq .info.requires_dist
[
  "peppercorn",
  "check-manifest; extra == \"dev\"",
  "coverage; extra == \"test\""
]

$ curl -s https://files.pythonhosted.org/packages/d7/73/c16e5f3f0d37c60947e70865c255a58dc408780a6474de0523afd0ec553a/sampleproject-4.0.0-py3-none-any.whl.metadata \
    | grep Requires-Dist
Requires-Dist: peppercorn
Requires-Dist: check-manifest ; extra == 'dev'
Requires-Dist: coverage ; extra == 'test'

For sampleproject the two agree. For a hostile upload they do not have to. Trail of Bits named pip-audit and SBOM generators among the tools that would report zero dependencies. Any scanner that reads the JSON API is auditing the uploader's claim, not the artifact. Analyze what is in the package, not what the registry says about it.

Attestations record where a file was built, not what it does

#

The 2026 attacks in this article went through publishers, not through PyPI itself. Maintainer account takeover through phishing, leaked tokens, and expired email domains is a common path. PyPI's account defenses are concrete. PyPI has required 2FA since January 2024. Periodic expired-domain checks had unverified more than 1,800 email addresses by August 2025, which blocks password resets through a re-registered domain (PyPI).

Trusted Publishing, which PyPI launched in April 2023, replaces long-lived API tokens with an OIDC exchange. A CI job presents an identity token from GitHub Actions, GitLab, or another supported provider, and PyPI mints an upload token that lasts 15 minutes. More than 50,000 projects used Trusted Publishing by the end of 2025, covering over 20% of that year's uploads, according to PyPI's 2025 review.

Trusted Publishing also produces PEP 740 attestations. PyPI introduced digital attestations in November 2024. An attestation is a Sigstore-signed in-toto statement that binds a file's SHA-256 digest to the publisher identity. 17% of 2025 uploads carried one. Here is the attestation for sampleproject 4.0.0, decoded:

Bash
$ curl -s https://pypi.org/integrity/sampleproject/4.0.0/sampleproject-4.0.0.tar.gz/provenance \
    | jq '.attestation_bundles[0].publisher'
{
  "claims": null,
  "environment": "",
  "kind": "GitHub",
  "repository": "pypa/sampleproject",
  "workflow": "release.yml"
}

$ curl -s https://pypi.org/integrity/sampleproject/4.0.0/sampleproject-4.0.0.tar.gz/provenance \
    | jq -r '.attestation_bundles[0].attestations[0].envelope.statement' | base64 -d | jq .
{
  "_type": "https://in-toto.io/Statement/v1",
  "subject": [
    {
      "name": "sampleproject-4.0.0.tar.gz",
      "digest": {
        "sha256": "0ace7980f82c5815ede4cd7bf9f6693684cec2ae47b9b7ade9add533b8627c6b"
      }
    }
  ],
  "predicateType": "https://docs.pypi.org/attestations/publish/v1",
  "predicate": null
}

The digest matches the sha256 in the Simple API response above. That is a strong statement about origin, and it says nothing about intent. In the Ultralytics compromise of December 2024, versions 8.3.41 and 8.3.42 were built by the project's real GitHub workflow after a cache-poisoning attack. Both carried valid attestations. In October 2026, the LiteLLM 1.107.0 container image carried build provenance that recorded its source repository as Berri-AI/litellm, one hyphen away from the real BerriAI/litellm.

Provenance is evidence to check against an expected identity: this repository, this workflow, this environment. As of October 2026, pip 26.2.2, the current release, has no option to verify PEP 740 attestations at install. The exchange itself also had a flaw. The 2026 audit found a 25-second window in which a leaked GitHub Actions OIDC token could be replayed to mint fresh upload tokens. PyPI has fixed it.

What happens to a malicious package on PyPI?

#

A malicious package on PyPI stays installable until someone reports it and a PyPI administrator acts. The admin quarantines the project or removes the bad releases. Quarantine, in use since August 2024, hides a project from the Simple API and freezes it for the owner. "Project is not installable (hidden from simple index) while in quarantine," wrote Mike Fiedler, PyPI's safety and security engineer, in the design writeup. By December 2024, admins had quarantined about 140 projects. One left quarantine after its owner fixed it, and the rest were removed.

Quarantine is one of five states a project or release can be in on PyPI, and most of them do not change what an installer does:

StateScope and who sets itFiles in Simple APINew uploadsWhat pip does
activeProject. Default.YesAllowedInstalls normally
yankedRelease. Maintainer.Yes, with a yanked reasonNot applicableSkips it unless pinned with == or ===, then installs with a warning
archivedProject. Maintainer.YesBlockedInstalls normally. PEP 792 says installers may warn.
quarantinedProject. PyPI admin.NoBlocked, and the owner cannot editReports no matching distribution
removedRelease or project. Admin or owner.NoThe filenames can never be reusedReports no matching distribution

PEP 792 also defines deprecated, but PyPI does not support it. As of October 29, 2026, Warehouse's lifecycle enum has quarantine and archived states only.

Yanking is not a security control. A yank tells resolvers a release is broken. The files stay downloadable, and an exact pin still installs them. When requests yanked 2.32.0 and 2.32.1, both released in May 2024, over a conflict with its CVE-2024-35195 fix, pinned installs kept working:

Plain text
$ pip install --dry-run --no-deps 'requests==2.32.0'
WARNING: The candidate selected for download or install is a yanked version: 'requests' candidate (version 2.32.0 at https://files.pythonhosted.org/packages/24/e8/09e8d662a9675a4e4f5dd7a8e6127b463a091d2703ed931a64aa66d00065/requests-2.32.0-py3-none-any.whl (from https://pypi.org/simple/requests/) (requires-python:>=3.8))
Reason for being yanked: Yanked due to conflicts with CVE-2024-35195 mitigation
Would install requests-2.32.0

$ pip install --dry-run --no-deps 'requests>=2.32.0,<2.32.3'
Would install requests-2.32.2

Removal is final and leaves almost no trace. On October 29, 2026, we checked the malicious releases from three 2026 incidents against the live index. These were LiteLLM 1.107.0; Telnyx 4.87.1 and 4.87.2, published by TeamPCP in March; and the Hades wave wheels for coolbox, dynamo-release, and ufish from June. Every malicious version was gone from the Simple API, and every project still reported active. A pinned install now fails like a typo:

Plain text
$ pip install --dry-run --no-deps 'litellm==1.107.0'
ERROR: Ignored the following yanked versions: 0.1.2291, 0.1.7701, 0.1.7713
ERROR: Could not find a version that satisfies the requirement litellm==1.107.0 (from versions: 0.1.0, 0.1.1, … 1.106.6, 1.107.0.dev1, 1.107.0.dev2, 1.107.0.dev3, 1.107.0rc1, 1.107.0rc2, 1.108.0rc1, 1.109.0.dev1)
ERROR: No matching distribution found for litellm==1.107.0

Removal has three consequences that defenders should plan for:

  • A removed release leaves no record in PyPI's APIs. The JSON API's vulnerabilities field lists OSV advisories for versions that exist. A deleted malicious version has no version page and no CVE.
  • Removal does not reach copies. pip caches, CI caches, internal mirrors, and container layers keep the file. Our Telnyx analysis told mirror operators to confirm they had purged the quarantined versions.
  • A clean environment today does not prove a clean environment last week. Code fetched by one-off runs such as uvx or pipx run, or by global installs, never lands in a project lockfile. We made the same point about npx after the axios compromise.

Deleting used to be the only way to retire a project, and deletion freed the name for anyone to register. That opening is revival hijacking. Since January 2025, PyPI's project archival lets maintainers retire a project without giving up the name.

Where PyPI's model still breaks

#

PyPI's guarantees are about bytes and identity. PyPI proves that a file is the same bytes an authorized publisher uploaded, and increasingly which workflow built it. It does not prove the bytes are safe. As of October 2026, three gaps keep it that way. Review is reactive, so malware is live from upload until a report is acted on. The safety signals are optional: PEP 792 lets installers ignore status markers, pip does not verify attestations, and the JSON API can disagree with what pip installs. Removal does not propagate to the caches and mirrors that many production installs read from.

The check therefore has to sit between PyPI and the environment. A package firewall does it at the network layer. Socket Firewall Free, for example, wraps the install command, as in sfw pip install requests or sfw uv pip install flask. It intercepts each artifact request and blocks confirmed malware using Socket's API (docs). It blocks only confirmed malware and warns on packages that are flagged but unconfirmed. It cannot block a file already in the local package cache, so the docs recommend clearing the cache first.

A dependency cooldown attacks the same window from the other side. pip's --uploaded-prior-to P3D (in pip 26.2.2) and uv's exclude-newer ignore files uploaded after a cutoff, using the upload-time field PyPI already serves. A cooldown delays security fixes by the same interval. It does nothing against malware that stays unreported longer than the cooldown.

The deeper problem is that PyPI's trust model was built around the maintainer, and attackers now go through the maintainer's pipeline. LiteLLM, Telnyx, and the Hades wave all published through legitimate accounts or release workflows. An index that can only judge the uploader will keep shipping whatever a compromised uploader sends. The judgment has to move to the artifact.