twine
twine is the PyPA command line tool that uploads already-built Python distributions to PyPI or any other package index. It does one job and stops there: you build a wheel and sdist with something else, then twine check tells you whether the long description will render on the project page and twine upload pushes the files over a verified HTTPS connection. The separation is the point, because it means you upload the exact artifact you tested rather than whatever a build-and-upload command produced in the same breath. Credentials come from a .pypirc file, TWINE_ environment variables, the system keyring, or Trusted Publishing when it detects supported CI.
The reference way to push a built distribution to PyPI, and the tool the official packaging tutorial still points at. If your project manager already has a publish command you probably do not need it, but there is nothing wrong with the one that does only this.
Use it if
- You build with python -m build or setuptools and need something to do the actual upload, since neither of them publishes
- You want to inspect or test the exact files before they go up, which build-then-upload-in-one-step tools make harder
- You publish to a private index (Artifactory, Nexus, devpi, GitLab) and want one uploader that takes a repository URL and client certificates
- You want to validate that your README renders on PyPI before release rather than after, using twine check
- You publish from GitHub Actions: the official pypa/gh-action-pypi-publish action wraps this and handles Trusted Publishing, so you would never type the command yourself
- Your project manager already publishes: uv publish, hatch publish, poetry publish and flit publish all cover the same step without a second tool
- You expect it to build anything, because it will not; a missing dist directory is the most common first-time failure
- You relied on --skip-existing against a third-party index, since 6.2.0 removed the vendor specific detection hacks and only PyPI and TestPyPI are reliably handled now
- You wanted GPG signing to mean something: PyPI stopped serving signatures, so --sign mostly produces files nobody consumes
Setup reality
pip install twine is small and fast, and the friction shows up on the first upload instead. Authentication is token based in practice: username __token__ and the pypi- prefixed token as the password, which is why TWINE_USERNAME and TWINE_PASSWORD are the usual CI setup. The keyring dependency comes along for the ride and on a headless Linux box it can hang or throw where you expected a plain prompt, which is what keyring --disable exists for. Do a dry run against TestPyPI before the real thing, because PyPI will not let you re-upload a filename you have already published, not even after a delete. One recent trap: 7.0.0 fixed metadata version 2.5 uploads and in doing so stopped accepting metadata version 2.0, so artifacts from a very old build backend now get rejected at upload time.
Patterns
The normal release sequencebuild-and-upload
python -m build
twine check dist/*
twine upload dist/*twine never builds; if dist is empty or stale you will upload nothing or the wrong version.
Rehearse on TestPyPI firsttest-pypi
twine upload -r testpypi dist/*TestPyPI needs its own account and its own API token; the PyPI one will not authenticate there.
Check the long description renderscheck-readme
twine check --strict dist/*--strict turns rendering warnings into a non-zero exit, which is what you want in CI; a README that fails here shows up as raw text on the project page.
Authenticate in CI with an API tokentoken-env
export TWINE_USERNAME=__token__
export TWINE_PASSWORD="pypi-AgEIcHlwaS5vcmc..."
twine upload --non-interactive dist/*__token__ is a literal username, not a placeholder; --non-interactive turns a missing credential into an error instead of a prompt that hangs the job.
Store repositories in .pypircpypirc
[distutils]
index-servers =
pypi
internal
[pypi]
username = __token__
password = pypi-AgEIcHlwaS5vcmc...
[internal]
repository = https://pkgs.example.com/simple/
username = ci
password = ${INTERNAL_TOKEN}Lives at ~/.pypirc by default; twine upload -r internal selects a section, and 7.0.0 reads the file as UTF-8.
Upload to an index by URLprivate-index
twine upload \
--repository-url https://pkgs.example.com/upload/ \
--client-cert /etc/ssl/client.pem \
dist/*--repository-url overrides -r; --client-cert takes one file containing both the key and the certificate.
Re-run a partly failed uploadskip-existing
twine upload --skip-existing dist/*Reliable on PyPI and TestPyPI only since 6.2.0 dropped the per-vendor detection hacks; on other indexes an already-uploaded file may still fail the run.
Upload PEP 740 attestations alongside filesattestations
twine upload --attestations dist/*Passing the flag when no .publish.attestation files exist is a hard error, not a silent skip, so generate them first.
Publish from CI without a stored tokentrusted-publishing
# GitHub Actions job needs: permissions: id-token: write
twine upload dist/*Since 6.1.0 twine can mint a short-lived PyPI token from ambient CI credentials, and 6.2.0 refreshes it during uploads that outlast the 15 minute validity window.
Upload one artifact rather than a globsingle-file
twine upload dist/mypkg-1.2.0-py3-none-any.whlUseful when wheels are built on several runners; PyPI accepts additional files for a release that already exists, it just rejects a filename twice.
See why an upload was rejectedverbose-debug
twine upload --verbose --disable-progress-bar dist/*--verbose prints the request and the index response, which is usually the only way to read the real error behind a 400.
Deal with the system keyringkeyring
keyring set https://upload.pypi.org/legacy/ __token__
# on a headless box where the backend misbehaves:
keyring --disablekeyring ships as a twine dependency, so it takes part in credential lookup whether or not you set it up.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| uv | PyPI | You already use uv for builds and want uv publish rather than a separate uploader |
| hatch | PyPI | You want one tool covering environments, build and publish for the whole project lifecycle |
| flit | PyPI | You maintain a simple pure Python package and want build plus publish in a single small command |