Home/Blog/pyproject.toml - Complete Guide with Examples & Best Practices
Software Engineering

pyproject.toml - Complete Guide with Examples & Best Practices

Master Python pyproject.toml configuration: build-system, project metadata, tool settings. Examples for Hatchling, setuptools, Poetry-core, Flit. PEP 621 compliant templates.

By Inventive HQ Team
pyproject.toml - Complete Guide with Examples & Best Practices

The definitive guide to Python's modern project configuration standard. Learn to configure pyproject.toml for packages, applications, and development tools with practical examples and troubleshooting tips.

If you've worked with Python projects, you've likely encountered the confusion of multiple configuration files: setup.py, setup.cfg, requirements.txt, MANIFEST.in, and various tool-specific configs like pytest.ini and .flake8. The Python community recognized this fragmentation and created a solution: pyproject.toml.

This comprehensive guide covers everything you need to know about pyproject.toml, from basic setup to advanced configurations, with real-world examples you can copy and adapt for your projects.

What Is pyproject.toml?

pyproject.toml is Python's standardized configuration file for project metadata, build settings, and tool configurations. Introduced through a series of Python Enhancement Proposals (PEPs), it serves as a single source of truth for your Python project:

  • PEP 517 (2015) - Defined a build-system independent format for source trees
  • PEP 518 (2016) - Introduced pyproject.toml and the [build-system] table
  • PEP 621 (2020) - Standardized the [project] table for metadata

Before pyproject.toml, you needed setup.py (executable Python) for packaging, which posed security risks and made dependency resolution difficult. pyproject.toml is declarative (static TOML), making it safer, faster to parse, and easier to understand.

Quick Start: Minimal pyproject.toml

Here's the simplest pyproject.toml that actually works:

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

[project]
name = "my-package"
version = "1.0.0"
description = "A simple Python package"
requires-python = ">=3.9"
dependencies = [
    "requests>=2.28",
]

With this file in your project root, you can:

# Install your package in development mode
pip install -e .

# Build a distributable wheel
pip install build
python -m build

The Three Core Tables

pyproject.toml has three main sections, each serving a distinct purpose.

[build-system] - How Your Package Gets Built

The [build-system] table tells pip which tool to use when building your package:

[build-system]
requires = ["hatchling>=1.21"]
build-backend = "hatchling.build"
  • requires: Packages needed to build (installed in isolated environment)
  • build-backend: The module that performs the build

Build Backend Comparison

BackendBest ForKey FeaturesConfig Complexity
HatchlingNew projectsFast builds, minimal config, good defaultsLow
setuptoolsLegacy migrationMost compatible, feature-richMedium
FlitPure PythonSimplest possible, no extrasVery Low
Poetry-corePoetry usersLock file integrationMedium

Hatchling example:

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

setuptools example:

[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"

Flit example:

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

Poetry-core example:

[build-system]
requires = ["poetry-core>=1.9"]
build-backend = "poetry.core.masonry.api"

[project] - Your Package Metadata (PEP 621)

The [project] table contains all metadata about your package:

[project]
name = "my-awesome-package"
version = "2.1.0"
description = "A package that does awesome things"
readme = "README.md"
license = "MIT"
requires-python = ">=3.9"
authors = [
    { name = "Your Name", email = "[email protected]" }
]
maintainers = [
    { name = "Maintainer", email = "[email protected]" }
]
keywords = ["python", "awesome", "package"]
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",
]

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

[project.urls]
Homepage = "https://github.com/you/my-awesome-package"
Documentation = "https://my-awesome-package.readthedocs.io"
Repository = "https://github.com/you/my-awesome-package"
Changelog = "https://github.com/you/my-awesome-package/blob/main/CHANGELOG.md"

Optional Dependencies

Group dependencies for different use cases:

[project.optional-dependencies]
dev = [
    "pytest>=7.0",
    "pytest-cov>=4.0",
    "black>=23.0",
    "ruff>=0.1",
    "mypy>=1.0",
    "pre-commit>=3.0",
]
test = [
    "pytest>=7.0",
    "pytest-cov>=4.0",
    "pytest-asyncio>=0.21",
]
docs = [
    "sphinx>=7.0",
    "sphinx-rtd-theme>=2.0",
    "myst-parser>=2.0",
]

Install specific groups:

pip install -e ".[dev]"      # Development dependencies
pip install -e ".[test]"     # Testing only
pip install -e ".[dev,docs]" # Multiple groups

Entry Points (Console Scripts)

Create command-line tools:

[project.scripts]
my-cli = "my_package.cli:main"
my-tool = "my_package.tools:run"

After installation, users can run my-cli and my-tool directly from the terminal.

For GUI applications (no terminal window on Windows):

[project.gui-scripts]
my-gui-app = "my_package.gui:main"

[tool.*] - Tool Configuration

Configure development tools in one place:

Black (Code Formatter)

[tool.black]
line-length = 88
target-version = ["py39", "py310", "py311", "py312"]
include = '\.pyi?$'
exclude = '''
/(
    \.git
    | \.hg
    | \.mypy_cache
    | \.tox
    | \.venv
    | _build
    | buck-out
    | build
    | dist
)/
'''

Ruff (Fast Linter)

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

[tool.ruff.lint]
select = [
    "E",   # pycodestyle errors
    "W",   # pycodestyle warnings
    "F",   # Pyflakes
    "I",   # isort
    "B",   # flake8-bugbear
    "C4",  # flake8-comprehensions
    "UP",  # pyupgrade
]
ignore = ["E501"]  # line too long (handled by formatter)

[tool.ruff.lint.isort]
known-first-party = ["my_package"]

pytest (Testing)

[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_functions = ["test_*"]
addopts = [
    "-v",
    "--tb=short",
    "--strict-markers",
]
markers = [
    "slow: marks tests as slow",
    "integration: marks integration tests",
]
filterwarnings = [
    "ignore::DeprecationWarning",
]

mypy (Type Checking)

[tool.mypy]
python_version = "3.9"
strict = true
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true

[[tool.mypy.overrides]]
module = ["tests.*"]
disallow_untyped_defs = false

Coverage

[tool.coverage.run]
source = ["my_package"]
branch = true
omit = ["*/tests/*", "*/__pycache__/*"]

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "raise NotImplementedError",
    "if TYPE_CHECKING:",
]
fail_under = 80

Dynamic Metadata

Some fields can be computed at build time instead of hardcoded.

Version from Source Code

Keep version in one place (my_package/__init__.py):

__version__ = "2.1.0"

Reference it in pyproject.toml:

[project]
name = "my-package"
dynamic = ["version"]

[tool.hatchling.version]
path = "my_package/__init__.py"

Version from Git Tags

Use git tags as the source of truth:

[project]
name = "my-package"
dynamic = ["version"]

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

[tool.hatch.build.hooks.vcs]
version-file = "my_package/_version.py"

Complete Examples by Project Type

Library Package (for PyPI)

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

[project]
name = "my-library"
version = "1.0.0"
description = "A reusable Python library"
readme = "README.md"
license = "MIT"
requires-python = ">=3.9"
authors = [{ name = "Your Name", email = "[email protected]" }]
classifiers = [
    "Development Status :: 4 - Beta",
    "License :: OSI Approved :: MIT License",
    "Programming Language :: Python :: 3",
]
dependencies = [
    "requests>=2.28",
]

[project.optional-dependencies]
dev = ["pytest>=7.0", "black>=23.0", "mypy>=1.0"]

[project.urls]
Homepage = "https://github.com/you/my-library"

[tool.black]
line-length = 88

[tool.pytest.ini_options]
testpaths = ["tests"]

FastAPI Application

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

[project]
name = "my-api"
version = "1.0.0"
description = "FastAPI application"
requires-python = ">=3.10"
dependencies = [
    "fastapi>=0.109",
    "uvicorn[standard]>=0.27",
    "pydantic>=2.5",
    "sqlalchemy>=2.0",
    "alembic>=1.13",
]

[project.optional-dependencies]
dev = [
    "pytest>=7.0",
    "pytest-asyncio>=0.23",
    "httpx>=0.26",  # For testing FastAPI
    "black>=23.0",
    "ruff>=0.1",
]
postgres = ["psycopg2-binary>=2.9"]
mysql = ["pymysql>=1.1"]

[project.scripts]
start-api = "my_api.main:start"

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

CLI Tool

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

[project]
name = "my-cli-tool"
version = "2.0.0"
description = "A powerful command-line tool"
readme = "README.md"
license = "MIT"
requires-python = ">=3.9"
dependencies = [
    "click>=8.1",
    "rich>=13.0",
    "httpx>=0.26",
]

[project.scripts]
mycli = "my_cli_tool.main:cli"

[project.optional-dependencies]
dev = ["pytest>=7.0", "pytest-click>=1.1"]

Migrating from setup.py

Before (setup.py)

from setuptools import setup, find_packages

setup(
    name="my-package",
    version="1.0.0",
    description="My package description",
    author="Your Name",
    author_email="[email protected]",
    packages=find_packages(),
    install_requires=[
        "requests>=2.28",
        "click>=8.0",
    ],
    extras_require={
        "dev": ["pytest>=7.0", "black>=23.0"],
    },
    entry_points={
        "console_scripts": [
            "mycli=my_package.cli:main",
        ],
    },
    python_requires=">=3.9",
)

After (pyproject.toml)

[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "my-package"
version = "1.0.0"
description = "My package description"
requires-python = ">=3.9"
authors = [{ name = "Your Name", email = "[email protected]" }]
dependencies = [
    "requests>=2.28",
    "click>=8.0",
]

[project.optional-dependencies]
dev = ["pytest>=7.0", "black>=23.0"]

[project.scripts]
mycli = "my_package.cli:main"

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

Migration Mapping

setup.pypyproject.toml
name[project] name
version[project] version
description[project] description
author / author_email[project] authors
install_requires[project] dependencies
extras_require[project.optional-dependencies]
entry_points["console_scripts"][project.scripts]
python_requires[project] requires-python
packages[tool.setuptools.packages.find]

Working with Both Files

Many projects use pyproject.toml as the source of truth but generate requirements.txt for deployment. For a complete guide on requirements.txt, see our Python requirements.txt guide.

Generating requirements.txt

# Using pip-tools (recommended)
pip install pip-tools
pip-compile pyproject.toml -o requirements.txt

# With hashes for security
pip-compile pyproject.toml -o requirements.txt --generate-hashes

# Using Poetry
poetry export -f requirements.txt --output requirements.txt --without-hashes

# Using PDM
pdm export -o requirements.txt --without-hashes

Workflow

  1. Edit dependencies in pyproject.toml
  2. Run pip-compile pyproject.toml -o requirements.txt
  3. Commit both files
  4. Deploy using requirements.txt

For a detailed comparison of when to use each file, see our pyproject.toml vs requirements.txt comparison.

Troubleshooting Common Errors

"Preparing metadata (pyproject.toml) did not run successfully"

Causes:

  • Invalid TOML syntax
  • Missing [build-system] table
  • Incorrect build-backend path
  • Missing required fields in [project]

Solutions:

# Validate TOML syntax
python -c "import tomllib; tomllib.load(open('pyproject.toml', 'rb'))"

# Check for common issues:
# 1. Ensure [build-system] exists
# 2. Verify build-backend matches requires (hatchling -> hatchling.build)
# 3. Ensure [project] has 'name' and 'version' (or 'dynamic')

"Failed building wheel for package"

Causes:

  • Missing native build tools (gcc, Visual Studio)
  • Platform-specific package without wheel
  • Incompatible Python version

Solutions:

# On Ubuntu/Debian
sudo apt install build-essential python3-dev

# On macOS
xcode-select --install

# On Windows - install Visual Studio Build Tools

# Try installing with verbose output
pip install -v .

License Configuration Errors

PEP 639 changed license handling. Use the new format:

# New format (PEP 639)
[project]
license = "MIT"

# Or with file reference
[project]
license = {file = "LICENSE"}

"Invalid version" Errors

Versions must follow PEP 440:

# Valid versions
version = "1.0.0"
version = "2.0.0a1"      # Alpha
version = "2.0.0b2"      # Beta
version = "2.0.0rc1"     # Release candidate
version = "2.0.0.post1"  # Post release
version = "2.0.0.dev1"   # Development

# Invalid versions
version = "1.0"          # OK but prefer 1.0.0
version = "v1.0.0"       # No 'v' prefix
version = "1.0.0-beta"   # Use 1.0.0b1 instead

Best Practices

Always Specify requires-python

Prevents cryptic errors on incompatible Python versions:

[project]
requires-python = ">=3.9"

Use Optional Dependencies for Dev Tools

Keep production installs lean:

[project.optional-dependencies]
dev = ["pytest", "black", "mypy", "ruff"]

Pin Build Backend Versions

Prevent unexpected build failures:

[build-system]
requires = ["hatchling>=1.21,<2"]  # Pin major version

Use src/ Layout for Packages

Prevents accidental imports of uninstalled code:

my-project/
├── pyproject.toml
├── src/
│   └── my_package/
│       ├── __init__.py
│       └── module.py
└── tests/
    └── test_module.py
[tool.setuptools.packages.find]
where = ["src"]

Include py.typed for Type Hints

Signal that your package supports type checking:

my_package/
├── __init__.py
├── py.typed        # Empty file
└── module.py

Security Considerations

Review Transitive Dependencies

# See all dependencies (including transitive)
pip install pipdeptree
pipdeptree

# Audit for known vulnerabilities
pip install pip-audit
pip-audit

Use Hash Checking

pip-compile pyproject.toml -o requirements.txt --generate-hashes

Summary

pyproject.toml is Python's modern standard for project configuration, replacing the fragmented landscape of setup.py, setup.cfg, and various tool-specific config files. Key takeaways:

  • [build-system] defines how to build your package (use Hatchling for new projects)
  • [project] contains metadata and dependencies (PEP 621 standard)
  • [tool.*] consolidates configuration for Black, Ruff, pytest, mypy, and more
  • Use optional-dependencies to separate dev, test, and docs requirements
  • Generate requirements.txt from pyproject.toml for deployment reproducibility

For deciding when to use pyproject.toml versus requirements.txt, see our complete comparison guide. For mastering requirements.txt specifically, check out our requirements.txt guide.

Elevate Your IT Efficiency with Expert Solutions

Transform Your Technology, Propel Your Business

Building Python applications at scale? InventiveHQ provides expert consulting for Python development best practices, CI/CD pipelines, and cloud deployments. Our team can help you establish robust packaging workflows and development standards.

Discover Our Services

Frequently Asked Questions

Find answers to common questions

pyproject.toml is Python's standard configuration file for project metadata and build settings, defined by PEP 517, 518, and 621. It replaces setup.py, setup.cfg, and MANIFEST.in with a single TOML file. Use it to define package name, version, dependencies, and configure tools like Black, pytest, and mypy. Any Python project intended for distribution via PyPI should use pyproject.toml.

Let's turn this knowledge into action

Get a free 30-minute consultation with our experts. We'll help you apply these insights to your specific situation.