skip to content

pyproject.toml and Build Backends

The single declarative file describing a Python project and the pluggable backend that turns it into artifacts. Expect to separate [project], what your package is, from [build-system], who builds it.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

4

What does the [project] table in a pyproject.toml file declare?

level: juniorimportance: must knowfreq 62%

answer

  1. The standard file every project now carries
  2. One table describes the package itself
  3. PEP 621 static metadata, not code
  4. name, version, requires-python, dependencies
  5. Becomes METADATA in the installed distribution

basics

~20 s

The [project] table holds a package's standardised static metadata: distribution name, version, requires-python, runtime dependencies, readme, license, classifiers and URLs. The build backend copies it into the built artifact's metadata. It says what the package is, not how it is built.

solid answer

~40 s

`[project]` is the PEP 621 metadata table: a declarative description of the distribution, written as data rather than as code. The common fields are `name` (the distribution name people `pip install`), `version`, `description`, `readme`, `requires-python`, `dependencies` (runtime requirements as PEP 508 strings), `license`, `authors`, `classifiers` and `urls`. Whichever build backend you use reads that one table and writes it into the core metadata of the artifact it produces, which is what an installer, a resolver and `importlib.metadata` later read. The key separation to state in an interview: `[project]` is *what the package is*, `[build-system]` is *who builds it*, and `[tool.<name>]` tables are per-tool configuration that installers ignore entirely. Because it is static data, tools can read a project's requirements without executing any of its code.

code

python · 18 lines
python
import tomllib

src = b"""
[build-system]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"

[project]
name = "sensor-collector"
version = "0.3.0"
requires-python = ">=3.11"
dependencies = ["packaging>=24"]
"""

cfg = tomllib.loads(src.decode())
project = cfg["project"]
print(project["name"], project["version"])
print(project["requires-python"], project["dependencies"])

go deeper

for a junior

Be ready to name the common [project] fields from memory — name, version, requires-python, dependencies, readme — and to say in one sentence that this table describes the package rather than building it.

for a middle

Explain how the table becomes core metadata in the artifact: Requires-Dist lines, Requires-Python, and the dist-info/METADATA file that importlib.metadata reads back after installation.

for a senior

Show judgement about where requirements belong: runtime versus build-time lists, environment markers on dependencies, and a requires-python floor that fails resolution early instead of at import time.

for a principal

Own the argument for static metadata across a fleet of repositories: resolvers, mirrors, licence audits and vulnerability scanners can all read a project without executing its code, which is what makes packaging policy enforceable at scale.

## Why a declarative table exists at all For most of Python's history a project described itself by *running code*: `setup.py` called `setup(...)` with keyword arguments, so the only way to learn a project's name, version or dependencies was to execute an arbitrary script from a stranger. That is a chicken-and-egg problem (the script may import the very library it needs to describe itself) and a security problem. `pyproject.toml` replaced it with data. TOML was chosen because it has one obvious parse, and since Python 3.11 the standard library can read it with `tomllib` — no third-party parser required. A `pyproject.toml` has three families of tables: * `[build-system]` — the build-time contract: which tools build this project. * `[project]` — PEP 621 core metadata: what this distribution *is*. * `[tool.<name>]` — a namespace each tool owns for its own settings. Installers ignore these completely; a formatter or a type checker reads its own subtable and nothing else. ## The fields, and what they mean `name` is the **distribution** name — the string you type after `pip install` and the name PyPI reserves. It is normalised (lowercased, and runs of `-`, `_` and `.` folded to a single `-`), so `Sensor_Collector` and `sensor-collector` are the same project. Crucially it is *not* required to match the importable package name; a distribution named `python-dateutil` installs a package you import as `dateutil`, and that gap is a routine source of confusion when reading a lock file. `version` is a PEP 440 version string (`1.4.0`, `2.0.0rc1`, `0.3.0.post1`). `requires-python` is a specifier such as `>=3.11` that installers consult *before* downloading, so an old interpreter gets a clear resolution error rather than a broken install. `dependencies` is a list of PEP 508 requirement strings — `"packaging>=24"`, `"tomli; python_version < '3.11'"` — and each may carry an environment marker so a requirement applies only on some platforms or interpreter versions. `description` is the one-line summary shown in search results; `readme` points at the long description file; `license` and `license-files` describe licensing (modern backends accept an SPDX expression string plus a glob of license files); `authors`, `maintainers`, `keywords`, `classifiers` and `urls` are catalogue metadata. A `dynamic` list names fields the backend computes at build time instead of you writing them literally; a field may be static or dynamic, never both. ## What the table turns into The backend translates `[project]` into **core metadata**: the `METADATA` file inside a wheel, `PKG-INFO` inside a source distribution, and the `<name>-<version>.dist-info/METADATA` file in the installed environment. The mapping is mechanical: `name` becomes `Name`, `dependencies` entries become repeated `Requires-Dist` lines, `requires-python` becomes `Requires-Python`. At runtime `importlib.metadata` reads exactly that file, which is why `importlib.metadata.version("somedist")` asks for the *distribution* name rather than the module you imported. ## The two dependency lists people confuse `[project] dependencies` are **runtime** requirements: installed alongside your package into the user's environment. `[build-system] requires` are **build-time** requirements: installed into a temporary environment that exists only while the artifact is produced, and never shipped to the user. A build plugin belongs in the second list; putting it in the first makes every user install a tool they will never run. The reverse mistake — a runtime dependency listed only in `requires` — produces a package that builds fine and fails on first import. ## Reading it back ```python import tomllib with open("pyproject.toml", "rb") as fh: cfg = tomllib.load(fh) project = cfg["project"] print(project["name"], project["version"]) print(project.get("requires-python")) ``` Note `tomllib.load` wants a **binary** file object; handing it a text handle raises `TypeError`. That is the standard-library reader added in Python 3.11. ## What an interviewer is checking Three things, usually. First, that you know the metadata is *static data*, not executed code, and why that matters to resolvers, mirrors and auditing tools. Second, that you can separate `[project]` from `[build-system]` without hesitating — a surprising number of candidates believe `pyproject.toml` is a setuptools file, when it is a standard every backend implements. Third, that you know the distribution name and the import name are different namespaces. If you can also say where the table ends up after installation — `dist-info/METADATA`, readable via `importlib.metadata` — you have covered the whole life cycle of the field in about ninety seconds.

  • Does the [project] name field have to match the name users import?
    No. `name` is the distribution name — what you `pip install` and what PyPI reserves — and it is normalised to lowercase with `-`, `_` and `.` folded to a single hyphen. The importable package name is whatever directory the backend puts in the wheel, and the two frequently differ. That is why `importlib.metadata.version()` takes the distribution name, not the module name, and why a lock file entry can look unrelated to the import at the top of your file.
  • How does a running program read the metadata that came from [project]?
    Through `importlib.metadata`, which reads the `dist-info/METADATA` file the installer wrote. `importlib.metadata.version("sensor-collector")` returns the version string, and `importlib.metadata.distribution(...)` gives the whole record — `Requires-Dist` lines, `Requires-Python`, classifiers. It reflects what is actually installed, so it is the honest answer to "which version is running", whereas a hand-maintained constant in your source can drift from the artifact.
  • What is the difference between [project] dependencies and [build-system] requires?
    `dependencies` are runtime requirements installed into the user's environment alongside your package. `requires` are build-time requirements installed into a temporary environment that exists only while the wheel or sdist is produced, and are never shipped to users. A code generator or version-deriving plugin belongs in `requires`; a library your module imports at run time belongs in `dependencies`. Getting it backwards either bloats every install or produces a package that builds cleanly and fails on first import.

Think of it as the label on a shipping crate: it states what is inside and who may accept it, while a separate document says which machine packed it.

saying these in an interview costs you the question

  • Calls pyproject.toml a setuptools-specific configuration file
  • Thinks [project] describes how the package is built
  • Assumes the distribution name always equals the import name
  • Says metadata is obtained by executing setup.py
  • Lists build-time plugins under [project] dependencies
  • Believes installers read [tool] tables

context

open as a page

What does the [build-system] table in pyproject.toml tell a build frontend?

level: middleimportance: must knowfreq 55%

basics

~10 s

It names who builds the project. requires lists build-time dependencies a frontend installs into a temporary environment; build-backend is the importable object the frontend calls to produce a wheel or a source distribution.

open as a page

How do you migrate a legacy setup.py/setup.cfg project to pyproject.toml?

level: middleimportance: should knowfreq 38%

basics

~20 s

Add a [build-system] table, move setup.cfg metadata and options into [project] (install_requires becomes dependencies, python_requires becomes requires-python), move package discovery into the backend's tool table, and delete setup.py unless you still need imperative build logic.

open as a page

Why can a PEP 517 build fail on an import that works in your active virtualenv?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Because the build runs in a fresh, isolated environment containing only the [build-system] requires list. Your activated environment is not on the build's import path, so anything the backend needs must be declared, not merely installed nearby.

open as a page