skip to content

What does the `[project]` table in `pyproject.toml` (PEP 621) standardize?

level: middleimportance: must knowfreq 52%

answer

  1. Metadata used to be executable code
  2. One static table, many tools
  3. Name, version, requires-python, dependencies
  4. Ranges not pins, because consumers co-install
  5. Tool-specific settings live elsewhere in the file

basics

~20 s

PEP 621 defines a tool-agnostic project table in pyproject.toml holding a project's core metadata: name, version, description, requires-python, abstract dependencies, extras and entry points. Every build backend and project manager reads that same table instead of its own bespoke format.

solid answer

~40 s

Before PEP 621, a project's metadata lived in an imperative setuptools script or in each tool's private config, so switching build backends or project managers meant rewriting the declaration. PEP 621 puts it in one static, declarative `[project]` table in `pyproject.toml`: `name`, `version`, `description`, `readme`, `requires-python`, `license`, `authors`, `dependencies`, an optional-dependencies table for extras, entry points and console scripts. Any backend can build from it and any project manager can read it, which is exactly what makes the all-in-one tools interchangeable at the *declaration* layer. Two details matter in interviews: `dependencies` are **abstract** — version ranges, not pins, because a published wheel must resolve against other people's constraints — and anything a specific tool needs beyond the standard goes under its own `[tool]` table, which nothing else has to understand.

code

python · 17 lines
python
import tomllib

SOURCE = """
[project]
name = "triage-bot"
version = "0.3.0"
description = "Routes incoming tickets to a queue"
requires-python = ">=3.11"
dependencies = ["ticket-client>=1.4", "structured-logger>=0.9"]
"""

data = tomllib.loads(SOURCE)
project = data["project"]
print(project["name"], project["version"])
print(project["requires-python"])
for dep in project["dependencies"]:
    print(dep)

go deeper

for a junior

Know that pyproject.toml is the modern home of project metadata and that the [project] table holds name, version, requires-python and dependencies. Be able to point at it rather than at a legacy setup script.

for a middle

Explain why it is static and declarative, what dynamic is for, and the abstract-versus-pinned split between dependencies and a lock file. Know that tool-specific settings live under a separate [tool] table.

for a senior

Show how source metadata becomes installed metadata readable through importlib.metadata, and use that to explain why the installed distribution name and the importable package name are different things that routinely differ in practice.

for a principal

Frame [project] as the interoperability layer that limits the blast radius of choosing any one project manager: the declaration is portable, the workflow around it is not. That is the argument for standardizing on the file rather than on a vendor.

## The problem PEP 621 solved For most of Python's history, project metadata was code. A setuptools project declared its name, version and dependencies by calling a function inside an imperative script, which meant that reading a project's metadata required *executing arbitrary Python* — and that the metadata could differ depending on the machine, the environment or the phase of the moon. Later a declarative config file moved most of it out of code, but it was still setuptools' own format. Meanwhile every other tool that wanted to know your dependencies invented its own file. Two PEPs fixed this in sequence. PEP 518 introduced `pyproject.toml` with a `[build-system]` table, so a project could name the build backend it needs and stop assuming setuptools. PEP 621 then standardized the metadata itself, in a `[project]` table in that same file. ## What is in the table The standard fields cover everything the packaging ecosystem needs to describe a distribution: - **Identity** — `name` (normalized by the packaging rules, so case and separators do not matter) and `version`. - **Description** — `description` (one line), `readme` (a file reference or inline text), `keywords`, `classifiers`, `urls`. - **People and terms** — `authors`, `maintainers`, `license` and license files. - **Compatibility** — `requires-python`, a version specifier such as `">=3.11"`. Installers use it to refuse or to pick an older release rather than install something that cannot run. - **Dependencies** — `dependencies`, a list of requirement strings, plus an optional-dependencies table whose keys are **extras**: named optional feature sets a consumer opts into by requesting the distribution with that extra. - **Entry points** — console scripts, GUI scripts and plugin groups, which become the executable shims an installer drops into the environment's `bin` directory. There is also `dynamic`, a list naming fields the build backend will compute at build time rather than read statically — the escape hatch for a project whose version comes from source control or from a module attribute. Anything not in `dynamic` must be static, and that is the point: a tool can read `pyproject.toml` and learn the metadata without running any of the project's code. With `tomllib` in the standard library since Python 3.11, reading that table is a three-line script — which is precisely why so many tools can agree on it. ## The abstract-versus-concrete distinction The single most common interview trip-up here is putting exact pins in `dependencies`. That list is **abstract**: it describes what your project can work with, as ranges and markers, because whoever installs your distribution must be able to satisfy your requirements *and* everyone else's simultaneously. A library that pins an exact version of a shared dependency makes itself un-co-installable. The exact resolved set — this version of that transitive dependency, with this hash — belongs in a lock file, which an application commits and a library generally does not publish. The declaration says what is acceptable; the lock says what was chosen. All-in-one managers keep both, and edit both when you add a dependency. ## From source metadata to installed metadata The `[project]` table is *source* metadata. When a backend builds a wheel, it translates those fields into the standardized core metadata file inside the distribution, and after installation that file is what `importlib.metadata` reads at runtime. So the table you write and the metadata a running process can introspect are the same information in two encodings — which is why the name you install and the name you import are separate concepts, and why a distribution can legitimately install several importable packages, or none with a matching name. ## What it does not standardize PEP 621 covers metadata, not behaviour. It says nothing about how a build backend compiles extension modules, how a project manager resolves, what a lock file looks like, or how tests are run. Each tool keeps its own configuration under a `[tool]` table namespaced by the tool's name, and other tools ignore it. That split is deliberate and is exactly what makes the declaration portable while the workflow is not: your `[project]` table survives a change of project manager unchanged, and everything under `[tool]` does not. One more consequence worth naming: because `[project]` is standard, a repository can be read by tooling that has never heard of the manager you chose — a security scanner, a dependency bot, an internal audit script. The declaration is the interoperability layer, and it is the reason a bet on any one all-in-one manager is smaller than it looks.

  • What is the `dynamic` field for, and why does using it cost you something?
    `dynamic` lists fields the build backend will compute at build time — most often `version`, derived from a source-control tag or a module attribute. The cost is that those fields can no longer be read statically: any tool that wants them must build the project or run backend code, which is slower and less safe than parsing the file. Keep the list as short as possible.
  • Why should a library's `dependencies` list use ranges rather than exact pins?
    Because a library is co-installed with other people's code. If two libraries each pin a different exact version of a shared dependency, the resolver has no solution and the install fails. Ranges state what your code can work with and let the consuming application's resolver pick one version that satisfies everybody. Exact versions belong in an application's lock file.
  • Does the `[project]` table decide which build backend builds the wheel?
    No — that is the separate `[build-system]` table from PEP 518, which names the backend and the requirements needed to run it. `[project]` is backend-neutral metadata; `[build-system]` is the plug. That separation is why you can change backends without rewriting your metadata.

saying these in an interview costs you the question

  • Pins exact versions in a library's dependencies list
  • Thinks pyproject.toml is one specific tool's config file
  • Cannot distinguish the project table from a tool table
  • Says metadata must be computed by running project code
  • Confuses the installed distribution name with the importable package name
  • Believes the project table also configures the build backend

context