Youngkwang Yang한국어
Contents

How Python packages get built

Python

Creating a Python project with uv init --lib produces a pyproject.toml, with a build backend declared inside.

(An example pyproject.toml created by uv)

[build-system]
requires = ["uv_build>=0.11.13,<0.12.0"]
build-backend = "uv_build"

This configuration selects the build backend used to build the library.

  • requires lists the packages needed to load and run the build backend.
  • build-backend gives the build backend path that package installers such as pip and uv will import.

Tools such as uv and pip parse this configuration file, install the packages in requires first, then load the backend defined in build-backend and call its build function.

How packages were installed with setup.py

Before pyproject.toml was introduced, packages were built and installed by running setup.py at the project root. (You can still find this file in older projects.)

# setup.py
from setuptools import setup

setup(name="hello", version="0.1.0", packages=["hello"])
$ python setup.py install

There was no common standard for calling build tools back then, so the convention of running setup.py to install was used as a de facto standard. For example, users installed directly with python setup.py install, or tools such as pip ran setup.py.

The setup.py file still serves as a build configuration file. Running it directly with python setup.py install is deprecated, though, so it prints the warning below. Since Python 3.12, new virtual environments do not include setuptools by default, so you may get an import error if you have not installed it separately.

SetuptoolsDeprecationWarning: setup.py install is deprecated.
Please avoid running ``setup.py`` directly.

Running setup.py install directly was deprecated because this installation method had a structural problem.. To find out what is needed to build/install, setup.py install has to read and execute setup.py. But running it requires the build dependencies to be installed first. That was the circular problem.

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

You might then think parsing setup.py without running it would work, but setup.py isn’t a fixed-format data file. It is simply Python code. Build configuration is passed as arguments to setup(...), and it can include values that depend on execution, such as operating system information or dependency branches based on the Python version.. so it still has to run.

The introduction of pyproject.toml (PEP 518)

With the adoption of PEP 518, the packages needed to start a build could be defined in pyproject.toml instead of setup.py.

[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, when installing a project with no pyproject.toml file or no build-backend, pip applies a default setuptools-based configuration and attempts installation 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 log says pyproject.toml even when the file is absent because pip uses the PEP 517 build process. For example, for an older project with only setup.py, pip internally selects setuptools as the build backend and builds it the same way.

Splitting the frontend and build backend (PEP 517)

PEP 518, discussed above, defines what is needed before a build starts, but does not define how to run it. PEP 517 added build-backend and made it possible to call predefined functions. The library that builds the package is called 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. Examples include setuptools, hatchling, and uv_build.

The frontend creates an isolated environment, installs the packages in requires, imports the backend named in build-backend, and calls the predefined functions. The backend must implement the mandatory functions 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 be called directly without a frontend. Let’s assume a project with just one module called hello and a pyproject.toml file, as below.

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"

After installing the build backend package setuptools at the project root, calling build_wheel directly creates a wheel file as below.

$ 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

A wheel file appeared in dist/, the directory passed as an argument. The example called build_wheel directly, and setuptools used its bdist_wheel command internally, which is why running bdist_wheel appears in the log.

Other build backends differ internally, but they implement the same interface as setuptools to handle the build.

Build backend usage

There is a survey of how widely the build backends mentioned above are used. (The data is from late 2024, so things may differ in 2026.) The survey of the 8000 most-downloaded PyPI projects3 shows setuptools far in first place, followed by Poetry, Hatchling, and Flit.

In this survey, many packages using setuptools do not declare build-backend and rely on backward compatibility. Perhaps many projects have not migrated because there is little reason to change the backend of a working project, which may explain the high count.

Most recent dependency tools default to a build backend they implement. (For example, uv uses uv_build, and Poetry uses poetry-core.) This is probably because managing the tool and build backend together makes it easier to align their configuration and behavior, and improve performance.

An excerpt from the uv documentation

uv supports all build backends (as specified by PEP 517), but also provides a native build backend (uv_build) that integrates tightly with uv to improve performance and user experience.

Perhaps developing and maintaining the backend within uv also makes it easier to implement new standards and adjust it to fit uv’s direction.

Inside a wheel

The wheel produced by the build backend is a compressed file containing code and installation metadata. Since it is an ordinary archive rather than a separate format, 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 wheel containing only pure Python code like the example above, the structure is simple enough to make it by hand without a build backend, as long as you follow the format.

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

Following the rules above, put the code and .dist-info metadata into a zip archive and name it according to the wheel rules to create an installable wheel file.

$ 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 create a wheel file and return its filename, but the internal work of choosing files or compiling extension modules varies with the build backend and the package being built. (For example, C extensions.) For the pure Python package above, the steps are roughly as follows.

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

For example, flit_core’s wheel-building code implements the process of selecting files, creating metadata and RECORD, and archiving them into a wheel. Other build backends such as hatchling differ in how they read configuration and select files, but must ensure that the file they create conforms to the wheel format, as required by PEP 517.

Inside an sdist

An sdist is a distribution containing package source and build configuration. For the example above, its structure is simpler than a wheel’s.

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 example command below archives the structure above. Installing it with pip prints the build and installation process 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

An sdist goes through a conversion step instead of being installed directly like a wheel. The log shows build dependencies being installed in an isolated environment, then a build backend function being called to create a wheel, which is then installed. If a wheel compatible with the installation environment is available, no build is needed, so installing a wheel is usually faster than installing an sdist.

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. The actual analysis covered 7434 sdists downloaded on 2025-01-08. ↩