PyPI · Packaging PEP 694 Round 4 on discuss.python.org PyPI Onboarding · No. 05 5,468 words · ~27 min Standards

PEP 694 Field Guide

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

One API, two eras

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
Shape1 file = 1 synchronous POSTSession of N files, then 1 publish
Encodingmultipart/form-dataapplication/vnd.pypi.upload.v2+json
Atomic unita single filethe whole release
Test before publictest.pypi.org, a separate accountstaged preview on the real index
Errorstext in the HTTP reason phraseRFC 9457 problem-details JSON
Metadata~40 form fields beside the filenone: the file is the source of truth
Reserve a nameupload a stub packagepublish an empty session
Big filesone unresumable bodynegotiable 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

The legacy path, end to end

twine / uv publish multipart POST, held open upload.pypi.org bare A record, no Fastly → warehouse ingress (Cabotage) forklift · file_upload ~1200-line view, all inline: validate, hash, YARA, extract METADATA, store, journal archive bucket PUT inside the request PostgreSQL Project, Release, File rows Celery, after response sync_file_to_cache · BigQuery B2 cache bucket pypi-files (allPrivate) distribution_metadata BigQuery public dataset pip install everyone, immediately pypi.org /simple/ Fastly · shows files as they land files.pythonhosted.org Fastly → B2, S3 Glacier fallback deferred work serves /packages/<blake2 fan-out>/<filename>
Fig. 1. Today. The upload hostname skips the CDN entirely; the whole gauntlet runs inside one held-open POST; each file becomes public the moment its own request finishes, which is exactly the race PEP 694 exists to close.

Six stages, from the client's first byte to the moment strangers can install the thing:

  1. 01

    The client POSTs a form

    https://upload.pypi.org/legacy/?:action=file_upload · docs.pypi.org/api/upload

    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).

  2. 02

    DNS, not Fastly

    pypi/infra → terraform/main.tf (absence) · upload.pypi.org A records

    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.

  3. 03

    The synchronous gauntlet

    warehouse/forklift/legacy.py → file_upload (~lines 613–1850)

    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.

  4. 04

    Two quirks every tool depends on

    warehouse/forklift/legacy.py → _is_duplicate_file, _exc_with_message

    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.

  5. 05

    After the response

    warehouse/packaging/tasks.py → sync_file_to_cache, update_bigquery_release_files

    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.

  6. 06

    And it is public. Now. Per file.

    pypi.org/simple/<project>/

    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

Q1Why does upload.pypi.org bypass Fastly when pypi.org and files.pythonhosted.org are both behind it?

A. Uploads are large, uncacheable request bodies. The edge adds buffering, its own size caps, and a 60-second first-byte timeout while providing zero caching value. So the hostname is a plain A record to the Cabotage ingress. Live check: server: gunicorn, no x-served-by. Note the asymmetry: TestPyPI uploads to test.pypi.org/legacy/ do traverse Fastly, because test.pypi.org never got a dedicated upload host.

Q2You upload the exact same wheel twice with the legacy API. What does the second request return?

B. Byte-identical re-uploads (same filename, same sha256, same blake2) doom the transaction and return HTTPOk, a deliberate no-op so naive retry loops succeed. D is what happens when the name or hash collides but the bytes differ; that prefix string is load-bearing for twine --skip-existing. Under Upload 2.0 this ambiguity disappears: a file-session request for an already-published filename is a hard 409.

The new mechanism

Sessions, stages, and one publish

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.

client twine / uv / gh-action POST /2.0 · create session {name, version} → 201 + links, mechanisms, session-token, expires-at the stage: invisible to regular index protocols file upload session × N {filename, size, hashes, mechanism} → 202 http-post-bytes required fallback, to warehouse vnd-pypi-s3multipart-… presigned, straight to storage complete → 201/202 → completed CI preview pip install --extra-index-url $STAGE POST links.publish atomically reserves all filenames, then 201, or 202 → processing published whole release visible at once, immutable canceled DELETE links.session, stage discarded unguessable stage URL or bail out
Fig. 2. Upload 2.0. Files accumulate in a private stage; CI can install from it; one publish flips the whole release public atomically. The mechanism negotiation is how warehouse gets to offload multi-gigabyte uploads to object storage without a new PEP.

The requests, concretely

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:

The state machines

Round 3 made both lifecycles normative. The asymmetry between them is the part worth memorizing:

ResourceStatesOn 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.

Stages are the killer feature

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."

Caution

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

Q3Why does session creation require the version, when the files themselves encode it?

C. The PEP's FAQ is explicit: with the version declared up front, every parallel file upload validates against the same value, and a mismatched file always fails regardless of arrival order. If the version were inferred from the first file, whichever upload the server processed first would "win." (D is half-true: for a files-free name reservation the version is an ignored placeholder like 0.0.0a0, but the key is still required.)

Q4Your release job publishes while one wheel's file session is still processing. What happens?

D. Round 4 defined this: publication's precondition is an allow-list, and every entry in files must be completed. Anything else (including error, including states future revisions might add) blocks the publish rather than being silently included or dropped. The client waits for the file session to resolve, or deletes files it no longer wants, then publishes again.

Q5A Trusted Publishing workflow's token expires mid-session and the job re-authenticates, getting a fresh token. Can the fresh token finish the session?

D. Round 3 deliberately relaxed this: every session request must be authorized at the time of that request for the project the session names. Rotating tokens, a second CI job, or a teammate's credentials all work. Conversely, revoking a principal locks it out of a session it created, on its very next request. The check is per request and never sticky.

History

Four years, five threads, one PEP

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.

WhenThreadWhat 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.

2022: born as tus2, died of scope

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.

2024–25: the revival, and the great scope trade

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.

2026: the delegate's round, then the races

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.

Where it stands (August 2026)

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

Reading the PEP like an operator

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 concernWhere 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

Q6Why did the client-supplied nonce get removed in Round 2?

B. C and D were real critiques (Woodruff flagged both the nonce-reuse UX and the missing domain separation in gentoken(): foo+11 and foo1+1 hash identically), and the hash was fixable with length prefixes. What killed it was the better question: if the server mints a fully random token, no safe construction is needed at all. The nonce's one unique power, computing the stage URL out-of-band without talking to the server, falls apart anyway once you want transitive staged dependencies resolved. Barry: simpler protocol, index-side flexibility, "and this can change without needing any future PEP."

Q7Does PEP 694 make name-squatting easier than today?

A. The PEP's own security section takes this head-on: registering a dummy package via the legacy API is already trivial, so sessions don't change the equation. The reservation held by an unpublished stage is temporary and expirable, unlike a published stub, which persists. Rate limiting (PyPI's existing 20/user/hour project-creation limit is the obvious template) and per-actor policy are explicitly left to the index. The operator review's related worry, staged sessions as un-scannable private malware hosting, is likewise acknowledged with mitigations (expiry limits, sharing stages with scanning partners) rather than protocol mandates.

Q8Warehouse implements Upload 2.0 next year. Which piece of today's infrastructure most plausibly stops being load-bearing?

C. Staged previews give you the rehearsal on the real index (real project, real resolver, unguessable URL), which is most of what test.pypi.org is for, minus the separate account, the divergent project namespace, and the "is test.pypi even up" question. Barry has said outright the feature might "allow us to decommission that service." D and A keep serving downloads regardless; B gets more load-bearing, since contemporaneous session auth was designed around rotating OIDC-minted tokens.

The map

Where everything is

ThingWhereWhy 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

If you're going to work on this

Five things to do first, roughly in order:

  1. Read the PEP's Change History bottom-up. It is the argument in compressed form.
  2. Read 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.
  3. Decide early where staged artifacts live. They must be downloadable via the stage URL but invisible to /simple/ and files.pythonhosted.org. The current archive/cache bucket split is suggestive but not sufficient.
  4. Watch the Round 4 thread for the auth-specification question and the four client-reported defects; both are likely to produce the next revision.
  5. Prototype against the uv fork's client: there is a client and no server, a rare gift for server implementers.

Pyramid terms, plain English

ACL / __acl__
A list of (allow or deny, who, permission) rules a resource can define. Pyramid's authorization layer checks it before letting a request through.
config.scan()
Walks the codebase looking for decorators like @view_config and registers whatever it finds. Without a scan, a decorator alone does nothing.
directive
A custom method bolted onto Pyramid's 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()
Asks the registry for "whatever implements this interface" and gets an object back, without the caller knowing or caring which concrete class it is.
includeme(config)
The one function every Pyramid plugin must define. config.include("some.package") calls it and hands over the Configurator, so the plugin can register its own routes, views, and services.
predicate
A condition attached to a route or view, like request_method="POST", that Pyramid checks before it will match that route or view to an incoming request.
register_service_factory()
The other half of find_service(): tells the registry "when someone asks for this interface, call this factory to build the answer."
registry
Pyramid's central lookup table, assembled once at startup, holding every route, view, service, and setting the running app knows about.
resource
In traversal, an object reachable by walking a chain of __getitem__ calls from the root. The traversal equivalent of a URL path segment.
root factory
The function that builds the very first resource in a traversal chain, given a request. Every other resource is reached by indexing into it.
subscriber
A function registered to run automatically whenever a specific event fires, like a transaction commit, without the code that fires the event knowing the subscriber exists.
traversal
Pyramid's alternative to matching a URL against a pattern: walk a resource tree one path segment at a time via __getitem__, and whatever object you land on becomes the view's context.
tween
Short for "between." Middleware that sits between Pyramid's router and the view, in an explicit, orderable chain, wrapping the whole request/response cycle.
venusian
The library 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.
view deriver
A wrapper Pyramid can apply to every view during registration, used for cross-cutting behavior (like injecting a metrics call) without every view author remembering to add it by hand.