Youngkwang Yang한국어
Contents

How Python packages get built

Python

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.

run setup.pyinstall build depsrunning it needs the build deps installedwhich deps are needed is only known by running it

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.

frontendpip, uv, build1. create env, install requiresisolated envbackendsetuptools, uv_build2. build_wheel()build_sdist()3..whl.tar.gz

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.

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.whl

Put 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.

build_wheel()select filesread config, decide what to shipwrite metadataMETADATA, WHEELRECORD + ziphash every file, archive

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

Footnotes

  1. pip implements this backward compatibility with a build backend called setuptools.build_meta:__legacy__.

  2. The name wheel comes from a wheel of cheese 🧀. See the background.

  3. PEP 517 build system popularity. A survey of the top 8000 most-downloaded packages as of 2024-12-01.