How to Publish a Python Package to PyPI

Blog / Python · May 20, 2021 · Updated June 10, 2026 · 9 min read
How to Publish a Python Package to PyPI

Publishing a package to PyPI (the Python Package Index) is how you turn a folder of Python code into something anyone can install with one pip install. The mechanics, though, have changed a lot since this guide was first written. If you learned packaging around 2021, you probably remember running python setup.py sdist uploadthat command is gone, and uploading with your account password no longer works either.

This is the current, 2026-accurate workflow: a single pyproject.toml as the source of truth, python -m build to produce your distributions, and twine (or Trusted Publishing from CI) to upload them with scoped API tokens. We maintain several open-source packages on PyPI and ship this flow for clients as part of our Python development services, so this is the setup we use in production.

What changed since the setup.py days

The old flow — a setup.py script with a setup() call, then python setup.py register and python setup.py sdist upload — is deprecated and insecure, and the upload/register commands were removed from setuptools years ago. Running arbitrary python setup.py ... invocations is now discouraged in general.

Here is what replaced it:

  • pyproject.toml is the single source of truth. Project metadata and your build configuration live here, defined by PEP 517/518 ([build-system]) and PEP 621 ([project]). A setup.py file is now optional and only needed for unusual cases like compiled C extensions.
  • Builds go through a build frontend. You run python -m build, which calls whatever backend you declared (setuptools, hatchling, flit, etc.) in an isolated environment.
  • Uploads use twine with API tokens. PyPI no longer accepts account-password uploads at all — you authenticate with a scoped API token, or skip tokens entirely using Trusted Publishing from CI.

Project layout: prefer a src layout

The recommended structure puts your importable package inside a src/ directory. This prevents a common bug where tests accidentally import your code from the working directory instead of the installed package, which can hide packaging mistakes until your users hit them.

django-simple-pagination/
├── pyproject.toml        # build config + all project metadata
├── README.md             # rendered as the long description on PyPI
├── LICENSE
└── src/
    └── simple_pagination/
        ├── __init__.py
        ├── pagination.py
        ├── templatetags/
        └── templates/

pyproject.toml: your single source of truth

Everything that used to be keyword arguments to setup() now lives as declarative TOML. The [build-system] table tells tools how to build the package; the [project] table holds the metadata PyPI displays. Here is a complete, modern example using hatchling as the backend:

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "django-simple-pagination"
version = "1.0.0"
description = "A simple, reusable pagination app for Django."
readme = "README.md"
requires-python = ">=3.9"
license = "MIT"                       # SPDX expression (PEP 639)
authors = [{ name = "MicroPyramid", email = "hello@micropyramid.com" }]
keywords = ["django", "pagination"]
classifiers = [
    "Development Status :: 5 - Production/Stable",
    "Framework :: Django",
    "Intended Audience :: Developers",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.12",
]
dependencies = [
    "django>=4.2",
]

[project.optional-dependencies]
dev = ["pytest", "build", "twine"]

[project.urls]
Homepage = "https://github.com/MicroPyramid/django-simple-pagination"
Source = "https://github.com/MicroPyramid/django-simple-pagination"
Issues = "https://github.com/MicroPyramid/django-simple-pagination/issues"

# Optional: expose a command-line entry point on install
# [project.scripts]
# my-tool = "simple_pagination.cli:main"

A few fields worth calling out:

  • name must follow PEP 503 normalization rules and be unique on PyPI — check availability before you build.
  • version uses PEP 440 / semantic versioning (e.g. 1.0.0, 1.1.0rc1). You cannot reuse a version number once it is published.
  • requires-python stops pip from installing your package on unsupported interpreters.
  • readme becomes the long description rendered on your PyPI project page.
  • license now takes an SPDX expression like "MIT"; the old License :: trove classifier is deprecated, so don't add both.
  • dependencies replaces the old install_requires — these are installed automatically when someone pip installs your package.

Choosing a build backend

The backend is the tool that actually assembles your sdist and wheel. They all read the same [project] metadata, so switching is mostly a one-line change in [build-system]. Pick based on your workflow:

Backend Best for Notes
hatchling Most new pure-Python packages Fast, low-boilerplate, great defaults; a common modern default
setuptools Legacy projects, C extensions The original; still fully supported and configured in pyproject.toml (no setup.py required)
flit-core Tiny single-module packages Minimal and opinionated
poetry-core Teams wanting lockfiles + env management Backend behind Poetry's all-in-one dependency/venv tooling
uv Speed-focused, modern workflows Rust-based; uv build / uv publish are extremely fast and can drive these backends

If you have no strong opinion, hatchling for pure-Python libraries and setuptools when you need compiled extensions are both safe, boring choices.

Build the distributions: sdist and wheel

With pyproject.toml in place, build both artifact types into dist/:

# Create and activate an isolated virtual environment
python -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate

# Install the modern build tooling
python -m pip install --upgrade build twine

# Build an sdist (.tar.gz) and a wheel (.whl) into dist/
python -m build

# ...or, with uv (much faster, no separate 'build' install needed):
uv build

This produces two files:

  • An sdist (source distribution, *.tar.gz) — your source code plus metadata. pip builds it locally if no compatible wheel exists. Always ship one; it's the fallback for any platform.
  • A wheel (*.whl) — a pre-built, ready-to-install artifact. It installs faster because nothing has to be compiled on the user's machine. Pure-Python projects produce a single universal wheel; projects with C extensions produce platform-specific wheels.

Validate before you upload

Always check the build first. twine check catches the classic "my README renders as a wall of broken markup on PyPI" problem before it goes live, and a quick install in a clean environment confirms the wheel actually works.

# Validate metadata and that the long description will render
twine check dist/*

# Smoke-test the wheel in a clean environment
pip install dist/django_simple_pagination-1.0.0-py3-none-any.whl

Upload with twine and API tokens

PyPI only accepts API tokens for uploads now — there is no password-based upload. Create a token under your PyPI account settings (scope it to a single project once the project exists), then store it in ~/.pypirc. The username is always the literal string __token__:

# ~/.pypirc  — PyPI accepts API tokens only (no account passwords)
[pypi]
  username = __token__
  password = pypi-AgEIcHlwaS5vcmc...your-scoped-token...

[testpypi]
  username = __token__
  password = pypi-AgENdGVzdC5weXBp...your-testpypi-token...

Test on TestPyPI first. TestPyPI is a separate, throwaway instance of the index — perfect for rehearsing a release and checking how your project page looks. Upload there, verify, then publish to the real index:

# 1. Rehearse on TestPyPI and review the project page
twine upload --repository testpypi dist/*

# 2. Happy with it? Publish to the real PyPI
twine upload dist/*

# In CI you can pass the token via environment variables instead of ~/.pypirc:
export TWINE_USERNAME=__token__
export TWINE_PASSWORD=pypi-...your-token...
twine upload dist/*

Trusted Publishing from GitHub Actions (no long-lived token)

The modern, recommended way to publish is Trusted Publishing — an OpenID Connect (OIDC) exchange that lets a specific GitHub Actions workflow publish to PyPI with no API token stored anywhere. You configure a "trusted publisher" once on PyPI (Your projects → Publishing), pointing it at your repository, workflow filename, and environment. The workflow then mints a short-lived OIDC token at runtime.

Set the job's permissions: id-token: write and use the official pypa/gh-action-pypi-publish action — no with: password needed. Since v1.11.0 the action also auto-generates PEP 740 attestations for your files:

# .github/workflows/publish.yml
name: Publish to PyPI

on:
  release:
    types: [published]

jobs:
  pypi-publish:
    runs-on: ubuntu-latest
    environment: pypi              # must match the trusted publisher on PyPI
    permissions:
      id-token: write             # OIDC token for Trusted Publishing — no API token
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.x"
      - run: python -m pip install build
      - run: python -m build
      - name: Publish package distributions to PyPI
        uses: pypa/gh-action-pypi-publish@release/v1

Versioning and release hygiene

A few rules that save real pain:

  • Versions are immutable. Once 1.0.0 is published you can never re-upload it, even after deleting the files. If a release is broken you yank it (it stays installable by exact pin but is hidden from new installs) and ship 1.0.1. So if an upload fails partway, bump the version rather than fighting it.
  • Use semantic versioning so users can pin sensibly: MAJOR for breaking changes, MINOR for features, PATCH for fixes.
  • MANIFEST.in is mostly legacy. Modern backends include your package files automatically; reach for backend-specific include rules (e.g. hatchling's [tool.hatch.build]) instead of hand-maintaining MANIFEST.in. You may still use it with setuptools to add non-code files to the sdist.
  • Keep your README and LICENSE accurate — they're the first things visitors and tools read on your project page.

Once published, anyone can install it:

pip install django-simple-pagination

# or, with uv:
uv pip install django-simple-pagination

Where this fits in real projects

Packaging well is less about the upload command and more about the habits around it: a clean src layout, honest metadata, a tested wheel, and an automated, secretless release pipeline so cutting a version is a non-event. If you want to go deeper on the language itself, see our guides on Python coding techniques and best practices, Python decorators, and why we choose Python for backend development.

MicroPyramid has been building and shipping Python software for 12+ years across 50+ delivered projects, including reusable open-source packages and internal libraries published to private and public indexes. If you'd like a hand setting up packaging, CI publishing, or a private package registry, our Python development team can help.

Frequently Asked Questions

Is setup.py still needed?

No, not as a script you run. python setup.py upload/register are removed and python setup.py ... invocations are discouraged. Project metadata belongs in pyproject.toml instead. A setup.py file is now optional and only relevant for edge cases like building C extensions with setuptools — and even then you don't run it directly.

Which build backend should I choose?

For most new pure-Python packages, hatchling is a fast, low-config default. Use setuptools if you have C extensions or an existing setuptools project. Pick poetry-core or uv if you already use those tools for dependency management. They all read the same [project] metadata, so switching later is essentially a one-line change in [build-system].

What is the difference between an sdist and a wheel?

A wheel (.whl) is a pre-built artifact that installs without a build step, so it's faster and more reliable for users. An sdist (.tar.gz) is your source code plus metadata, which pip builds locally when no suitable wheel is available. Publish both: the wheel for speed, the sdist as a universal fallback.

How do I test a release on TestPyPI?

Upload to the separate TestPyPI index with twine upload --repository testpypi dist/* using a TestPyPI-specific token. Review the rendered project page, then install from it to confirm it works before touching real PyPI. TestPyPI is periodically wiped, so treat it purely as a rehearsal sandbox.

What is Trusted Publishing?

Trusted Publishing lets a specific GitHub Actions (or GitLab CI) workflow publish to PyPI via short-lived OpenID Connect tokens, so you store no long-lived API token in your repo secrets. You register the trusted publisher once on PyPI, set permissions: id-token: write in the job, and use pypa/gh-action-pypi-publish. It's the most secure option for automated releases.

Can I overwrite a version on PyPI?

No. A version number is permanent once uploaded — you can't re-upload it even after deleting the files. If a release is broken, yank it (which hides it from new resolutions while keeping exact pins working) and publish a new, higher version such as 1.0.1.

Share this article