Intermediate
You’ve written a Python utility that you keep copying between projects — a helper library, a CLI tool, a set of data processing functions — and you realize it’s time to stop copying files and start sharing properly. Publishing to PyPI (the Python Package Index) turns your code into something anyone can install with pip install your-package. It sounds intimidating, but the modern packaging workflow with pyproject.toml and the build + twine tools has reduced it to about ten minutes of setup.
PyPI is the official package repository at pypi.org. When you run pip install requests, pip downloads from there. TestPyPI at test.pypi.org is a separate sandbox for practice publishing — you can publish there first to verify everything works without affecting the real registry. You’ll need Python 3.7+, pip install build twine, and free accounts on both PyPI and TestPyPI.
In this tutorial you’ll learn the modern packaging structure using pyproject.toml, how to write a proper pyproject.toml with all required metadata, how to build source and wheel distributions, how to publish to TestPyPI and then PyPI, how to handle versioning, and how to add entry points for CLI commands. By the end you’ll have a complete, installable Python package.
Publishing a Python Package: Quick Example
Here’s the minimum structure and commands needed to go from code to pip install:
# Project structure:
# my_package/
# pyproject.toml
# README.md
# src/
# my_package/
# __init__.py
# core.py
# pyproject.toml (minimum required)
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.backends.legacy:build"
[project]
name = "my-package"
version = "0.1.0"
description = "A short description of what it does"
readme = "README.md"
requires-python = ">=3.8"
license = {text = "MIT"}
# Build and publish:
# pip install build twine
# python -m build
# twine upload dist/*
After running these commands:
$ python -m build
* Creating venv isolated environment...
* Installing packages in isolated environment... (setuptools)
* Getting build dependencies for sdist...
* Building sdist...
* Building wheel from sdist
Successfully built my_package-0.1.0.tar.gz and my_package-0.1.0-py3-none-any.whl
Running python -m build creates two files in the dist/ directory: a source distribution (.tar.gz) and a wheel (.whl). The wheel is what pip installs by default — it’s a pre-built zip archive. twine upload dist/* uploads both to PyPI. The sections below walk through the complete workflow with a real, working example package.
Modern Package Structure
The “src layout” is the current best practice for Python packages. It places your actual package code inside a src/ directory, which prevents common import-order bugs where Python imports your local code instead of the installed package during testing.
| File/Directory | Purpose | Required? |
|---|---|---|
pyproject.toml |
All project metadata and build config | Yes |
README.md |
Project description (shown on PyPI) | Strongly recommended |
LICENSE |
License text (MIT, Apache, etc.) | Recommended |
src/mypackage/__init__.py |
Makes the directory a package | Yes |
src/mypackage/module.py |
Your actual code | Yes |
tests/ |
Test files (NOT inside src/) | Recommended |
.gitignore |
Excludes dist/, *.egg-info, __pycache__ | Recommended |
The name in PyPI (name = "my-package") is the installable name (pip install my-package). The import name (import mypackage) is the directory name inside src/. These can differ — for example, the Pillow package is installed as pip install Pillow but imported as from PIL import Image.

Writing pyproject.toml
The pyproject.toml file contains all project configuration in one place. Here’s a comprehensive example showing all the important fields:
# pyproject.toml -- complete example
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.backends.legacy:build"
[project]
name = "textclean"
version = "1.0.0"
description = "A utility library for cleaning and normalizing text data"
readme = "README.md"
license = {file = "LICENSE"}
authors = [
{name = "Jane Developer", email = "jane@example.com"}
]
keywords = ["text", "nlp", "cleaning", "normalization"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Topic :: Text Processing :: Linguistic",
]
requires-python = ">=3.8"
# Runtime dependencies
dependencies = [
"regex>=2023.0",
]
# Optional dependency groups
[project.optional-dependencies]
dev = ["pytest>=7.0", "black", "ruff"]
docs = ["sphinx>=6.0", "furo"]
# CLI entry points: "textclean" command -> textclean.cli:main function
[project.scripts]
textclean = "textclean.cli:main"
# Project URLs shown on PyPI sidebar
[project.urls]
Homepage = "https://github.com/janedeveloper/textclean"
Documentation = "https://textclean.readthedocs.io"
Issues = "https://github.com/janedeveloper/textclean/issues"
Changelog = "https://github.com/janedeveloper/textclean/CHANGELOG.md"
# Tell setuptools where the packages are
[tool.setuptools.packages.find]
where = ["src"]
The classifiers field is important — PyPI uses them to categorize and filter packages, and they appear as browseable tags on your package page. The full list is at pypi.org/classifiers. The [project.scripts] section creates a textclean command-line tool after installation, calling the main() function in src/textclean/cli.py.
Building the Distributions
Before publishing, you need to build the distribution files. Let’s create a real working package to demonstrate:
# Create the complete package structure
mkdir -p textclean/src/textclean
mkdir -p textclean/tests
# src/textclean/__init__.py
cat > textclean/src/textclean/__init__.py << 'PYEOF'
"""textclean: Text cleaning and normalization utilities."""
__version__ = "1.0.0"
from .core import clean_text, remove_html, normalize_whitespace
__all__ = ["clean_text", "remove_html", "normalize_whitespace"]
PYEOF
# src/textclean/core.py
cat > textclean/src/textclean/core.py << 'PYEOF'
"""Core text cleaning functions."""
import re
import unicodedata
def remove_html(text: str) -> str:
"""Remove HTML tags from text."""
return re.sub(r'<[^>]+>', '', text)
def normalize_whitespace(text: str) -> str:
"""Collapse multiple spaces and strip leading/trailing whitespace."""
return re.sub(r'\s+', ' ', text).strip()
def clean_text(text: str, remove_html_tags: bool = True) -> str:
"""Apply standard cleaning pipeline to text."""
if remove_html_tags:
text = remove_html(text)
text = normalize_whitespace(text)
return text
PYEOF
# src/textclean/cli.py
cat > textclean/src/textclean/cli.py << 'PYEOF'
"""Command-line interface for textclean."""
import sys
from .core import clean_text
def main() -> None:
"""Read text from stdin, clean it, print to stdout."""
if sys.stdin.isatty():
print("Usage: echo 'text' | textclean", file=sys.stderr)
sys.exit(1)
text = sys.stdin.read()
print(clean_text(text))
PYEOF
Then build:
# Install build tool
pip install build
# Build both sdist and wheel
cd textclean
python -m build
# Result:
dist/
textclean-1.0.0.tar.gz # source distribution
textclean-1.0.0-py3-none-any.whl # wheel (installable)
The py3-none-any in the wheel filename means: Python 3, no ABI dependency (pure Python), any platform. If your package includes C extensions compiled with Cython or uses platform-specific code, the wheel name will include the Python version, ABI, and platform (e.g., cp311-cp311-linux_x86_64).

Publishing to TestPyPI and PyPI
Always publish to TestPyPI first to verify your package uploads and installs correctly before pushing to production PyPI.
# Step 1: Create accounts
# TestPyPI: https://test.pypi.org/account/register/
# PyPI: https://pypi.org/account/register/
# Both require email verification.
# Step 2: Create API tokens (recommended over passwords)
# TestPyPI: Account Settings -> API tokens -> Add API token
# PyPI: Account Settings -> API tokens -> Add API token
# Scope: "Entire account" for a new package (no project scope yet)
# Step 3: Install twine
pip install twine
# Step 4: Check your distributions for common problems
twine check dist/*
# Expected output:
# Checking dist/textclean-1.0.0-py3-none-any.whl: PASSED
# Checking dist/textclean-1.0.0.tar.gz: PASSED
# Step 5: Upload to TestPyPI
twine upload --repository testpypi dist/*
# You'll be prompted for username and password
# Username: __token__
# Password: pypi-AgEIcHlwaS5... (your TestPyPI API token)
# Step 6: Verify it installs from TestPyPI
pip install --index-url https://test.pypi.org/simple/ textclean
python -c "from textclean import clean_text; print(clean_text('Hello world'))"
# Output: Hello world
# Step 7: If all good, upload to real PyPI
twine upload dist/*
# Username: __token__
# Password: pypi-... (your PyPI API token, different from TestPyPI)
# Step 8: Verify from real PyPI
pip install textclean
Store API tokens in ~/.pypirc so you don’t have to paste them on every upload:
# ~/.pypirc
[distutils]
index-servers =
pypi
testpypi
[pypi]
repository = https://upload.pypi.org/legacy/
username = __token__
password = pypi-AgEI... (your PyPI token)
[testpypi]
repository = https://test.pypi.org/legacy/
username = __token__
password = pypi-AgEI... (your TestPyPI token)
With .pypirc configured, twine upload dist/* uses the stored credentials automatically. On macOS and Linux, set chmod 600 ~/.pypirc to restrict read access to your user only.
Real-Life Example: CLI Package with Version Bumping
A complete workflow for building, versioning, and publishing a CLI tool — demonstrating how real projects handle the release cycle.

# release.py -- automated release script
"""
Automates the release workflow:
1. Bumps version in pyproject.toml
2. Runs tests
3. Builds distribution
4. Uploads to PyPI
"""
import re
import subprocess
import sys
from pathlib import Path
def read_version(pyproject_path: Path) -> str:
content = pyproject_path.read_text()
match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
if not match:
raise ValueError("version not found in pyproject.toml")
return match.group(1)
def bump_version(version: str, bump_type: str) -> str:
major, minor, patch = map(int, version.split("."))
if bump_type == "major":
return f"{major + 1}.0.0"
elif bump_type == "minor":
return f"{major}.{minor + 1}.0"
elif bump_type == "patch":
return f"{major}.{minor}.{patch + 1}"
raise ValueError(f"Invalid bump_type: {bump_type}")
def update_version(pyproject_path: Path, new_version: str) -> None:
content = pyproject_path.read_text()
updated = re.sub(
r'^(version\s*=\s*)"[^"]+"',
f'\\1"{new_version}"',
content,
flags=re.MULTILINE
)
pyproject_path.write_text(updated)
print(f"Updated version to {new_version}")
def run(cmd: list[str]) -> int:
"""Run a command and print output. Returns exit code."""
print(f"$ {' '.join(cmd)}")
result = subprocess.run(cmd)
return result.returncode
def release(bump_type: str = "patch", test_only: bool = True) -> None:
pyproject = Path("pyproject.toml")
current = read_version(pyproject)
new_ver = bump_version(current, bump_type)
print(f"Releasing: {current} -> {new_ver}")
# Bump version
update_version(pyproject, new_ver)
# Run tests
if run(["python", "-m", "pytest", "tests/", "-q"]) != 0:
print("Tests failed -- aborting release")
update_version(pyproject, current) # rollback
sys.exit(1)
# Clean previous builds
import shutil
for d in ["dist", "build"]:
if Path(d).exists():
shutil.rmtree(d)
# Build
if run(["python", "-m", "build"]) != 0:
print("Build failed")
sys.exit(1)
# Upload (to TestPyPI if test_only, real PyPI otherwise)
repo = "testpypi" if test_only else "pypi"
if run(["twine", "upload", f"--repository={repo}", "dist/*"]) != 0:
print("Upload failed")
sys.exit(1)
print(f"\nReleased {new_ver} to {'TestPyPI' if test_only else 'PyPI'}")
if __name__ == "__main__":
bump = sys.argv[1] if len(sys.argv) > 1 else "patch"
test = "--prod" not in sys.argv
release(bump_type=bump, test_only=test)
# Usage:
# python release.py patch # patch release to TestPyPI
# python release.py minor # minor release to TestPyPI
# python release.py patch --prod # patch release to real PyPI
This release script encodes your entire release workflow. It prevents human error (forgetting to bump the version, forgetting to run tests) and makes releases reproducible. Add it to your project alongside a CHANGELOG.md and a GitHub Actions workflow that runs it automatically when you tag a release.
Frequently Asked Questions
Should I still use setup.py?
setup.py is the legacy approach from before pyproject.toml was standardized (PEP 517/518). As of 2024, pyproject.toml is the recommended standard for all new packages. Running python setup.py install directly is deprecated and was removed in pip 23.1. You can have a minimal setup.py for backward compatibility with very old tools, but all configuration should live in pyproject.toml. If you have an existing package with setup.py, migrate it to pyproject.toml.
How should I version my package?
Follow Semantic Versioning (SemVer): MAJOR.MINOR.PATCH. Increment MAJOR for breaking changes (removing or changing an API), MINOR for new features that are backward-compatible, PATCH for bug fixes. Start at 0.1.0 for initial development — the 0.x series signals that the API is not yet stable. Release 1.0.0 when the API stabilizes. Never reuse version numbers — once 1.0.0 is on PyPI, it’s permanent. Delete a broken release and publish 1.0.1 instead.
Can I publish a private package?
Yes — several options exist: use a private PyPI server like devpi or Nexus, use GitHub Packages as a pip registry, or use AWS CodeArtifact. You can also install directly from a private Git repository: pip install git+https://github.com/org/private-repo.git@v1.0.0. For internal company packages, a private registry with authentication is the proper approach rather than publishing to public PyPI.
What is the difference between a wheel and an sdist?
An sdist (source distribution) is a .tar.gz containing your source code plus a PKG-INFO file. Installing from an sdist requires building, which means the user needs a C compiler for packages with C extensions. A wheel (.whl) is a pre-built zip archive that pip installs directly without building. Pure-Python packages produce a single wheel tagged py3-none-any that works everywhere. C-extension packages need multiple wheels for different Python versions and platforms — this is handled by the cibuildwheel tool in CI.
What if my package name is already taken on PyPI?
PyPI package names are globally unique and case-insensitive (hyphens and underscores are equivalent). If your desired name is taken, check if the existing package is actively maintained — if it’s abandoned, you can file a name claim via the PyPI support form. Otherwise, choose a different name: add a prefix (your username, organization, or project namespace) or pick a more specific name. Use pip search my-name or browse pypi.org to check availability before starting development.
Conclusion
Publishing a Python package to PyPI requires four things: a proper pyproject.toml with metadata, a well-structured src/ layout, the build tool to create distributions, and twine to upload them. Always test on TestPyPI first. Use API tokens instead of passwords. Follow SemVer for version numbers. The release script above is a complete automation of this workflow — adapt it to your project’s needs and wire it into your CI/CD pipeline.
Once your package is on PyPI, keep it healthy: respond to issues, bump dependencies, and maintain a changelog. A well-maintained package builds trust with users and contributors.
Official documentation: https://packaging.python.org/en/latest/tutorials/packaging-projects/