pyproject.toml Validator
A pyproject.toml can be flawless TOML and still be rejected by every build backend there is, because [project] is not just a table — it is a closed schema with PEP 440 versions, PEP 508 dependency strings and a rule that a field is static or dynamic but never both. This checks the file against those rules and tells you the line number, not just that something is wrong somewhere.
The errors that cost the most time
Build failures from pyproject.toml are unusually expensive because the message you
get rarely names the field. A backend reads the table, finds something it cannot use, and raises
from three layers down. These are the ones worth recognising on sight.
Static and dynamic at once. PEP 621 says a field is written in the file or listed
in dynamic so the backend supplies it. Both is an error, and it is easy to arrive at:
you add dynamic = ["version"] for setuptools-scm and forget to delete the
version line. setuptools' complaint does not say "delete one of these".
A version that is not PEP 440. 2026-08-26 is the common one — a
date-based version has to be written with dots, 2026.8.26, because a dash means
something else entirely. Worth separating from a version that is legal but is not the
normalised spelling: 1.0-alpha1 and v1.0.0.rc1 are both accepted by
PEP 440 and both normalise to something shorter (1.0a1, 1.0.0rc1).
That normalised form is what PyPI shows and what the wheel filename contains, so the validator
reports it as a note rather than an error — but if a colleague pins the spelling you typed, it
will not be the string the index has.
A version written bare. version = 1.0 is a TOML float, and
version = 1.0.0 is not valid TOML at all. It has to be a quoted string. This is the
one case where the TOML parser and the schema disagree about which is the confusing error.
A requirement missing its operator. "requests 2.31" parses as a
package name followed by nothing it recognises. "requests>=2.31" is what you meant.
Likewise a -r requirements.txt line pasted in from pip: that is a pip flag, not a
requirement, and it belongs in a requirements file.
Coming from Poetry
Poetry predates PEP 621, so files written for it put everything under [tool.poetry] in
shapes that no longer match. Poetry 2 reads [project] when it is present and ignores
its own table, which means a half-migrated file has two copies of the metadata and only one of them
is used — usually not the one you edited.
The three shape changes: dependencies becomes an array of strings rather than a table
of constraints; authors becomes an array of { name, email } tables rather
than "Name <mail>" strings; and Poetry's caret and tilde constraints
(^1.2) are not PEP 508 — the nearest equivalents are >=1.2,<2.0
and ~=1.2.
What it deliberately does not do
It does not resolve anything. No request goes anywhere, so it cannot tell you a package name is misspelled, a version does not exist, or two constraints cannot both be satisfied. What it can tell you is that a string is not a requirement, a classifier is not a trove classifier, and a field is not one PEP 621 defines — which is the whole class of failures you can fix without a network.