Start a new Python project with uv (uv init --lib) and you get a
pyproject.toml, with a build backend declared like this.
[build-system]
requires = ["uv_build>=0.11.13,<0.12.0"]
build-backend = "uv_build"
This configuration selects the build backend for the package. requires
lists the packages needed to load that backend, while build-backend gives
the path that pip or uv will import. These are the values uv writes. The
values depend on which tool initialized the project.
A tool such as uv or pip parses the configuration, installs the packages in
requires, loads the backend from build-backend, and calls its build
function.
Today this declaration is read before a build starts, but build information used to live inside setup.py.
How packages were installed with setup.py
Before pyproject.toml was introduced, packages were built and installed by running the setup.py at the project root.
# setup.py
from setuptools import setup
setup(name="hello", version="0.1.0", packages=["hello"])
$ python setup.py install
There was no standard for how to build back then, so the convention of running
setup.py to install was used as a de facto standard. You either installed directly with
python setup.py install, or pip downloaded the sdist (source distribution),
unpacked it, and ran the setup.py inside.
The setup.py file itself is still valid as build configuration. What is
deprecated is running it directly as a command, as in
python setup.py install. That command prints the warning below. Since Python
3.12, new virtual environments do not include setuptools by default either,
so it will fail to import unless you install it separately.
SetuptoolsDeprecationWarning: setup.py install is deprecated.
Please avoid running ``setup.py`` directly.
Direct setup.py commands had a structural problem. The information needed to build was inside setup.py, so reading it required running setup.py, but running setup.py required the build dependencies to be installed first.
You might think parsing setup.py without running it would work, but setup.py
isn’t a fixed-format data file. It is Python code. Build configuration is
passed as arguments to setup(...), and code can calculate dependencies based
on the operating system or Python version. Getting the final values still
requires running it.
The introduction of pyproject.toml (PEP 518)
PEP 518 introduced the [build-system]
section in pyproject.toml for the packages needed to start a build. pip reads
this configuration before setup.py, which lets it install the build
dependencies first.
[build-system]
requires = ["setuptools"]
requires is mandatory in [build-system] and lists the packages needed to
start the build. build-backend did not exist at this point. It arrived in the
next standard, PEP 517.
Even today, pip applies a default setuptools-based configuration when a
project has no pyproject.toml or no build-backend, for backward
compatibility.1 Installing a setup.py-only project produces a log
like this.
$ ls legacy-project/
setup.py hello/
$ pip install ./legacy-project
Processing ./legacy-project
Preparing metadata (pyproject.toml): started
Preparing metadata (pyproject.toml): finished with status 'done'
...
Successfully installed hello-0.1.0
The project has no pyproject.toml, yet the log still says (pyproject.toml).
pip applies its default build-system configuration and then uses the PEP 517
build process. This backward compatibility keeps older setup.py-based packages
installable.
Splitting the frontend and build backend (PEP 517)
PEP 518 recorded what was needed before a build could start, but it did not
say how to run the build. PEP 517 added
build-backend and defined builds in terms of Python functions with standard
names. The library that builds the package is the backend, and the tool that
calls it is the frontend.
- A frontend (build frontend) is the tool you run. pip, uv, and build are frontends.
- A build backend is a library that turns the package into an installable file called a wheel2. setuptools, hatchling, and uv_build are build backends.
The frontend creates an isolated environment, installs the packages in
requires, imports the backend named in build-backend, and calls its
standard hooks. The two mandatory hooks are build_wheel and build_sdist.
Because the build backend interface consists of Python functions, it can also
be called directly without a frontend. Assume a project with a hello module
and a pyproject.toml arranged like this.
hello-project/
├─ hello/
│ └─ __init__.py
└─ pyproject.toml
# hello/__init__.py
def greet():
return "hello"
# pyproject.toml
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
# package name and version metadata
[project]
name = "hello"
version = "0.1.0"
Install the setuptools build backend at the project root and call build_wheel
directly. It creates a wheel file in dist/.
$ pip install setuptools
$ python -c "from setuptools import build_meta; print(build_meta.build_wheel('dist'))"
running bdist_wheel
running build
...
adding 'hello-0.1.0.dist-info/RECORD'
hello-0.1.0-py3-none-any.whl
$ ls dist/
hello-0.1.0-py3-none-any.whl
Here build_wheel was called directly. setuptools uses its bdist_wheel
command internally, which is why the log contains running bdist_wheel.
Other build backends use the same hook names and return types.
Build backend usage
Which backends are actually common? A survey based on the 8000 most-downloaded PyPI projects as of late 2024 found setuptools far in first place, followed by Poetry, Hatchling, and Flit.3
Many packages using setuptools do not declare build-backend and rely on the
backward-compatible behavior instead. If a project already works, there may be
little reason to change its backend. That is probably one reason many projects
have not migrated and the setuptools count remains high.
Most recent dependency management tools default to a build backend they
implement. uv uses uv_build, while Poetry uses poetry-core. Managing the
tool and the build backend together makes it easier to keep configuration and
behavior consistent, and it seems to help performance too. The
uv documentation likewise
describes uv_build as tightly integrated with uv for better performance and
usability.
Inside a wheel
The wheel just produced by the build backend is a compressed file containing
package code and installation metadata (basically a zip..). You can inspect it
with unzip.
$ unzip -l hello-0.1.0-py3-none-any.whl
Length Name
--------- ----
32 hello/__init__.py
49 hello-0.1.0.dist-info/METADATA
81 hello-0.1.0.dist-info/WHEEL
270 hello-0.1.0.dist-info/RECORD
For a pure Python package like this one, the structure is simple enough to build by hand without a backend. These are the files inside it.
def greet():
return "hello"Metadata-Version: 2.2 Name: hello Version: 0.1.0
Wheel-Version: 1.0 Generator: bare hands Root-Is-Purelib: true Tag: py3-none-any
hello/__init__.py,sha256=7XnBcgkoKNpsJ2GAPI8omwzaFh7YzoS_pBdznqoLLZY,32 hello-0.1.0.dist-info/METADATA,sha256=hli8NwWkjaxLsQL358wKUBsozf7n5wGWMtfWflygw44,49 hello-0.1.0.dist-info/WHEEL,sha256=N9mGGlGSkyWvaHSuPaz4uSAMpBDBw1gIFi3Bh2VlAJQ,81 hello-0.1.0.dist-info/RECORD,,
The filename follows a rule too.
hellonamethe package name-0.1.0versionthe package version-py3pythonruns on Python 3-noneabino compiled extension, so not tied to a specific Python build-anyplatforminstalls on any OS or CPU.whlPut the hello package and its .dist-info metadata into a ZIP archive, name
the file according to the wheel rule, and it becomes a wheel.
$ zip -r hello-0.1.0-py3-none-any.whl hello hello-0.1.0.dist-info
$ pip install --no-index hello-0.1.0-py3-none-any.whl
Processing ./hello-0.1.0-py3-none-any.whl
Installing collected packages: hello
Successfully installed hello-0.1.0
$ python -c "import hello; print(hello.greet())"
hello
build_wheel must produce and return this wheel file. Its internal work varies
with the backend and the package. It may choose files, generate code, or compile
extension modules. For the pure Python example above, the flow is roughly this.
flit_core’s
wheel-building code
selects files, writes metadata and RECORD, and archives the result. Other
build backends such as hatchling differ in how they read configuration and
choose files, but they return the same wheel format to a PEP 517 frontend.
Inside an sdist
An sdist is a source distribution containing the package source and build configuration. Its basic layout looks simpler than a wheel.
hello-0.1.0.tar.gz
└─ hello-0.1.0/
├─ pyproject.toml
├─ PKG-INFO
└─ hello/
└─ __init__.py
PKG-INFO is written in the same format as the wheel’s METADATA.
Metadata-Version: 2.2
Name: hello
Version: 0.1.0
The command below makes a minimal tar archive that pip can process. Installing it makes the build steps visible in the log.
$ tar czf hello-0.1.0.tar.gz hello-0.1.0
$ pip install --no-cache-dir hello-0.1.0.tar.gz
Processing ./hello-0.1.0.tar.gz
Installing build dependencies: started
Getting requirements to build wheel: started
Preparing metadata (pyproject.toml): started
Building wheels for collected packages: hello
Building wheel for hello (pyproject.toml): started
Created wheel for hello: filename=hello-0.1.0-py3-none-any.whl ...
Installing collected packages: hello
Successfully installed hello-0.1.0
Unlike a wheel, an sdist is not installed directly. The log shows build dependencies entering an isolated environment (PEP 518), a backend hook creating a wheel (PEP 517), and that wheel being installed. A compatible wheel does not need this build step, so it is generally faster to install than an sdist.
An isolated environment keeps build-only packages out of the user’s environment. It also reduces the chance that packages already installed there will affect the build result.
References
- PEP 517: A build-system independent format for source trees
- PEP 518: Specifying Minimum Build System Requirements
- wheel format spec (PyPA specs)
- sdist format spec (PyPA specs)
Footnotes
-
pip implements this backward compatibility with a build backend called
setuptools.build_meta:__legacy__. ↩ -
The name wheel comes from a wheel of cheese 🧀. See the background. ↩
-
PEP 517 build system popularity. A survey of the top 8000 most-downloaded packages as of 2024-12-01. ↩