pyproject.toml

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the Python Reference bibliography.

pyproject.toml is the one file that every modern Python packaging tool reads. This page was written against pip 26.2.2, Poetry 2.5.1 with poetry-core 2.5.0, build 1.6.1 and hatchling 1.32.4, and the builds below were run on CPython 3.14.6 on 2026-10-22. The introduction to pyproject.toml shows a first example; this page goes through every table and key.

The file is TOML. It has three standard tables and one open namespace:

Table Purpose Read by

[build-system]

Which backend builds the project and what must be installed to run it (PEP 518, PEP 517).

Every build frontend: pip, build, Poetry, uv.

[project]

The project’s metadata: name, version, dependencies, entry points, … (PEP 621).

The build backend, which turns it into core metadata; Poetry 2 also uses it for resolution.

[dependency-groups]

Named lists of requirements that are not published: test, lint and docs tools (PEP 735).

pip (--group, since 25.1), Poetry (since 2.2), uv; never the backend.

[tool.<name>]

Settings of one tool. The name is the tool’s PyPI name.

That tool only: [tool.poetry], [tool.hatch], [tool.setuptools], [tool.ruff], [tool.pytest.ini_options], …

The [build-system] table

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

A list of PEP 508 requirements that the frontend installs into the isolated build environment before calling the backend. It must include the backend itself. Pin a minimum (the oldest version with the features you use, here hatchling’s PEP 639 support from 1.27.0) and, for backends that break compatibility on purpose, an upper bound on the next major (poetry-core>=2.0.0,<3.0.0 is what poetry new writes).

build-backend

The import path of the backend object, module or module:object. If it is missing, frontends fall back to setuptools.build_meta:__legacy__, which runs a setup.py the old way; always set it.

backend-path

A list of directories, relative to the project root, that are put on sys.path before importing the backend. It exists for in-tree backends (a project that is its own backend, such as a backend’s own repository) and should not be used otherwise.

The backends in common use, with the line each one needs, are listed on Build Backends.

The [project] table

Every key, its TOML type and the core metadata field the backend writes for it:

Key TOML type Core metadata field Notes

name

string

Name

Required, and the only key that can never be dynamic. Compared after normalization.

version

string

Version

Required unless listed in dynamic. A PEP 440 version.

description

string

Summary

One line.

readme

string or table

Description, Description-Content-Type

A path ("README.md", the type is guessed from .md, .rst, .txt) or {file = "…", content-type = "text/markdown"} or {text = "…", content-type = "…"}.

requires-python

string

Requires-Python

A version specifier such as ">=3.10". Do not add an upper bound.

license

string

License-Expression

An SPDX licence expression ("MIT", "Apache-2.0 OR MIT") since PEP 639. The old table form {text = …} / {file = …} is deprecated.

license-files

array of strings

License-File

Glob patterns relative to the project root; each match goes into .dist-info/licenses/. The specification says a pattern that matches nothing must fail the build; poetry-core 2.5.0 does, hatchling 1.32.4 did not (see below).

authors, maintainers

array of tables

Author, Author-email, Maintainer, Maintainer-email

Tables with name and/or email.

keywords

array of strings

Keywords

Joined with commas.

classifiers

array of strings

Classifier

Trove classifiers. License :: classifiers are deprecated in favour of license.

urls

table

Project-URL

Label to URL. Well-known labels (Homepage, Source, Documentation, Issues, Changelog, …) get icons on PyPI.

scripts, gui-scripts

table

entry_points.txt

Console and GUI launchers: birdtally = "birdtally.cli:main".

entry-points

table of tables

entry_points.txt

Other entry-point groups, used for plugins. console_scripts and gui_scripts are not allowed here.

dependencies

array of strings

Requires-Dist

PEP 508 requirements needed at run time.

optional-dependencies

table of arrays

Provides-Extra, Requires-Dist with extra == "…"

Extras, installed with birdtally[pretty].

import-names, import-namespaces

array of strings

Import-Name, Import-Namespace

PEP 794 (Accepted), see below.

dynamic

array of strings

Dynamic

Keys the backend will fill in.

The meaning of license, license-files and the classifiers, and how PyPI shows them, is covered in depth on Preparing a Library. The requirement syntax used by dependencies and optional-dependencies is on Versions & Dependency Specifiers.

The core metadata it becomes

The hatchling wheel of the birdtally file at the end of this page contains this METADATA; the README follows it as the message body:

Metadata-Version: 2.5
Name: birdtally
Version: 0.1.0
Summary: Tally bird-survey sightings from CSV files.
Project-URL: Homepage, https://example.org/birdtally
Project-URL: Changelog, https://example.org/birdtally/changelog
Author: The birdtally authors
License-Expression: MIT
License-File: LICENSE
Keywords: birds,csv,survey
Classifier: Development Status :: 3 - Alpha
Classifier: Programming Language :: Python :: 3
Classifier: Typing :: Typed
Requires-Python: >=3.10
Provides-Extra: pretty
Requires-Dist: rich>=13; extra == 'pretty'
Description-Content-Type: text/markdown

The poetry-core wheel of the same metadata says Metadata-Version: 2.4 and spells the requirement the older way, Requires-Dist: rich (>=13) ; extra == "pretty"; both are valid. Core Metadata 2.6 (PEP 808, May 2026) is the current version of the specification; backends are allowed to write the lowest version that holds every field they use, which is why neither writes 2.6 for this project. Version 2.4 added License-Expression and License-File (PEP 639), 2.5 added Import-Name and Import-Namespace (PEP 794), and 2.6 changed what Dynamic means for multiple-use fields (below). Tools must refuse metadata with a higher major version than they know, and should warn on a higher minor one; twine 7.0.0 accepts 1.0 to 2.5.

Static and dynamic metadata

Most values are written directly in [project]: they are static, so any tool can read them without running the backend. A key listed in dynamic is instead computed by the backend at build time, for example a version read from a Git tag or from __version__. The rules come from PEP 621 and were extended by PEP 808:

  • name can never be dynamic. A required field (name, version) must be either static or listed in dynamic.

  • If a key is not in dynamic, the backend must use the static value and must not fill it in itself.

  • If a key is in dynamic and the backend cannot determine it, the build fails.

  • For a single-value key, giving a static value and listing it in dynamic is an error.

  • Since PEP 808 (May 2026) a key whose value is a list or a table of arbitrary entries may be static and dynamic at the same time. The backend may then only append to the static value; it must not remove, reorder or change the entries you wrote. The keys are authors, classifiers, dependencies, entry-points, gui-scripts, import-names, import-namespaces, keywords, license-files, maintainers, optional-dependencies, scripts and urls.

Dynamic metadata has a cost: tools such as Poetry, uv and Dependabot cannot read a dynamic dependencies list without building the project. Keep dependencies static.

A dynamic version with hatchling reads __version__ from the package:

[project]
name = "birdtally"
dynamic = ["version"]

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

Listing version statically and in dynamic fails the build with hatchling’s message:

ValueError: Metadata field `version` cannot be both statically defined and listed in field `project.dynamic`

and listing name in dynamic fails with:

ValueError: Static metadata field `name` cannot be present in field `project.dynamic`

In the sdist’s PKG-INFO, Dynamic: version tells installers that a wheel built from the sdist may have a different version field. Since Core Metadata 2.6, a multiple-use field that is present in the sdist and also marked Dynamic may only be appended to in a wheel built from it; before 2.6 such a field was ignored in the sdist.

Poetry has its own dynamic-version mechanism through plugins; see Versioning & Release Management.

Import names and import namespaces

import-names lists the import names the project exclusively provides, and import-namespaces the namespace packages it contributes to without owning them (PEP 794, added to the specification in October 2025). A name followed by ; private marks a private module, an empty list declares a distribution without Python modules, and the same name in both lists is an error. The point is to let tools answer "which project provides import PIL?" from metadata:

[project]
name = "birdtally"
import-names = ["birdtally"]

With hatchling 1.32.4 this becomes Import-Name: birdtally in a Metadata-Version: 2.5 file. poetry-core 2.5.0 accepted the key on the day of the check but did not write the field, so do not rely on it with the Poetry flavour yet.

Extras and self-referential extras

An extra is a named set of optional dependencies. An extra may refer to other extras of the same project, which is the usual way to offer an all extra:

[project.optional-dependencies]
pretty = ["rich>=13"]
cli = ["click>=8"]
all = ["birdtally[pretty,cli]"]

The backend expands it, so the wheel’s metadata lists the dependencies themselves:

Provides-Extra: all
Requires-Dist: click>=8; extra == 'all'
Requires-Dist: rich>=13; extra == 'all'
Provides-Extra: cli
Requires-Dist: click>=8; extra == 'cli'

pip has accepted self-referential extras since 21.2 and supports them officially since 26.2; the specification documents them since August 2026.

The [dependency-groups] table

Extras are part of the published package. Development tools are not: nobody who installs birdtally needs pytest. Before PEP 735 every tool invented its own place for them (requirements-dev.txt, [tool.poetry.dev-dependencies], [tool.pdm.dev-dependencies]). [dependency-groups] standardises it:

[dependency-groups]
test = ["pytest>=8"]
lint = ["ruff>=0.6"]
docs = ["mkdocs>=1.6"]
dev = [{ include-group = "test" }, { include-group = "lint" }]
  • Each value is a list of PEP 508 strings or {include-group = "<name>"} tables. Includes are expanded recursively; a cycle is an error. Group names are normalized like project names.

  • A group is not a requirement on the project itself. To test the installed project, install the project and the group (pip install . --group test).

  • Build backends must not put dependency groups into the metadata of sdists or wheels. They stay in the copy of pyproject.toml inside the sdist, but they are not part of the package’s interface.

How pip installs groups is on pip: Dependency Groups & Inline Script Metadata, and how Poetry maps them to its own groups is on Poetry: Managing Dependencies & Groups. The tools that the groups name are covered in Testing, Debugging & Tooling and Docstrings.

The [tool] table and who reads what

[tool.<name>] is reserved for the tool whose PyPI name is <name>. No standard governs what is inside. The tools in this section read the file like this:

Tool What it reads

pip

[build-system] to set up the build; [dependency-groups] for --group. It never reads [project] itself: it asks the backend for the metadata.

Poetry 2.x

[project] for metadata and the main dependencies, [tool.poetry] for Poetry-only settings and enrichment (packages, include, source constraints, [tool.poetry.dependencies] details), [dependency-groups] and [tool.poetry.group.*] for groups, [tool.poetry.requires-plugins], [[tool.poetry.source]].

hatchling

[project] plus [tool.hatch.build], [tool.hatch.version], [tool.hatch.metadata].

setuptools

[project] plus [tool.setuptools] (package discovery, package-data, dynamic sources).

The complete birdtally file, both flavours

The two variants of the running scenario have the same code and the same metadata. The left-hand file builds with hatchling and is used with pip, build and twine; the right-hand file builds with poetry-core and is managed by Poetry. Both poetry check and python -m build accept them.

pip flavour (hatchling) Poetry flavour (poetry-core)
[build-system]
requires = ["hatchling>=1.27"]
build-backend = "hatchling.build"

[project]
name = "birdtally"
version = "0.1.0"
description = "Tally bird-survey sightings from CSV files."
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "The birdtally authors" }]
keywords = ["birds", "survey", "csv"]
classifiers = [
    "Development Status :: 3 - Alpha",
    "Programming Language :: Python :: 3",
    "Typing :: Typed",
]
dependencies = []

[project.optional-dependencies]
pretty = ["rich>=13"]

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

[project.urls]
Homepage = "https://example.org/birdtally"
Changelog = "https://example.org/birdtally/changelog"

[dependency-groups]
test = ["pytest>=8"]
lint = ["ruff>=0.6"]
docs = ["mkdocs>=1.6"]
dev = [{ include-group = "test" }, { include-group = "lint" }]
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"

[project]
name = "birdtally"
version = "0.1.0"
description = "Tally bird-survey sightings from CSV files."
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "The birdtally authors" }]
keywords = ["birds", "survey", "csv"]
classifiers = [
    "Development Status :: 3 - Alpha",
    "Typing :: Typed",
]
dependencies = []

[project.optional-dependencies]
pretty = ["rich>=13"]

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

[project.urls]
Homepage = "https://example.org/birdtally"
Changelog = "https://example.org/birdtally/changelog"

[tool.poetry]
packages = [{ include = "birdtally", from = "src" }]
include = [{ path = "CHANGELOG.md", format = "sdist" }]

[dependency-groups]
test = ["pytest>=8"]
lint = ["ruff>=0.6"]
docs = ["mkdocs>=1.6"]
dev = [{ include-group = "test" }, { include-group = "lint" }]

The differences are deliberate:

  • [build-system] names the backend. poetry-core is pinned below 3 because a major version is where Poetry puts the breaking changes it cannot phase in through deprecations (Poetry does not claim strict semantic versioning).

  • [tool.poetry] adds what [project] cannot express. hatchling finds src/birdtally on its own; poetry-core is told with packages, and include adds CHANGELOG.md to the sdist only. Without it the Poetry sdist would leave the changelog out, while hatchling’s sdist includes every file that Git does not ignore (including tests/ and examples/).

  • The Python classifier Programming Language :: Python :: 3 is written out in the hatchling file and left out of the Poetry one. With a static classifiers list poetry-core writes exactly what you list. If you instead move the list to

    [tool.poetry] classifiers and declare dynamic = ["classifiers"], poetry-core appends `Programming Language

    Python :: 3` and one 3.x classifier per minor version allowed by requires-python (3.10 to 3.15 for >=3.10 with poetry-core 2.5.0); see Poetry: pyproject.toml & Project Metadata.

Common mistakes

  • Missing build-backend. The legacy setuptools fallback runs and the project may build with the wrong backend.

  • Putting dev tools in optional-dependencies. They are then published as an extra; use [dependency-groups].

  • Declaring a dependency in both [project] and [tool.poetry.dependencies] with different constraints. Poetry 2 uses the [tool.poetry] entry only to enrich the [project] one (source, develop mode, …); keep the version constraint in one place.

  • Using caret or tilde constraints in [project.dependencies]. They are Poetry syntax and are not valid PEP 508; see the translation table on Versions & Dependency Specifiers.

  • A license-files glob that matches nothing. With license-files = ["LICENCE.txt"] (a misspelling of the real LICENSE) poetry build stops with No files found for license file glob pattern 'LICENCE.txt' and exit code 1, but hatchling 1.32.4 built the wheel without any licence file and without a License-File field, and printed no warning. Check the wheel with python -m zipfile -l dist/*.whl before publishing.

  • An upper bound on requires-python. Resolvers then refuse to install the project on newer Pythons, and Poetry propagates the bound to every dependent project.