Skip to content
Codeloom
Python

Python Packaging with pyproject.toml

A complete guide to modern Python packaging using pyproject.toml, build backends, and best practices for distributing your library.

·5 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • Why pyproject.toml replaced setup.py and setup.cfg
  • Writing a complete pyproject.toml from scratch
  • Choosing between build backends like setuptools, hatchling, and flit
  • Building and publishing a package to PyPI

Prerequisites

None — this post is self-contained.

For years, Python packaging meant writing a setup.py file with imperative code. That approach had problems: it required executing arbitrary code just to read metadata, it mixed build logic with project configuration, and it made reproducible builds difficult. PEP 517 and PEP 621 introduced pyproject.toml as the single source of truth for project metadata and build configuration. This article shows you how to use it.

The Minimum Viable pyproject.toml

A complete, publishable package only needs one file:

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "my-library"
version = "0.1.0"
description = "A short description of what this library does"
readme = "README.md"
license = "MIT"
requires-python = ">=3.10"
authors = [
    { name = "Your Name", email = "you@example.com" },
]

dependencies = [
    "requests>=2.28",
    "click>=8.0",
]

The [build-system] table tells tools like pip and build which backend to use. The [project] table holds all your metadata in a standardized format defined by PEP 621.

Project Structure

A typical layout looks like this:

my-library/
  pyproject.toml
  README.md
  LICENSE
  src/
    my_library/
      __init__.py
      core.py
      utils.py
  tests/
    test_core.py
    test_utils.py

The src/ layout is recommended because it prevents accidental imports of your uninstalled package during development. When you run pip install -e ., pip installs a link to the src/my_library directory.

Detailed Configuration

Optional Dependencies

Group optional features so users install only what they need:

[project.optional-dependencies]
dev = [
    "pytest>=7.0",
    "ruff>=0.4",
    "mypy>=1.5",
]
docs = [
    "sphinx>=7.0",
    "sphinx-rtd-theme>=2.0",
]
postgres = [
    "psycopg2-binary>=2.9",
]

Install with pip install my-library[dev] or pip install my-library[dev,docs].

Entry Points and Scripts

If your package provides a command-line tool:

[project.scripts]
my-cli = "my_library.cli:main"

This creates a my-cli executable that calls my_library.cli.main() when installed.

For plugin systems, use entry point groups:

[project.entry-points."my_library.plugins"]
csv = "my_library.plugins.csv:CsvPlugin"
json = "my_library.plugins.json:JsonPlugin"

Classifiers and URLs

[project]
classifiers = [
    "Development Status :: 4 - Beta",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
    "License :: OSI Approved :: MIT License",
    "Typing :: Typed",
]

[project.urls]
Homepage = "https://github.com/you/my-library"
Documentation = "https://my-library.readthedocs.io"
Repository = "https://github.com/you/my-library"
Issues = "https://github.com/you/my-library/issues"

Choosing a Build Backend

The [build-system] table determines which tool turns your source code into a distributable package. Here are the main options:

Hatchling

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

Fast, modern, and batteries-included. Supports dynamic versioning from git tags and VCS-based file inclusion. This is a strong default choice for new projects.

Setuptools

[build-system]
requires = ["setuptools>=68.0", "setuptools-scm>=8.0"]
build-backend = "setuptools.build_meta"

[tool.setuptools.packages.find]
where = ["src"]

The legacy standard. If you are migrating from setup.py, setuptools is the easiest transition. It now fully supports pyproject.toml.

Flit

[build-system]
requires = ["flit_core>=3.9"]
build-backend = "flit_core.buildapi"

Minimal and opinionated. Best for pure-Python packages with no build steps.

Tool Configuration

pyproject.toml is also the standard location for tool configuration. This keeps everything in one file:

[tool.ruff]
line-length = 88
target-version = "py310"

[tool.ruff.lint]
select = ["E", "F", "I", "UP"]

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra -q"

[tool.mypy]
python_version = "3.10"
strict = true
warn_return_any = true

Building and Publishing

Building the Package

Install the build tool and run it:

pip install build
python -m build

This creates two files in the dist/ directory:

  • my_library-0.1.0.tar.gz — the source distribution
  • my_library-0.1.0-py3-none-any.whl — the wheel (binary distribution)

Publishing to PyPI

Use twine to upload:

pip install twine
twine upload dist/*

For testing, publish to TestPyPI first:

twine upload --repository testpypi dist/*
pip install --index-url https://test.pypi.org/simple/ my-library

Dynamic Versioning

Instead of hardcoding the version, you can derive it from git tags:

[project]
dynamic = ["version"]

[tool.hatch.version]
source = "vcs"

[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"

Now, tagging a commit with v0.2.0 and building will automatically set the version.

Migration from setup.py

If you have an existing setup.py, the migration is straightforward. Map each argument to its pyproject.toml equivalent:

setup.pypyproject.toml
name="pkg"[project] name = "pkg"
install_requires=[...][project] dependencies = [...]
extras_require={...}[project.optional-dependencies]
entry_points={...}[project.scripts] or [project.entry-points]
python_requires=">=3.10"[project] requires-python = ">=3.10"

Once everything is in pyproject.toml, you can delete setup.py, setup.cfg, and MANIFEST.in.

Key Takeaways

Modern Python packaging is declarative. Put your metadata in pyproject.toml, pick a build backend, and let standardized tools handle the rest. The days of writing imperative setup.py scripts are behind us.