Type something to search...
Publishing Your First Python Package to PyPI

Publishing Your First Python Package to PyPI

At some point a helper module you keep copying between projects deserves a proper home. Publishing it to PyPI means anyone, including future you, can install it with a single pip install. The process used to involve setup.py, setup.cfg, MANIFEST.in, and a fair amount of folklore. Today it's one pyproject.toml, two commands, and an account.

This guide walks through the whole thing with a small, real package: laying out the project, writing the metadata, building the distribution files, checking them, doing a dry run on TestPyPI, uploading to the real index, and finally automating releases with trusted publishing from GitHub Actions.

What You're Actually Uploading

When you "publish a package", you upload one or more distribution files to the Python Package Index:

  • A source distribution (sdist), a .tar.gz containing your source code and pyproject.toml. Installers can build from it if no compatible wheel exists.
  • A wheel, a .whl file (really a zip) that's ready to install with no build step. For pure-Python code there's one wheel for every platform, tagged py3-none-any.

You build both locally (or in CI), then upload them. PyPI never runs your build; it just stores the files and serves them to pip, uv, and every other installer.

Step 1: Pick a Name

Names on PyPI are first come, first served, and they're normalized: Tidy_Slug, tidy-slug, and tidy.slug all count as the same project. Before you write any metadata, search pypi.org for your name. If https://pypi.org/project/<name>/ returns a page, it's taken.

The distribution name (what people pip install) and the import name (what they import) don't have to match, but life is easier when they do. For this guide the package is called tidyslug, a tiny library that turns strings into URL slugs. Swap in your own name everywhere you see it.

Step 2: Lay Out the Project

Use the src layout. Putting your package inside a src/ directory means your tests run against the installed package rather than whatever happens to be on the current directory's import path, which catches packaging mistakes (like a missing file) before your users do.

tidyslug/
├── LICENSE
├── README.md
├── pyproject.toml
├── src/
│   └── tidyslug/
│       ├── __init__.py
│       ├── cli.py
│       └── py.typed
└── tests/
    └── test_slugify.py

The library itself is short:

# src/tidyslug/__init__.py
"""Turn any string into a clean, URL-friendly slug."""

import re
import unicodedata

__all__ = ["slugify"]
__version__ = "0.1.0"

_NON_WORD = re.compile(r"[^a-z0-9]+")


def slugify(text: str, separator: str = "-") -> str:
    """Return a lowercase ASCII slug for *text*."""
    normalized = unicodedata.normalize("NFKD", text)
    ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
    slug = _NON_WORD.sub(separator, ascii_text.lower())
    return slug.strip(separator)

unicodedata.normalize("NFKD", ...) splits accented characters into a base letter plus a combining mark, and encoding to ASCII with "ignore" drops the marks. Everything that isn't a lowercase letter or digit collapses into the separator.

To show off a console command as well, add a tiny CLI module:

# src/tidyslug/cli.py
import sys

from tidyslug import slugify


def main() -> None:
    text = " ".join(sys.argv[1:])
    print(slugify(text))

The empty py.typed file is a marker from PEP 561. It tells type checkers like mypy and Pyright that your package ships inline type hints, so they'll use them instead of treating your code as untyped.

If you're fuzzy on how packages and __init__.py files relate, this primer on modules and packages covers the basics.

Step 3: Write pyproject.toml

This one file declares how to build your package and everything PyPI will display about it.

# pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "tidyslug"
version = "0.1.0"
description = "Turn any string into a clean, URL-friendly slug."
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "Maria Example", email = "maria@example.com" }]
keywords = ["slug", "url", "text"]
classifiers = [
    "Programming Language :: Python :: 3",
    "Operating System :: OS Independent",
    "Development Status :: 3 - Alpha",
]
dependencies = []

[project.urls]
Homepage = "https://github.com/maria-example/tidyslug"
Issues = "https://github.com/maria-example/tidyslug/issues"

[project.scripts]
tidyslug = "tidyslug.cli:main"

A few fields deserve a closer look:

  • [build-system] tells build tools which backend turns your source tree into distributions. Hatchling is a solid default. Setuptools (setuptools.build_meta), Flit (flit_core.buildapi), PDM (pdm.backend), and uv's own uv_build all work too; the rest of this guide doesn't change.
  • readme becomes the long description on your PyPI page. Markdown and reStructuredText are both supported, and the content type is inferred from the extension.
  • license is an SPDX expression such as "MIT" or "Apache-2.0", and license-files lists the files to include in the distributions. This is the PEP 639 format. Older guides put a License :: OSI Approved :: ... classifier in the list; with a license expression you should leave those classifiers out.
  • requires-python stops installers from picking your package on interpreters you don't support. Be honest about it: if you use match statements, you need at least 3.10.
  • dependencies lists runtime requirements with loose bounds, such as "httpx>=0.27". Don't pin exact versions in a library; that's the application's job.
  • [project.scripts] creates a tidyslug command on install that calls tidyslug.cli:main.

For a field-by-field tour of everything else the file can hold, see Understanding pyproject.toml.

Keeping the Version in One Place

Right now the version lives in two places: pyproject.toml and __version__. They will drift. With Hatchling you can mark the version as dynamic and read it from your code:

[project]
name = "tidyslug"
dynamic = ["version"]
# ...the rest stays the same, minus the version line

[tool.hatch.version]
path = "src/tidyslug/__init__.py"

Hatchling looks for a __version__ = "..." assignment in that file. Alternatively, keep the version only in pyproject.toml and read it at runtime with importlib.metadata.version("tidyslug"). Either approach is fine; pick one and stick to it.

Step 4: Build the Distributions

Create a virtual environment for your tooling (see venv explained if you need a refresher), then install build and twine:

python -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade build twine

Build from the project root:

python -m build
* Creating isolated environment: venv+pip...
* Installing packages in isolated environment:
  - hatchling
...
Successfully built tidyslug-0.1.0.tar.gz and tidyslug-0.1.0-py3-none-any.whl

build creates a throwaway environment, installs whatever your [build-system] requires, and asks the backend to produce both files in dist/. Because the environment is isolated, a successful build here means it will also succeed on someone else's machine.

Look Inside Before You Ship

It takes ten seconds to confirm the wheel contains what you expect:

python -m zipfile -l dist/tidyslug-0.1.0-py3-none-any.whl
File Name                                             Modified             Size
tidyslug/__init__.py                           2020-02-02 00:00:00          488
tidyslug/cli.py                                2020-02-02 00:00:00          122
tidyslug/py.typed                              2020-02-02 00:00:00            0
tidyslug-0.1.0.dist-info/METADATA              2020-02-02 00:00:00          717
tidyslug-0.1.0.dist-info/WHEEL                 2020-02-02 00:00:00           87
tidyslug-0.1.0.dist-info/entry_points.txt      2020-02-02 00:00:00           47
tidyslug-0.1.0.dist-info/licenses/LICENSE      2020-02-02 00:00:00           46
tidyslug-0.1.0.dist-info/RECORD                2020-02-02 00:00:00          618

(The fixed 2020 timestamps are deliberate: Hatchling writes reproducible wheels.) Note that tests/ isn't in the wheel, which is what you want, and the src/ prefix is gone, so users import tidyslug, not src.tidyslug.

Then run Twine's metadata check, which catches problems like a README that PyPI can't render:

twine check dist/*
Checking dist/tidyslug-0.1.0-py3-none-any.whl: PASSED
Checking dist/tidyslug-0.1.0.tar.gz: PASSED

Test the Wheel Like a User Would

Install the wheel into a fresh environment, outside your project, and use it:

python -m venv /tmp/try-tidyslug
/tmp/try-tidyslug/bin/pip install dist/tidyslug-0.1.0-py3-none-any.whl
/tmp/try-tidyslug/bin/tidyslug "Héllo, Wörld!  Ready?"
hello-world-ready

If the import or the command fails here, it will fail for everyone. Fix it now, before the version number is burned.

Step 5: Create Your Accounts and Tokens

You need two accounts, because they're completely separate systems:

  • TestPyPI at test.pypi.org, a sandbox for practice uploads.
  • PyPI at pypi.org, the real index.

Both require two-factor authentication before you can upload. Once that's set up, go to Account settings → API tokens and create a token. For the very first upload the token has to be scoped to your whole account, because the project doesn't exist yet. After the first release, delete it and create a new token scoped to just that project.

Twine reads credentials from ~/.pypirc:

# ~/.pypirc
[pypi]
username = __token__
password = pypi-AgEIcHlwaS5vcmc...

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

The username is literally the string __token__; the password is the full token including its pypi- prefix. If you'd rather not store tokens in a file, set the TWINE_USERNAME and TWINE_PASSWORD environment variables instead. Either way, never commit a token to Git. PyPI scans public repositories for leaked tokens and revokes them, but you don't want to rely on that.

Step 6: Dry Run on TestPyPI

Upload to the sandbox first:

twine upload --repository testpypi dist/*

Twine prints a link to your new project page. Open it and check that the README renders, the links work, and the metadata looks right.

Then install from it. TestPyPI doesn't mirror the real index, so if your package has dependencies, point an extra index at PyPI so they can be resolved:

python -m venv /tmp/try-testpypi
/tmp/try-testpypi/bin/pip install \
  --index-url https://test.pypi.org/simple/ \
  --extra-index-url https://pypi.org/simple/ \
  tidyslug

TestPyPI is occasionally wiped and anyone can register names there, so don't treat it as anything more than a rehearsal space.

Step 7: Publish for Real

When the TestPyPI run looks good, upload the same files to PyPI:

twine upload dist/*

Within a minute your project is live at https://pypi.org/project/tidyslug/, and anyone can run:

pip install tidyslug

One rule to internalize: a version can only be uploaded once. PyPI won't let you replace 0.1.0, even if you delete it. If you discover a mistake after uploading, fix it and release 0.1.1. You can "yank" a broken release from the project's management page, which hides it from installers that don't pin that exact version, but the file name is still used up.

That's also why it pays to clean dist/ before every build. Old files lingering there will make twine upload dist/* try to re-upload previous versions:

rm -rf dist/
python -m build

Step 8: Automate Releases with Trusted Publishing

Manually uploading with a long-lived token works, but the better setup for anything you maintain is trusted publishing. Instead of storing a token as a CI secret, you tell PyPI "releases for this project come from this GitHub repository and this workflow". GitHub Actions then exchanges a short-lived OpenID Connect token for a temporary upload credential. There's nothing to leak or rotate.

On PyPI, open your project's Settings → Publishing page and add a GitHub publisher with your repository owner, repository name, workflow file name (publish.yml), and an environment name (pypi). You can even do this before the first release by adding a "pending publisher" from your account's publishing page, which lets CI create the project.

Then add the workflow:

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

on:
  release:
    types: [published]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.13"
      - run: python -m pip install build
      - run: python -m build
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  publish:
    needs: build
    runs-on: ubuntu-latest
    environment: pypi
    permissions:
      id-token: write
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/
      - uses: pypa/gh-action-pypi-publish@release/v1

The workflow runs whenever you publish a GitHub release. The build job produces dist/ and hands it to the publish job as an artifact. The publish job runs in the pypi environment and has id-token: write, which is the permission that allows it to request the OIDC token. The official pypa/gh-action-pypi-publish action handles the exchange and the upload, and it also generates digital attestations for your files by default.

Splitting build and publish into two jobs keeps the permission to publish away from the steps that install and run third-party build code. In the GitHub repository settings you can also add protection rules to the pypi environment, such as requiring a manual approval, so a release can't go out by accident.

Using uv Instead of build and Twine

If you already manage your project with uv, it has both halves built in:

uv build
uv publish --index testpypi   # needs a [[tool.uv.index]] entry named testpypi
uv publish                    # uploads dist/* to PyPI

uv build produces the same sdist and wheel, using whatever backend your [build-system] names. uv publish uploads them and supports trusted publishing from GitHub Actions as well. The uv guide covers the rest of its workflow.

A Release Checklist

Once you've done this a couple of times, every release boils down to:

  1. Bump the version (following semantic versioning is a good habit: patch for fixes, minor for new features, major for breaking changes).
  2. Update the changelog.
  3. Run your tests.
  4. rm -rf dist/ && python -m build
  5. twine check dist/*, and install the wheel into a throwaway venv.
  6. Tag and publish a GitHub release (with trusted publishing), or twine upload dist/*.

Common Problems

Error or symptomLikely cause
403 Forbidden on uploadName already taken by another project, or the token is scoped to a different project
400 File already existsYou're re-uploading a version; bump it, and clear old files from dist/
README shows as raw textreadme points to a file with an unknown extension, or the Markdown is invalid; run twine check
ModuleNotFoundError after installPackage directory not found by the backend, or a missing __init__.py; inspect the wheel's file list
Users on old Python get a broken installrequires-python is missing or too loose

Conclusion

Publishing to PyPI is much less ceremony than its reputation suggests. Put your code in a src layout, describe it in pyproject.toml, build with python -m build, verify with twine check and a throwaway install, rehearse on TestPyPI, and upload. Once the first release is out, switch to trusted publishing so future releases are just "create a GitHub release" with no tokens to manage.

The part that takes real care is everything around the upload: an honest requires-python, loose dependency bounds, a README that explains what the package does, and version numbers that tell users what changed. Get those right and your small helper module becomes something other people can depend on.

Tags :
Share :

Related Posts

Abstract Base Classes in Python with the abc Module

Abstract Base Classes in Python with the abc Module

Python leans on duck typing: if an object has the method you need, you call it and move on. That works well until you have a family of classes that a

Continue Reading
*args and **kwargs in Python: Flexible Function Signatures

*args and **kwargs in Python: Flexible Function Signatures

You've seen def wrapper(*args, **kwargs): in decorators, and probably super().__init__(**kwargs) in class hierarchies. These two parameters let a

Continue Reading
Asyncio in Python: A Beginner's Guide to Asynchronous Programming

Asyncio in Python: A Beginner's Guide to Asynchronous Programming

A lot of programs spend most of their time waiting. A web scraper waits for pages to download, an API server waits for the database, a chat bot waits

Continue Reading