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 upload — that 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.tomlis the single source of truth. Project metadata and your build configuration live here, defined by PEP 517/518 ([build-system]) and PEP 621 ([project]). Asetup.pyfile 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
twinewith 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:
namemust follow PEP 503 normalization rules and be unique on PyPI — check availability before you build.versionuses PEP 440 / semantic versioning (e.g.1.0.0,1.1.0rc1). You cannot reuse a version number once it is published.requires-pythonstopspipfrom installing your package on unsupported interpreters.readmebecomes the long description rendered on your PyPI project page.licensenow takes an SPDX expression like"MIT"; the oldLicense ::trove classifier is deprecated, so don't add both.dependenciesreplaces the oldinstall_requires— these are installed automatically when someonepip 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 buildThis produces two files:
- An sdist (source distribution,
*.tar.gz) — your source code plus metadata.pipbuilds 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.whlUpload 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/v1Versioning and release hygiene
A few rules that save real pain:
- Versions are immutable. Once
1.0.0is 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 ship1.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.inis 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-maintainingMANIFEST.in. You may still use it with setuptools to add non-code files to the sdist.- Keep your
READMEandLICENSEaccurate — 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-paginationWhere 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.