skip to content

How do you publish a Python package to PyPI using python -m build and twine?

level: juniorimportance: must knowfreq 45%

answer

  1. Two acts, not one
  2. Artifacts first, then upload
  3. Rehearse somewhere that does not matter
  4. Check the README renders
  5. build, check, testpypi, upload

basics

~20 s

Build the artifacts from a clean tree with python -m build, check them with twine check dist/*, rehearse on TestPyPI, then run twine upload dist/* against PyPI, authenticating with an API token or, from CI, trusted publishing.

solid answer

~40 s

Publishing is two separate acts: producing artifacts, then uploading them. From a clean checkout, `python -m build` writes a source distribution and a wheel into `dist/`. `twine check dist/*` verifies the README will render on the project page — a broken long description is the classic first-release failure. Upload with `twine upload dist/*`; credentials are an API token sent in place of a password, or, from CI, a short-lived token obtained through trusted publishing so no secret is stored. Rehearse first with `twine upload --repository testpypi dist/*` and install the result into a throwaway virtual environment, because the real index will never let you replace that filename once it is accepted. `uv publish` is a drop-in for the upload step.

code

console · 5 lines
console
rm -rf dist
python -m build
python -m twine check dist/*
python -m twine upload --repository testpypi dist/*
python -m twine upload dist/*

go deeper

for a junior

Be ready to name the two commands and say what each does: one produces the artifacts in dist/, the other uploads them. Know that uploads authenticate with an API token rather than your account password, and that TestPyPI is a separate site with its own account.

for a middle

Explain why building and uploading are separate steps, what twine check catches, and how a rehearsal on the test index is verified by installing into a clean virtual environment rather than by trusting the upload output.

for a senior

Show the release discipline: a cleared dist/, artifacts built from a tagged clean checkout, project-scoped credentials or trusted publishing from CI, and an install-from-the-index smoke test before you announce anything. Explain what immutability costs you when a step is skipped.

for a principal

Own the release process as a whole: who may publish, whether releases are cut by humans or only by a pipeline, how the artifact is tied to a reviewed commit, and what the team's standing answer is when a released version turns out to be wrong.

## Publishing is the last mile, and it is not the build Two distinct things get conflated under "publish". **Building** turns your source tree into distribution artifacts — a source distribution archive and one or more wheels — and is the job of a build backend driven by a build frontend. **Publishing** takes those finished files and hands them to an index. `python -m build` does the first; `twine upload` does the second. Keeping them separate matters in practice: it means the exact bytes you tested are the exact bytes you upload, and it means a failed upload never silently rebuilds something different. ## The flow, step by step ```console python -m pip install --upgrade build twine python -m build python -m twine check dist/* python -m twine upload --repository testpypi dist/* python -m twine upload dist/* ``` 1. **Start clean.** `python -m build` builds in an isolated environment, but it does not clean `dist/` for you. A stale wheel left from a previous version is the most common way a release goes out with the wrong contents, because `twine upload dist/*` uploads everything it finds. Delete `dist/` first, and build from a checkout with no local modifications so the artifact corresponds to a tag. 2. **Check the metadata.** `twine check dist/*` renders the long description the way the index will and fails on a README the index cannot display. It is cheap and it catches the single most common first-release embarrassment: a project page that shows raw markup or nothing at all. The README is declared in your project metadata along with its content type; classifiers and the supported-Python constraint travel in the same metadata and drive what the index displays and what an installer will consider. 3. **Rehearse on TestPyPI.** TestPyPI is a separate index with **separate accounts and separate API tokens** — your production credentials do not work there. Upload with `--repository testpypi`, then install the result into a throwaway virtual environment. Because most dependencies do not exist on TestPyPI, that install usually needs the real index as a fallback source. What the rehearsal actually validates is the shape of the release: the project name, the rendered page, that the wheel installs, and that the console commands and imports work from an *installed* package rather than from your source tree. 4. **Upload for real.** `twine upload dist/*`. On success the release appears on the index and is installable within seconds. ## Credentials PyPI does not accept an account password for uploads. You present an **API token**: it goes in the password field, paired with a fixed reserved username rather than your account name, and the token string itself starts with `pypi-`. Tokens can be scoped to a single project, which is what you want — an account-scoped token can publish anything you own. Store it in the CI secret store, or in twine's configuration file, or pass it through twine's environment variables. From CI there is a better answer: **trusted publishing**, where the workflow proves its identity to the index with a short-lived OIDC token and exchanges it for a short-lived, project-scoped upload token. Nothing long-lived is stored anywhere. If you publish from a pipeline, that is the default choice; a stored token is the fallback for machines that cannot mint an identity. ## What you cannot undo The release is **immutable**. Once the index accepts a file, that filename — and by extension that version — is spent forever. You cannot re-upload it with a fix, and deleting it does not free the name. That single fact is why the rehearsal step exists and why `twine check` is worth the two seconds: the cost of noticing a mistake after the upload is a new version number and a yank, not an edit. ## Verifying the release A release is not done because the upload printed a URL. Create a fresh virtual environment on a machine that has never seen your source tree, install the project from the index by name, import it, and run its console command. Installing from a source tree hides an entire class of packaging bugs — missing package data, a module that only imports because the current directory is on the import path, a console command that was never declared. Install from the index, and those surface immediately. ## Alternatives to the two commands `uv publish` uploads the contents of `dist/` and speaks the same protocol and the same trusted publishing exchange; `flit publish` and `poetry publish` bundle build and upload into one command for projects already using those tools. The mechanics — an index that authenticates you, accepts files once, and never lets you change them — are identical whichever frontend you pick. The legacy setuptools upload command is long deprecated and should not be used.

  • Why install the rehearsal upload into a fresh virtual environment instead of testing in your source checkout?
    Testing in the checkout hides packaging bugs, because the current directory puts your modules on the import path whether or not they were packaged. A fresh environment installing from the index only sees what the wheel actually contains, so missing package data, an undeclared console command, or a module left out of the build fail immediately rather than after your first user installs it.
  • What does `twine check dist/*` actually validate, and what does it not?
    It renders the long description the way the index will and fails when the README cannot be displayed, plus some basic metadata sanity. It does not run your tests, does not install the wheel, does not verify dependencies resolve, and does not check that your version number is sane. It is a metadata smoke test, not a release gate.
  • Why does uploading with the `dist/*` glob deserve care?
    The glob uploads every file in the directory, so a wheel left over from a previous build goes out with the current release. Always clear `dist/` before building, or name the files explicitly. It is also why building from a clean tagged checkout matters: the artifact should correspond to a commit you can point at.

Building is printing the book; publishing is handing it to a distributor whose warehouse accepts each edition exactly once and never takes corrections.

saying these in an interview costs you the question

  • Thinking `python -m build` also uploads the artifacts
  • Logging in to the index with an account password
  • Believing a bad release can be re-uploaded after deletion
  • Treating TestPyPI as sharing accounts and tokens with PyPI
  • Uploading a stale dist/ directory without clearing it
  • Verifying the release only from the source checkout

context