Every upload to PyPI is still a web form from the early 2000s, submitted by a robot. PEP 694 replaces it with sessions, staged releases, and one atomic publish. This guide shows the path today, the path after, and the four years of argument in between.
Orientation
There has never been a standardized upload API for Python package indexes. Everyone reverse-engineers what PyPI does. What PyPI does is accept a multipart/form-data POST. The original upload API was not an API at all: it was a browser form on the first PyPI, and the tools learned to submit that form programmatically. Twenty-odd years later, the whole ecosystem still holds one HTTP request open while the index validates, scans, stores, and journals a file.
PEP 694 defines Upload API 2.0: a JSON protocol where a release is a publishing session, each file goes up in its own file upload session through a negotiated mechanism, the whole set can be installed and tested from an unguessable stage URL before anyone else can see it, and a single publish makes every file public at the same instant, or none of them.
| Legacy (1.0) | Upload 2.0 | |
|---|---|---|
| Shape | 1 file = 1 synchronous POST | Session of N files, then 1 publish |
| Encoding | multipart/form-data | application/vnd.pypi.upload.v2+json |
| Atomic unit | a single file | the whole release |
| Test before public | test.pypi.org, a separate account | staged preview on the real index |
| Errors | text in the HTTP reason phrase | RFC 9457 problem-details JSON |
| Metadata | ~40 form fields beside the file | none: the file is the source of truth |
| Reserve a name | upload a stub package | publish an empty session |
| Big files | one unresumable body | negotiable mechanisms, e.g. S3 multipart |
The PEP does not deprecate the legacy API. Both will run side by side, at different endpoints, for years. That is why its atomicity rules keep talking about conflicts arriving "through the legacy API." The two doors open into the same room.
How it works today
Six stages, from the client's first byte to the moment strangers can install the thing:
One request per file: :action=file_upload, protocol_version=1, the artifact as a content part, digests (sha256 or blake2_256 required), and every core-metadata field flattened into ~40 form keys whose names only roughly match METADATA. Auth is HTTP Basic with username __token__, or a Trusted Publishing OIDC exchange that mints the same kind of short-lived token on a third hostname (auth.domain).
upload.pypi.org appears nowhere in the Fastly Terraform. It is a bare A record pointing at warehouse.ingress.us-east-2.pypi.io, the Cabotage ingress, and it answers as gunicorn with no x-cache headers. The main pypi.org service even has VCL to un-shield POSTs, but a CDN still buffers and caps large request bodies, so uploads get their own hostname that skips the edge entirely. TestPyPI is asymmetric: test.pypi.org/legacy/ does go through Fastly.
On the warehouse side, the split is a Pyramid route domain: every forklift route binds to FORKLIFT_DOMAIN (warehouse/forklift/__init__.py), and the same paths on pypi.org return 410 Gone with a pointer to the upload host.
One view function runs everything while the client waits: form and metadata validation, project auto-creation (rate limited: 20/hour per user, 40/hour per IP), permission, verified-email and 2FA checks, README rendering, release creation (which row-locks every release of the project to re-sort them), filename rules, streaming the body to a temp file while computing md5+sha256+blake2b and enforcing size limits (100 MiB/file and 10 GiB/project by default, overrides capped at 1 GiB), archive inspection with a decompression-bomb check and a YARA scan of every member, PEP 625/639 filename and license checks, wheel RECORD and platform-tag validation, PEP 658 METADATA extraction into a sidecar file, PEP 740 attestation verification, and finally the object-storage PUT to the archive bucket and the journal writes, all before HTTPOk. The DB connection sets a 600-second statement timeout because this can genuinely take that long.
Re-uploading a byte-identical file is a silent no-op: the transaction is doomed and the server answers 200 OK anyway. A same-name-different-bytes upload gets a 400 whose message must begin with the literal string File already exists, because twine --skip-existing matches on that prefix. And every error travels in the HTTP reason phrase, a field deprecated since HTTP/2, encoded down to latin-1 so gunicorn doesn't choke.
Only two things defer to Celery: copying the file from the archive bucket to the B2 cache bucket that files.pythonhosted.org serves (Fastly → B2 primary, restart-failover to an S3 Glacier archive), and inserting the metadata row into the public BigQuery distribution_metadata table. The file is durable and DB-visible on response but not yet in the CDN-served bucket.
Each file appears in the simple index the moment its own POST commits. A release with an sdist and thirty wheels is public in thirty-one increments. If a consumer resolves the project after the sdist lands but before their platform's wheel does, they build from source. Or fail. This is the race that downstream CI feels as "the release exists but is broken for an hour."
Checkpoint 1 · the legacy path
The new mechanism
Upload 2.0 is a small number of JSON resources linked together. Everything below is from the current draft (post–Round 4, July 2026). Every request carries Content-Type: application/vnd.pypi.upload.v2+json, except executing an upload mechanism itself, which uses whatever that mechanism specifies (application/octet-stream for http-post-bytes). The proposed PyPI root is https://upload.pypi.org/2.0.
POST /2.0 HTTP/1.1
Content-Type: application/vnd.pypi.upload.v2+json
{"meta": {"api-version": "2.0"}, "name": "foo", "version": "1.0"}
→ 201 Created
{"meta": {"api-version": "2.0"},
"links": {"session": "…", "upload": "…", "publish": "…",
"extend": "…", "stage": "…"},
"mechanisms": ["http-post-bytes"],
"session-token": "<unguessable>",
"expires-at": "2026-08-20T12:00:00Z", "status": "open", "files": {}}
POST <links.upload>
{"meta": {"api-version": "2.0"}, "filename": "foo-1.0-py3-none-any.whl",
"size": 1048576, "hashes": {"sha256": "…"}, "mechanism": "http-post-bytes"}
→ 202 Accepted (then POST the raw bytes to mechanism.file_url,
then POST links.complete, poll until "completed")
Details that matter, each of which exists because someone argued for it:
409 Conflict with a Location header to the existing session. That is also how several Trusted Publishing jobs join one session. twine upload foo-1.1* foo-2.0* bar-2.0* means three sessions; the client manages that invisibly.filename, size, and hashes are required up front so the index can reject a quota overrun or a filename conflict before a gigabyte crosses the wire. The legacy API can only fail after.completed or the publish is a 409 naming the offenders. Nothing is silently included, nothing silently dropped.409 until the publish succeeds (reservation becomes permanent) or fails (reservation released).0.0.1 stub uploads.Round 3 made both lifecycles normative. The asymmetry between them is the part worth memorizing:
| Resource | States | On failure |
|---|---|---|
| publishing session | open → processing → published / error / canceled |
error is fully editable: fix the file, publish again |
| file upload session | pending → processing → completed / error / canceled |
error is unrecoverable: delete the file, start a new session for it |
A session can be edited back to health because its files are replaceable; a half-uploaded file cannot be edited in place, only discarded. Cancellation is always available from any editable state (DELETE links.session) but is refused with 409 during processing, because by then the outcome is already being decided.
Staged previews are optional for an index. One that doesn't support them omits links.stage and session-token entirely, and still gets atomic publish out of the deal. For PyPI, though, staging is the whole point. The stage URL and session-token are cryptographically unguessable, server-minted capabilities. Hand the URL to CI and it can pip install --extra-index-url $STAGE_URL my-package against the real index (real filenames, real metadata, real resolver behavior) before the public can see anything. Deliberately, the preview capability is not gated by upload permission, so a test job needs no project credentials. This is the feature Barry Warsaw thinks "would solve many of the problems with test.pypi.org, and maybe even allow us to decommission that service."
A STAGE IS SHARED MUTABLE STATE FOR EVERYONE WHO CAN UPLOAD TO THE PROJECT. Because authorization is contemporaneous and sessions are keyed by name+version, any currently-authorized principal can add files to, cancel, or publish a colleague's open session. The 409-with-Location on duplicate create is the join mechanism, not a lock. The blast radius is confined to the unpublished stage (published artifacts are immutable), but "who publishes first" is cooperative, not enforced. And a revoked principal that already holds the stage URL keeps read-only preview access until the session ends, a documented and accepted limitation.
Checkpoint 2 · the protocol
History
PEP 694 is a 2022 design that died, was exhumed in 2024, and was then argued into its current shape across four numbered rounds. Reading the threads in order is watching scope get traded for shippability.
| When | Thread | What happened |
|---|---|---|
| Jun–Aug 2022 | original (16879) | Donald Stufft's draft: sessions + resumable chunked uploads modeled on tus2. Enthusiasm, then stall. |
| Jan–Aug 2025 | revival (76316) | Barry Warsaw co-authors and rewrites: "draft"→"stage", client nonce, /2.0 root. Michał Górny's protocol review. |
| Aug–Dec 2025 | Round 2 (101483) | Nonce removed for server-minted tokens; RFC 3339 UTC timestamps; duplicate create → 409; RFC 9457 errors. |
| Jun–Jul 2026 | Round 3 (107923) | PEP-Delegate feedback lands: actions become link endpoints, metadata key deleted, both state machines specified, contemporaneous authorization, security section. |
| Jul 2026– | Round 4 (108320) | Atomic publication + filename reservation; first-release name reservation MAY→MUST; retention rules; publish-during-upload defined. Quiet thread; pre-acceptance polish. |
Stufft's original was, in his words, an "application specific protocol that happens to look almost exactly like tus2" (the IETF resumable-upload draft), with chunked uploads, offsets, and upload tokens in the core spec. The killer use case arrived in the thread immediately, from downstream CI:
"One of the biggest pain points for our projects is that many of our dependencies have significant delays (hours or even days) between uploading some parts of their releases."
fungi (Jeremy Stanley), original thread #8
But implementers pushed back on weight: "the proposed API requires much more from service implementations than the current legacy upload API" (mmarston, #32). Chunk-size semantics tangled with S3's multipart limits, and after Stufft's last post in August 2022 the thread simply stopped. The companion warehouse PR went untouched for two years. Procedurally the PEP was stuck too: Stufft is the natural PEP-Delegate for PyPI PEPs and can't delegate his own PEP, and nobody assigned anyone else.
Barry Warsaw picked the PEP up in mid-2024, Alpha-Omega funded implementation work, and two decisions made the 2025 draft shippable. First, Ee Durbin cut resumable uploads out of the core: file transfer became a negotiated mechanism, with dumb-simple http-post-bytes required and anything fancier (S3 multipart via presigned URLs, resumable protocols, whatever) addable as vnd-<operator>-<impl> identifiers without a new PEP:
"I would very much like to see us offload the multi-part upload work to an S3 compatible multi-part upload interface rather than implement it as part of warehouse."
EWDurbin (Ee Durbin), original thread #76
Second, Round 2's defining argument: the January 2025 draft let clients supply a nonce from which the stage URL could be computed out-of-band, via a specified gentoken() hash. William Woodruff found the hash construction lacked domain separation (("foo", 11) and ("foo1", 1) both concatenate to foo11), then asked the question that deleted the whole design: "what if the server was entirely responsible for generating the session token?" Barry conceded the out-of-band property, "as cool as it might be," for a simpler protocol where the index mints an opaque, unguessable token by any algorithm it likes. The same round pinned timestamps to RFC 3339 UTC (after a long localization debate that fungi ended with daylight-saving time) and made duplicate session creation an explicit 409 instead of a get-or-create.
Round 3 is the PEP responding to off-thread review by Dustin Ingram, the PEP-Delegate (Stufft and Durbin being conflicted out as co-authors, the Steering Council's fallback). The changes read like a reviewer's checklist of everything that was previously vibes: dedicated links.publish/links.extend/links.complete endpoints instead of magic action strings in request bodies; the optional metadata upload key deleted (files are the source of truth); both state machines written down with transition tables and diagrams; session access relaxed from creator-bound to contemporaneously-authorized; a security-implications section covering name squatting and staged-malware hosting; and a client-recommendations section sketching twine upload --stage, uv publish --test-install, and a stage→test→publish GitHub Actions pipeline.
Round 4 then closed the concurrency gaps Barry had spotted himself: publication became atomic with filename reservation across both APIs, first-release name reservation went from MAY to MUST (otherwise two clients could race to first-publish a new name), publish-with-uploads-in-flight became a defined 409, and cancellation was guaranteed available regardless of file states.
Pre-acceptance, converging. The Round 4 thread is nearly silent; the newest actionable feedback is four implementation-level defects from someone prototyping a client in a uv fork against a server that does not exist: blake2b without a pinned digest length (hashlib defaults to 512-bit, PyPI uses Blake2b-256), file_url/attestations_url in snake_case amid a kebab-case protocol, the error example saying details where RFC 9457 says detail, and an unspecified Location resolution. The one design-level loose end is authentication: konstin (Astral) argued you still cannot write a universal client from the spec text, and the resolution so far is RFC 7235 WWW-Authenticate negotiation rather than a single mandated scheme. Ee Durbin: "I don't much like masquerading basic auth under the Bearer path."
Warehouse contains zero PEP 694 code today. No session models, no stage storage, nothing. The closest thing is the July 2026 change rejecting new files on releases older than 14 days, whose own blog post defers the real semantics: "stay tuned for the future where Upload 2.0 API provides semantics for releases that are 'closed' instead of 'open'."
The review lens
If you review this PEP from the PyPI side (as reviews on the draft PR, python/peps#4794, did), the questions are less about JSON shapes and more about what the protocol does to the index. It is a useful exercise to trace how each concern fared:
| Operator concern | Where it landed |
|---|---|
| Metadata beside the file, again? Files should be the only source. | Won completely. Round 3 deleted the metadata key. |
Magic "publish"/"extend" action strings mix two action mechanisms; make them links. |
Won. Round 3 moved every action to a dedicated links.* endpoint. |
| Is a session a lock? What does a 409 mean for a different user? | Answered by contemporaneous authorization: authorized principals share the session (403 outranks 409 so outsiders learn nothing); it is a coordination point, not a lock. |
| Sessions per name+version shift a matrix of sessions onto multi-project publishers. | Held as designed. The FAQ now defends it (permissions, Trusted Publishing scoping, atomicity), and clients are expected to manage N sessions invisibly. |
| No rate limiting story; open sessions could squat names or privately host malware on the index. | Acknowledged, then delegated. The Security Implications section names both and leaves limits, expiry policy, and scanning as index policy. The name-reservation MUST makes squatting-by-session at least observable and expirable. |
| Expiry/extension mechanics feel clunky; testing needs hours, embargoes need weeks. | Held, but softened: extend-for is a suggestion the server may decline; the PEP explicitly discusses different lifetimes for testing vs. embargo use. |
| What about uploading a duplicate of an already-published file? | Defined, and differently than legacy: 409 Conflict at file-session creation. The legacy silent no-op does not carry over. |
Why is size required? |
Held. It is the fail-fast: quota and sanity checks before bandwidth is spent. |
| Publishers need a section on the new user-facing workflow, not just wire formats. | Won. Round 3's client-recommendations section is exactly this. |
Checkpoint 3 · the arguments
The map
| Thing | Where | Why you'd look |
|---|---|---|
| The PEP itself | peps.python.org/pep-0694 · source python/peps → peps/pep-0694.rst |
The Change History section is the fastest honest summary of all four rounds |
| Legacy upload view | pypi/warehouse → warehouse/forklift/legacy.py (file_upload, ~613–1850) |
Everything a 2.0 implementation must reproduce or retire, in one function |
| Forklift routing / domain split | warehouse/forklift/__init__.py, warehouse/routes.py (410 shims), warehouse/config.py (forklift.domain) |
How upload.pypi.org is a route predicate, and where /2.0 would slot in |
| Upload edge (absence of) | pypi/infra → terraform/main.tf, terraform/warehouse/vcl/main.vcl |
upload.pypi.org is not in the Fastly domain map; POSTs to pypi.org skip shield |
| File serving loop | terraform/file-hosting/ (B2 primary, S3 Glacier fallback, conveyor) |
Where staged files would have to be kept out of the public path |
| Storage backends | warehouse/packaging/services.py (B2/S3/GCS IFileStorage), warehouse/packaging/tasks.py (sync_file_to_cache) |
The archive/cache split a vnd-pypi-* presigned mechanism would build on |
| All five threads | discuss.python.org topics 16879, 76316, 101483, 107923, 108320 | The design rationale that never made it into the PEP text |
| Draft-review PR | python/peps#4794 | Line-level review that shaped Rounds 3–4 |
| 14-day release window | warehouse/forklift/legacy.py (MAXIMUM_AGE_FOR_NEW_UPLOADS_DAYS) · PyPI blog, 2026-07-22 |
The only shipped code that gestures at 694's "closed release" semantics |
| Client prototype | astral-sh/uv#20789 (staged uploads fork) | First implementation experience; source of the Round 4 spec-defect list |
Five things to do first, roughly in order:
file_upload in forklift/legacy.py start to end, and for each validation ask: does this belong at file-session creation (fail fast), mechanism execution, file completion, or publish? That mapping is the warehouse implementation design./simple/ and files.pythonhosted.org. The current archive/cache bucket split is suggestive but not sufficient.Pyramid terms, plain English
__acl__config.scan()@view_config and registers whatever it finds. Without a scan, a decorator alone does nothing.Configurator object (via add_directive), so a plugin can offer its own verb, like config.add_periodic_task(...), instead of every caller repeating the same boilerplate.find_service()includeme(config)config.include("some.package") calls it and hands over the Configurator, so the plugin can register its own routes, views, and services.request_method="POST", that Pyramid checks before it will match that route or view to an incoming request.register_service_factory()find_service(): tells the registry "when someone asks for this interface, call this factory to build the answer."__getitem__ calls from the root. The traversal equivalent of a URL path segment.__getitem__, and whatever object you land on becomes the view's context.config.scan() actually uses. A decorator like @view_config just leaves a note for venusian to find later; it does nothing by itself until scanned.