Publishing your Python package to PyPI makes it installable with a simple pip install your-package for developers worldwide. This guide covers the complete publishing workflow, from preparing your package structure to automating releases with trusted publishing.
Package Structure for Distribution
Before publishing, your package needs a proper structure. The recommended modern layout uses a src directory:
my-package/
├── src/
│ └── my_package/
│ ├── __init__.py
│ └── core.py
├── tests/
│ └── test_core.py
├── pyproject.toml
├── README.md
└── LICENSE
Why Use the src Layout?
The src layout prevents a common mistake: accidentally importing your local source instead of the installed package during testing.
# Without src layout (flat)
my_package/ # This gets imported during tests
__init__.py
tests/
test_core.py # import my_package → imports local, not installed
# With src layout
src/
my_package/ # Not directly importable
__init__.py
tests/
test_core.py # import my_package → imports installed version
The init.py File
Your __init__.py should expose the public API and define __version__:
# src/my_package/__init__.py
"""My Package - A brief description."""
__version__ = "1.0.0"
from .core import main_function, MyClass
__all__ = ["main_function", "MyClass", "__version__"]
Configuring pyproject.toml for Publishing
Your pyproject.toml contains all metadata PyPI needs:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "my-package"
version = "1.0.0"
description = "A brief description of what your package does"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.9"
authors = [
{name = "Your Name", email = "[email protected]"}
]
keywords = ["keyword1", "keyword2", "keyword3"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
dependencies = [
"requests>=2.28.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0",
"black",
"mypy",
]
[project.urls]
Homepage = "https://github.com/username/my-package"
Documentation = "https://my-package.readthedocs.io"
Repository = "https://github.com/username/my-package"
Changelog = "https://github.com/username/my-package/blob/main/CHANGELOG.md"
[project.scripts]
my-command = "my_package.cli:main"
Understanding Classifiers
Classifiers help users find your package on PyPI. Common categories:
| Category | Example Values |
|---|---|
| Development Status | 3 - Alpha, 4 - Beta, 5 - Production/Stable |
| Intended Audience | Developers, Science/Research, System Administrators |
| License | MIT License, Apache Software License, GPL v3 |
| Programming Language | Python :: 3.11, Python :: 3.12 |
| Topic | Internet :: WWW/HTTP, Software Development :: Libraries |
Find the full list at pypi.org/classifiers.
Building Your Package
Use the build module to create distribution files:
# Install build tool
pip install build
# Build both wheel and sdist
python -m build
# Output in dist/
# dist/
# my_package-1.0.0-py3-none-any.whl (wheel)
# my_package-1.0.0.tar.gz (sdist)
Understanding Distribution Types
┌─────────────────────────────────────────────────────────────┐
│ Distribution Types │
├─────────────────────────────────────────────────────────────┤
│ │
│ Source Distribution (sdist) │
│ ├── my_package-1.0.0.tar.gz │
│ ├── Contains: source code, pyproject.toml, README │
│ ├── Must be built on install (may compile C extensions) │
│ └── Universal: works on any platform │
│ │
│ Built Distribution (wheel) │
│ ├── my_package-1.0.0-py3-none-any.whl │
│ ├── Contains: pre-built, ready to install │
│ ├── Installs faster (no build step) │
│ └── May be platform-specific for C extensions │
│ │
└─────────────────────────────────────────────────────────────┘
Wheel Naming Convention
The wheel filename encodes compatibility:
my_package-1.0.0-py3-none-any.whl
│ │ │ │ │
│ │ │ │ └── Platform (any = universal)
│ │ │ └─────── ABI tag (none = pure Python)
│ │ └──────────── Python version (py3 = Python 3)
│ └───────────────── Package version
└───────────────────────── Package name
Checking Your Package Before Upload
Use twine check to validate your package:
pip install twine
# Check for common issues
twine check dist/*
# Output:
# Checking dist/my_package-1.0.0-py3-none-any.whl: PASSED
# Checking dist/my_package-1.0.0.tar.gz: PASSED
Common issues twine check catches:
- Invalid README format (must render as HTML)
- Missing required metadata
- Invalid classifiers
Publishing to Test PyPI
Always test on Test PyPI first:
1. Create a Test PyPI Account
Register at test.pypi.org.
2. Create an API Token
Go to Account Settings → API tokens → Add API token:
- Token name: "GitHub Actions" or descriptive name
- Scope: Entire account (or specific project after first upload)
3. Upload to Test PyPI
# Upload using API token
twine upload --repository testpypi dist/*
# You'll be prompted for credentials:
# Username: __token__
# Password: pypi-AgEIcHlwaS5vcmc... (your API token)
4. Test Installation
# Install from Test PyPI
pip install --index-url https://test.pypi.org/simple/ my-package
# If you have dependencies from real PyPI:
pip install --index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ my-package
Publishing to PyPI (Production)
Once verified on Test PyPI, publish to the real PyPI:
1. Create a PyPI Account
Register at pypi.org.
2. Create an API Token
Go to Account Settings → API tokens → Add API token.
3. Upload to PyPI
# Upload to production PyPI
twine upload dist/*
# Using stored credentials (optional)
# Create ~/.pypirc:
# [pypi]
# username = __token__
# password = pypi-AgEIcHlwaS5vcmc...
Your package is now live at https://pypi.org/project/my-package/!
Trusted Publishing with GitHub Actions
Trusted publishing is the modern, more secure way to publish from CI/CD. It uses OpenID Connect (OIDC) instead of API tokens.
How Trusted Publishing Works
┌─────────────────────────────────────────────────────────────┐
│ Trusted Publishing Flow │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. Configure Trust on PyPI │
│ └── Tell PyPI: "Trust releases from this GitHub repo" │
│ │
│ 2. GitHub Actions Runs │
│ └── GitHub creates a signed OIDC token proving: │
│ - Which repo the workflow runs in │
│ - Which workflow file triggered it │
│ - Other contextual claims │
│ │
│ 3. Publish Action Uses Token │
│ └── pypa/gh-action-pypi-publish exchanges OIDC token │
│ for a short-lived PyPI upload credential │
│ │
│ 4. PyPI Verifies │
│ └── PyPI checks the OIDC token matches the trusted │
│ publisher configuration you set up │
│ │
└─────────────────────────────────────────────────────────────┘
Configure Trusted Publishing on PyPI
- Go to your project on PyPI (or create a new "pending publisher" for first upload)
- Manage → Publishing → Add a new publisher
- Fill in:
- Owner: Your GitHub username or org
- Repository: Repository name
- Workflow name:
publish.yml(or your workflow filename) - Environment:
pypi(optional but recommended)
GitHub Actions Workflow
Create .github/workflows/publish.yml:
name: Publish to PyPI
on:
push:
tags:
- 'v*' # Triggers on tags like v1.0.0, v2.1.3
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install build dependencies
run: pip install build
- name: Build package
run: python -m build
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
publish:
needs: build
runs-on: ubuntu-latest
environment: pypi # Optional: adds deployment protection
permissions:
id-token: write # Required for trusted publishing
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
# No credentials needed! Trusted publishing handles it
Creating a Release
# Update version in pyproject.toml and __init__.py
# Commit changes
git add .
git commit -m "Release v1.2.0"
# Create and push tag
git tag v1.2.0
git push origin main --tags
# GitHub Actions automatically publishes to PyPI
Publishing to Test PyPI with Trusted Publishing
For testing, configure a separate trusted publisher for Test PyPI:
name: Publish to Test PyPI
on:
push:
branches:
- main
workflow_dispatch: # Manual trigger
jobs:
build-and-publish:
runs-on: ubuntu-latest
environment: test-pypi
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install build dependencies
run: pip install build
- name: Build package
run: python -m build
- name: Publish to Test PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/
Complete CI/CD Workflow
Here's a production-ready workflow that tests, builds, and publishes:
name: CI/CD
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
pip install -e ".[dev]"
- name: Run tests
run: pytest
- name: Run type checking
run: mypy src/
build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install build dependencies
run: pip install build twine
- name: Build package
run: python -m build
- name: Check package
run: twine check dist/*
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
publish-test:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: build
runs-on: ubuntu-latest
environment: test-pypi
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Publish to Test PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/
skip-existing: true
publish:
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
needs: build
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
Common Publishing Errors
Error: "File already exists"
HTTPError: 400 Bad Request from https://upload.pypi.org/legacy/
File already exists.
Cause: You're trying to upload a version that already exists.
Solution: Bump the version number. PyPI doesn't allow re-uploading the same version.
Error: "Invalid distribution file"
InvalidDistribution: Cannot find file (or expand pattern): 'dist/*'
Cause: No distribution files in dist/ directory.
Solution: Run python -m build first.
Error: "The description failed to render"
The description failed to render in the default format of reStructuredText.
Cause: Your README has syntax errors or uses unsupported Markdown features.
Solution:
- Ensure
readme = "README.md"is in pyproject.toml - Test locally:
pip install readme-renderer && python -m readme_renderer README.md
Error: "Invalid classifier"
HTTPError: 400 Bad Request: Invalid classifier "Development Status :: Stable"
Cause: Using a non-existent classifier.
Solution: Check the exact classifier name at pypi.org/classifiers.
Error: "Name already exists"
HTTPError: 400 Bad Request: The name 'my-package' is already taken.
Cause: Someone else has registered that package name.
Solution: Choose a different, unique name.
Best Practices
1. Version Pinning for Releases
Use exact version in release commits:
# pyproject.toml
version = "1.2.3" # Exact version for releases
# For development, consider dynamic versioning:
# dynamic = ["version"]
2. Include a LICENSE File
Always include a license. Common choices:
| License | Use Case |
|---|---|
| MIT | Maximum permissiveness |
| Apache 2.0 | Enterprise-friendly with patent protection |
| GPL v3 | Copyleft (derivatives must be open source) |
| BSD 3-Clause | Similar to MIT, explicit no-endorsement clause |
3. Write a Good README
Your README should include:
- What the package does (one paragraph)
- Installation instructions
- Quick start example
- Link to full documentation
- License information
4. Use Environments for Protection
GitHub Environments add approval workflows:
environment: pypi
# Requires manual approval before publishing
5. Automate Everything
Never publish manually in production:
- Use CI/CD for all releases
- Tag-based triggers ensure version consistency
- Automated tests run before every publish
Release Checklist
Before each release:
- Update version in
pyproject.toml - Update version in
__init__.py(if not dynamic) - Update CHANGELOG.md
- Run full test suite locally
- Build and check:
python -m build && twine check dist/* - Test on Test PyPI first
- Create git tag matching version
- Push tag to trigger release
Next Steps
- Learn about Package Versioning for version strategies
- See our pyproject.toml Complete Guide for configuration details
- Explore the Python Packaging Complete Guide for the full ecosystem
For more Python development guides, explore our complete Python packaging series.