Building a package is reversible ā you can rebuild it as many times as you want locally. Publishing to PyPI is not: a published version number can never be reused, even if you delete the release. This lesson covers publishing correctly the first time, including the safety net of testing on TestPyPI first.
1Publishing Is Permanent: The Consequence of a Version Number
twine upload dist/* (or the equivalent uv publish/poetry publish covered in earlier lessons) is the step that actually sends your built wheel and sdist to PyPI, making them publicly installable via pip install your-package. This is a genuinely different kind of action than every other step in the packaging pipeline covered so far: building, even publishing to a private or internal index, is reversible or at least contained ā publishing to the real, public PyPI is not.
The specific, easy-to-miss policy that makes this consequential: PyPI permanently reserves a version number once it has been uploaded, even if you later delete that specific release. Publishing 1.0.0 with a critical bug, realizing the mistake, and deleting the release does *not* free up 1.0.0 for a corrected re-upload ā the actual fix must be published under a genuinely new version number (1.0.1, or 1.0.0.post1), permanently, with no way to reuse the original number.
This policy exists specifically to preserve reproducibility across the entire ecosystem: if version numbers *could* be reused, pip install package==1.0.0 run by two different people, at two different times, could silently install two genuinely different sets of code ā exactly the reproducibility guarantee this curriculum's Dependency Management lesson emphasized, which PyPI's immutable-version-number policy protects at the ecosystem level, not just within one project's own lock file.
$ python -m build
$ twine upload dist/*
# Uploads dist/my_package-1.0.0.whl and dist/my_package-1.0.0.tar.gz to PyPI
# Once uploaded, version 1.0.0 can NEVER be re-uploaded, even if deletedDeleting a release does NOT free it for reuse
2TestPyPI: A Genuine Practice Run With No Permanent Consequence
Given that publishing is irreversible in this specific, important sense, TestPyPI (https://test.pypi.org) exists as a genuinely separate, fully-functional instance of the same PyPI infrastructure, specifically for practicing the entire publish workflow ā uploading, then verifying via pip install --index-url https://test.pypi.org/simple/ your-package that the published package actually installs correctly and works as expected ā without touching, or consuming a version number on, the real, permanent PyPI at all.
This is not a simulation or a mock ā it's the actual same publish-and-install mechanics running against a genuinely separate registry, meaning a successful TestPyPI publish-and-install cycle is real, meaningful evidence that your pyproject.toml metadata, build configuration, and package discovery (from the Building Packages lesson) are all correctly configured, *before* you commit to an irreversible action on the real registry.
The professional workflow this establishes for any package's first release, or any release involving meaningful configuration changes (a new dependency, a build backend switch): publish to TestPyPI first, install from TestPyPI and verify it genuinely works as expected, and only then publish the identical, already-verified artifact to the real PyPI ā treating the real publish as the final, confirmed step rather than the first attempt at getting the configuration right.
$ twine upload --repository testpypi dist/*
# Uploads to https://test.pypi.org instead of the real, permanent PyPI
$ pip install --index-url https://test.pypi.org/simple/ my-package
# Verify the ACTUAL published package installs and works correctlyVerify everything works before the real, irreversible publish
3API Tokens, Not Passwords: Scoped, Revocable Authentication
Authenticating a publish with your actual PyPI account password is both a security risk (that password, if leaked from a CI configuration or a local .pypirc file, grants full access to your entire account, every package you maintain) and increasingly disallowed by PyPI's own policies for programmatic access. API tokens are the correct, current authentication method: generated from your PyPI account settings, a token can be scoped to a *specific single project* rather than your whole account, and ā critically ā can be individually revoked at any time without affecting your actual account password or any other tokens.
Using __token__ as the literal username (a PyPI-specific convention signaling 'this password field actually contains an API token, not an account password') alongside the actual token value is the standard configuration pattern, whether set in a ~/.pypirc file for local publishing or as a CI secret for automated publishing pipelines. The scoping and revocability this provides directly limits the blast radius of a leaked credential ā a leaked project-scoped token compromises only that one project's ability to publish new releases, not your entire PyPI account or every other package you maintain.
This follows the exact same principle covered in the Python Security section's treatment of credentials generally: never commit a token or password to version control, use environment variables or a properly-secured secrets mechanism (particularly in CI pipelines), and prefer the most narrowly-scoped, easily-revocable credential available for any given task ā publishing to PyPI is a specific, concrete instance of that general discipline.
# ~/.pypirc or environment variable -- an API TOKEN, not a password
[pypi]
username = __token__
password = pypi-AgEIcHlwaS5vcmc... # a scoped, revocable token
# NEVER commit this file or token to version controlProject-scoped API token ā never the actual account password
4Step-by-Step Breakdown
PyPI does not let you re-upload a version number, ever, even after deleting a bad release. That single fact should change how you approach your first publish.
twine upload sends your BUILT artifacts (from the previous lesson) to PyPI -- it does the actual publishing step, separate from building.
Checkpoint: If version 1.0.0 is published to PyPI with a critical bug, and you delete that release, can you later re-upload a FIXED version also numbered 1.0.0?
- āNo ā PyPI permanently reserves a version number once it's been used, even after deletion; the fix must be published as a new, different version number (e.g. 1.0.1)
- āYes ā deleting a release frees up its version number for reuse
TestPyPI is a SEPARATE, real instance of PyPI for practicing the publish process -- without permanently consuming a real version number.
Checkpoint: What is the main value of publishing to TestPyPI before the real PyPI?
- āIt lets you verify the entire publish-and-install process actually works, without permanently consuming a real version number on a mistake
- āIt uploads packages faster than the real PyPI
API tokens (not your PyPI account password) are the correct authentication method -- scoped, revocable, and never exposing your actual account credentials.
That completes Packaging & Distribution ā pyproject.toml, pip, uv, Poetry, building, and now publishing give you the complete path from source code to a real, installable package. Next, Python Security covers the practices that keep that published code safe to depend on.
Check a Real Version Publish Rule. Finish can_publish_version(): PyPI never allows re-uploading a version number.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Always publish to TestPyPI first and verify the package actually installs and works correctly, for any first release or significant configuration change
This catches metadata, build, or package-discovery mistakes before an irreversible action on the real PyPI, given that real PyPI version numbers can never be reused once published.
Use a project-scoped API token, never your actual PyPI account password, for publishing
A scoped, revocable token limits the blast radius of a leaked credential to one project rather than your entire account, and is required by modern PyPI publishing policy.
Frequent Bugs
Publishing directly to the real, permanent PyPI without first verifying via TestPyPI, discovering a configuration mistake (missing files, wrong metadata) only after the version number has already been permanently, irreversibly consumed.
Always publish to TestPyPI first and verify the resulting package installs and works correctly, before publishing the same, already-verified artifact to the real PyPI.
Real-World Examples
A Verified Release Workflow Using TestPyPI Before the Real Publish
A team preparing a library's first public release wants to catch any packaging mistakes before consuming a permanent version number on the real PyPI.
$ python -m build
$ twine upload --repository testpypi dist/*
$ python -m venv /tmp/verify-env && source /tmp/verify-env/bin/activate
$ pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ my-package
$ python -c "import my_package; print(my_package.__version__)" # verify it actually works
# Only after this succeeds:
$ twine upload dist/* # the real, permanent publish