Home/Blog/Publishing Python Packages to PyPI: Complete Guide with Trusted Publishing
Software Engineering

Publishing Python Packages to PyPI: Complete Guide with Trusted Publishing

Learn how to publish Python packages to PyPI using twine, trusted publishing with GitHub Actions, and modern best practices. Complete guide from package structure to automated releases.

By InventiveHQ Team

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:

CategoryExample Values
Development Status3 - Alpha, 4 - Beta, 5 - Production/Stable
Intended AudienceDevelopers, Science/Research, System Administrators
LicenseMIT License, Apache Software License, GPL v3
Programming LanguagePython :: 3.11, Python :: 3.12
TopicInternet :: 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

  1. Go to your project on PyPI (or create a new "pending publisher" for first upload)
  2. Manage → Publishing → Add a new publisher
  3. 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:

  1. Ensure readme = "README.md" is in pyproject.toml
  2. 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:

LicenseUse Case
MITMaximum permissiveness
Apache 2.0Enterprise-friendly with patent protection
GPL v3Copyleft (derivatives must be open source)
BSD 3-ClauseSimilar 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

For more Python development guides, explore our complete Python packaging series.

Frequently Asked Questions

Find answers to common questions

PyPI (Python Package Index) is the official repository for Python packages. Publishing there makes your package installable via pip install your-package for anyone in the world. It's the standard way to distribute reusable Python code and has over 500,000 packages.

Need Expert IT & Security Guidance?

Our team is ready to help protect and optimize your business technology infrastructure.