Back to Scaling Python Applications guides

Managing Python Projects With pyproject.toml

Stanley Ulili
Updated on March 6, 2025

pyproject.toml is a powerful configuration file format for Python projects that enhances project organization, build specifications, and dependency management.

It was introduced as part of PEP 518 and has become the standard for modern Python projects, earning its place as the default in major tools like Poetry, Hatch, and PDM.

With its broad ecosystem support and clean, readable syntax, pyproject.toml offers a vastly improved configuration experience compared to the legacy setup.py approach.

This article will guide you using pyproject.toml to create a well-structured Python project. You'll learn how to leverage its features and customize configurations to build a project that adheres to industry best practices.

Prerequisites

Before diving into the rest of this article, ensure you have a recent version of Python (3.13+) and pip installed on your machine. This article also assumes a basic understanding of Python and dependency management.

The history and purpose of pyproject.toml

For years, Python packaging was fragmented, relying on multiple configuration files like setup.py, requirements.txt, and tool-specific configs.

This led to inconsistencies, security risks, and maintenance challenges. In particular, the setup.py approach suffered from arbitrary code execution risks, bootstrapping issues, and lack of standardization.

To address these problems, PEP 518 introduced pyproject.toml in 2016, providing a standardized way to define build dependencies. Over time, additional PEPs refined its role, with significant tools like Poetry, Flit, and setuptools adopting it as a central configuration file.

Switching from setup.py to a declarative approach, pyproject.toml enhances security, simplifies dependency management, promotes consistency, and centralizes configuration for development tools.

Getting started with pyproject.toml

To get the most out of this tutorial, create a new Python project to try out the concepts we'll discuss.

Start by initializing a new project structure using the commands below:

 
mkdir pyproject-demo && cd pyproject-demo

Create the virtual environment:

 
python3 -m venv venv 

Activate the virtual environment:

 
source venv/bin/activate 

Now, create a basic pyproject.toml file in the root of your project directory:

pyproject.toml
[build-system]
requires = ["setuptools>=42", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "pyproject-demo"
version = "0.1.0"
description = "A sample Python project using pyproject.toml"
readme = "README.md"
authors = [
    {name = "Your Name", email = "your.email@example.com"}
]
requires-python = ">=3.7"

This snippet defines the build system requirements and basic project metadata. We'll explore all the different ways you can customize this configuration, but for now, let's set up a minimal package structure:

 
mkdir -p src/pyproject_demo

Create a simple module file:

src/pyproject_demo/__init__.py
"""A sample Python package using pyproject.toml."""

__version__ = "0.1.0"


def hello():
    """Return a friendly greeting."""
    return "Hello, world!"

Now, verify your setup by installing the package in development mode:

 
pip install -e .
Output
Obtaining file:///Users/stanley/pyproject-demo
  Installing build dependencies ... done
  Checking if build backend supports build_editable ... done
  Getting requirements to build editable ... done
  Preparing editable metadata (pyproject.toml) ... done
  ...
  Stored in directory: /private/var/folders/rr/372_1g9j1cbd1_zhrcc13s8m0000gn/T/pip-ephem-wheel-cache-25cy86ow/wheels/17/4b/2d/a93454a53e1a643831f489eb61407df39846d1d33d89c2428a
Successfully built pyproject-demo
Installing collected packages: pyproject-demo
Successfully installed pyproject-demo-0.1.0
...

Once installed, you can test the package in a Python interpreter. Open the shell with the following command:

 
python

Then, run the following code:

 
>>> from pyproject_demo import hello
>>> hello()
'Hello, world!'

The first thing you'll notice about pyproject.toml is that it uses TOML, a minimal configuration file format designed to be easy to read and write.

Unlike setup.py, which is executable Python code, pyproject.toml is a declarative specification, which eliminates many potential security issues and makes project configurations more consistent.

Understanding the core sections in pyproject.toml

Now that you've set up a basic project, let's explore the main sections of pyproject.toml in detail. Understanding these sections is crucial for effectively configuring your Python projects.

The build-system section

The [build-system] section is mandatory according to PEP 518. It specifies which build tools are required to build your package and which backend to use:

pyproject.toml
[build-system]
requires = ["setuptools>=42", "wheel"]
build-backend = "setuptools.build_meta"

The requires field lists the packages needed for building (not running) your package. The build-backend specifies which tool will interpret your build instructions. While setuptools is the most common backend, alternatives like Hatchling, Poetry, or Flit can also be used.

The project section

The [project] section contains metadata about your package and replaces the previous setuptools setup() call:

pyproject.toml
[project]
name = "pyproject-demo"
version = "0.1.0"
description = "A sample Python project using pyproject.toml"
readme = "README.md"
authors = [
    {name = "Your Name", email = "your.email@example.com"}
]
requires-python = ">=3.7"
license = {text = "MIT"}
classifiers = [
    "Development Status :: 3 - Alpha",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.7",
    "Programming Language :: Python :: 3.8",
    "Programming Language :: Python :: 3.9",
    "License :: OSI Approved :: MIT License",
    "Operating System :: OS Independent",
]

This section includes package name, version, description, and other metadata that will be used when publishing to the Python Package Index (PyPI). The requires-python field helps users know which Python versions are supported by your package.

Managing dependencies

One of the most significant improvements in pyproject.toml is how it handles dependencies. Dependencies are specified in the [project.dependencies] section:

pyproject.toml
[project]
# ... other project metadata ...

dependencies = [
    "requests>=2.28.0",
    "pyyaml>=6.0",
    "click>=8.1.0",
]

For optional features or development dependencies, you can use the [project.optional-dependencies] section:

pyproject.toml
[project.optional-dependencies]
dev = [
    "pytest>=7.0.0",
    "black>=22.3.0",
    "flake8>=4.0.1",
    "mypy>=0.950",
]

docs = [
    "sphinx>=4.5.0",
    "sphinx-rtd-theme>=1.0.0",
]

This approach lets users install extra dependencies only when needed:

 
pip install -e ".[dev]"  # Install with development dependencies
pip install -e ".[docs]"  # Install with documentation dependencies
pip install -e ".[dev,docs]"  # Install with both sets

Entry points and scripts

To create command-line scripts that can be called directly after installation, use the [project.scripts] section:

pyproject.toml
[project.scripts]
pyproject-demo = "pyproject_demo.cli:main"

This configuration makes a command called pyproject-demo available on the system path after installation, which calls the main() function in the pyproject_demo.cli module.

For more complex plugin registration or entry points that other packages can discover, use the [project.entry-points] section:

pyproject.toml
[project.entry-points."console_scripts"]
pyproject-demo = "pyproject_demo.cli:main"

[project.entry-points."pytest11"]
pyproject-plugin = "pyproject_demo.pytest_plugin"

Tool-specific configuration in pyproject.toml

Beyond the standard project metadata and dependency management, one of the most powerful features of pyproject.toml is its ability to house configuration for various development tools. This approach centralizes your project settings, eliminating the need for multiple configuration files scattered throughout your project directory.

Black code formatter

Black is a popular Python code formatter that enforces a consistent style across your codebase. You can configure Black directly in your pyproject.toml file:

pyproject.toml
[tool.black]
line-length = 88
target-version = ["py37", "py38", "py39"]
include = '\.pyi?

exclude = '''
/(
    \.git
  | \.hg
  | \.mypy_cache
  | \.tox
  | \.venv
  | _build
  | buck-out
  | build
  | dist
)/
'''

This configuration sets the maximum line length to 88 characters (Black's default), specifies which Python versions to target, and defines patterns for files to include or exclude from formatting. Black's native support for pyproject.toml makes it a perfect example of how modern Python tools are embracing this configuration approach.

MyPy type checking

MyPy is a static type checker for Python that helps catch type-related errors before runtime. Its configuration in pyproject.toml is straightforward:

pyproject.toml
[tool.mypy]
python_version = "3.7"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true

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

This configuration enforces strict type checking for your project code while relaxing the requirements for test files. The disallow_untyped_defs option ensures all functions have type annotations, improving code quality and documentation. With the override section, you can apply different rules to specific modules, making it easier to adopt type checking in larger projects gradually.

Ruff

Ruff is a fast Python linter written in Rust that aims to replace multiple tools like Flake8, isort, and more. Its configuration in pyproject.toml is comprehensive:

pyproject.toml
[tool.ruff]
# Enable flake8-bugbear (B) rules
select = ["E", "F", "B"]
# Ignore specific rules
ignore = ["E501"]
# Line length to target
line-length = 88
# Target Python version
target-version = "py37"
# Allow autofix for all enabled rules
fixable = ["ALL"]
# Allow unused variables with leading underscore
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"

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

This configuration enables specific rule sets (E for style errors, F for flake8 rules, and B for bugbear rules), ignores certain rules (like E501 for line length), and configures import sorting behavior. Ruff's unified approach to linting demonstrates the value of having a single configuration file for all your development tools, as it can replace multiple separate linters with a single, faster implementation.

Combining multiple tools

The real power of pyproject.toml comes from centralizing all these configurations in one place. Here's how they look together:

pyproject.toml
[build-system]
requires = ["setuptools>=42", "wheel"]
build-backend = "setuptools.build_meta"

[project]
# ... project metadata ...

[tool.black]
line-length = 88
target-version = ["py37", "py38", "py39"]

[tool.mypy]
python_version = "3.7"
warn_return_any = true
disallow_untyped_defs = true

[tool.ruff]
select = ["E", "F", "B"]
line-length = 88
target-version = "py37"

This unified approach offers several advantages:

  • Single source of truth: All configurations are in one file, making it easier to maintain consistency.
  • Reduced cognitive load: Developers don't need to remember multiple file formats and locations.
  • Easier onboarding: New team members can quickly understand the project's tooling setup.
  • Version control efficiency: Changes to tool configurations are tracked together, simplifying code reviews.

Most modern Python development tools now support pyproject.toml configuration, reflecting a community-wide shift toward standardization and simplification.

As you develop your Python projects, leveraging this centralized configuration approach can significantly improve your development workflow and project maintainability.

Dynamic versioning with pyproject.toml

One common challenge in Python projects is maintaining consistent version numbers across your package. Repeating the version in multiple places can lead to inconsistencies when one location is updated but others are forgotten.

Let's explore how to implement dynamic versioning with pyproject.toml to solve this problem.

Using a dedicated version file

A good practice is to create a single source of truth for your version number:

src/pyproject_demo/_version.py
"""Version information."""

__version__ = "0.1.0"

Then reference this version in your pyproject.toml file:

pyproject.toml
[project]
name = "pyproject-demo"
dynamic = ["version"]
# ... other project metadata ...

[tool.setuptools.dynamic]
version = {attr = "pyproject_demo._version.__version__"}

With this approach, you declare that the version is "dynamic" in the [project] section and specify its source in the [tool.setuptools.dynamic] section. This configuration tells setuptools to read the version from the __version__ variable in the pyproject_demo._version module at build time.

The primary benefit of this method is that both your code and build system reference the same version string. Your package can access its version at runtime using:

 
from pyproject_demo._version import __version__

print(f"Running version {__version__}")

Using SCM-based versioning

For more advanced projects, you can use tools like setuptools-scm, which automatically derives your package version from git tags:

pyproject.toml
[build-system]
requires = ["setuptools>=42", "wheel", "setuptools_scm[toml]>=6.2"]
build-backend = "setuptools.build_meta"

[project]
name = "pyproject-demo"
dynamic = ["version"]
# ... other project metadata ...

[tool.setuptools_scm]
write_to = "src/pyproject_demo/_version.py"

This configuration adds setuptools_scm to your build dependencies and tells it to write the version (derived from git tags and local changes) to a _version.py file.

When you tag a commit in your git repository (e.g., git tag -a v0.2.0 -m "Version 0.2.0"), setuptools-scm will use that tag as the version. For local development with uncommitted changes, it will add development suffixes (like .dev1+g4f8e9d1.d20230315), helping you track exactly which code version is being used.

The version file is generated during the build process, so it won't exist in the source repository but will be available in the installed package:

 
>>> import pyproject_demo
>>> pyproject_demo.__version__
'0.2.0'  # Or something like '0.2.0.dev1+g4f8e9d1.d20230315' for development builds

Manually managing versions in pyproject.toml

For simpler projects, you might prefer to maintain the version directly in pyproject.toml:

pyproject.toml
[project]
name = "pyproject-demo"
version = "0.1.0"
# ... other project metadata ...

However, you'll need a way for your package to access this version at runtime. One approach is to use the importlib.metadata module (available in Python 3.8+ or via the importlib-metadata backport):

src/pyproject_demo/__init__.py
"""A sample Python package using pyproject.toml."""

try:
    from importlib.metadata import version, PackageNotFoundError
except ImportError:
    from importlib_metadata import version, PackageNotFoundError

try:
    __version__ = version("pyproject-demo")
except PackageNotFoundError:
    # Package is not installed
    __version__ = "unknown"


def hello():
    """Return a friendly greeting."""
    return "Hello, world!"

This method works well for installed packages but has limitations during development. It's best suited for simple projects or when integration with development tools that expect a static version number is not needed.

Final thoughts

This article explored how pyproject.toml has transformed Python project management through centralized, declarative configuration.

The adoption of pyproject.toml by the Python ecosystem demonstrates a commitment to standardization that benefits developers with enhanced security, simplified workflows, and better dependency management.

Consider migrating your existing projects to pyproject.toml, starting simple and gradually incorporating more advanced features. Effective Python project management is an ongoing refinement process, and embracing this approach will serve you well as your projects evolve.

Happy coding!

Author's avatar
Article by
Stanley Ulili
Stanley Ulili is a technical educator at Better Stack based in Malawi. He specializes in backend development and has freelanced for platforms like DigitalOcean, LogRocket, and AppSignal. Stanley is passionate about making complex topics accessible to developers.
Got an article suggestion? Let us know
Next article
Get Started with Job Scheduling in Python
Learn how to create and monitor Python scheduled tasks in a production environment
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

Make your mark

Join the writer's program

Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.

Write for us
Writer of the month
Marin Bezhanov
Marin is a software engineer and architect with a broad range of experience working...
Build on top of Better Stack

Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.

community@betterstack.com

or submit a pull request and help us build better products for everyone.

See the full list of amazing projects on github