What is the uv build backend (uv_build)?
uv_build is the build backend written by the uv team. It reads project metadata from pyproject.toml and produces wheels and source distributions, the same job Hatchling and setuptools do.
uv init writes it into the build systemThe [build-system] section in pyproject.toml that declares which tool builds your project into an installable package. It names the build backend and its version constraints.
table of the packaged projects it creates, unless --build-backend names another:
[build-system]
requires = ["uv_build>=0.12.0,<0.13.0"]
build-backend = "uv_build"The pin tracks the uv that generated the file. uv_build follows uv’s versioning policy, so the upper bound keeps a future release from changing the build underneath you.
The uv executable carries a copy of the backend inside it, and uv build reaches for that copy rather than the version the project pins. Every other frontend installs the uv_build package from PyPI. Choosing uv_build costs your users nothing: pip install . resolves the backend from PyPI, builds the wheel, and installs it on a machine with no uv.
That split has one consequence worth knowing: because uv build prefers its own bundled copy, the pin in requires does not decide which backend builds the wheel. Pinning an exact older release makes the substitution visible. With requires = ["uv-build==0.11.33"], which names the same package as uv_build since PyPI treats underscores and hyphens alike:
$ uv build
Building source distribution...
warning: `build_system.requires = ["uv-build==0.11.33"]` does not contain the current uv version 0.12.0
Building wheel from source distribution...
Successfully built dist/pinned-0.1.0.tar.gz
Successfully built dist/pinned-0.1.0-py3-none-any.whl
The build succeeds despite the warning. Every wheel carries a WHEEL file in its .dist-info metadata directory, and the Generator line there records which backend produced it:
$ unzip -p dist/pinned-0.1.0-py3-none-any.whl '*.dist-info/WHEEL' | grep Generator
Generator: uv 0.12.0
The pinned 0.11.33 did not build this wheel. The 0.12.0 backend inside the running uv did, and with the usual >=x,<y range the same substitution happens with no warning at all. uv build --force-pep517 skips the bundled copy, installs the pinned version, and stamps Generator: uv 0.11.33. Reach for it when a build has to be reproducible against a specific backend release rather than against whatever uv is on the machine.
See what uv_build ships
The backend is narrow about what it ships, and the gap against Hatchling is immediate. One project with src/incl/__init__.py, src/incl/table.csv, plus tests/, docs/, README.md, and LICENSE at the root, built twice with nothing changed but the [build-system] table:
Hatchling sweeps the whole project directory into the sdist:
$ tar tzf dist/incl-0.1.0.tar.gz
incl-0.1.0/README.md
incl-0.1.0/docs/index.md
incl-0.1.0/src/incl/__init__.py
incl-0.1.0/src/incl/table.csv
incl-0.1.0/tests/test_a.py
incl-0.1.0/LICENSE
incl-0.1.0/pyproject.toml
incl-0.1.0/PKG-INFO
uv_build ships the metadata and the module root:
$ tar tzf dist/incl-0.1.0.tar.gz
incl-0.1.0/PKG-INFO
incl-0.1.0/pyproject.toml
incl-0.1.0/pyproject.toml.orig
incl-0.1.0/
incl-0.1.0/src
incl-0.1.0/src/incl
incl-0.1.0/src/incl/__init__.py
incl-0.1.0/src/incl/table.csv
pyproject.toml.orig is the project’s file as written; the pyproject.toml beside it is uv_build’s normalized copy. Non-Python files inside the module directory come along without configuration. Everything outside it must be asked for, and [project] metadata is one way of asking: a project declaring readme = "README.md", as uv init writes it, gets the README in its sdist.
A source distribution that used to carry the test suite stops carrying it, which breaks downstream packagers who build and test from the sdist. A LICENSE that Hatchling detected on its own is skipped unless [project] declares it:
[project]
license-files = ["LICENSE"]With that line the wheel gets incl-0.1.0.dist-info/licenses/LICENSE; without it, no license file ships. A license = "MIT" expression on its own does not do it, since that records the license in metadata rather than packaging the file.
Point the backend at your module
uv_build expects one module directory whose name is the project name lowercased with dots and dashes replaced by underscores. A project named acme-widgets must have src/acme_widgets/__init__.py. When it does not, the build fails:
$ uv build
Building source distribution...
error: Failed to build `/tmp/acme-widgets`
Caused by: Expected a Python module at: src/acme_widgets/__init__.py
The fix depends on the layout.
A module whose name differs from the project name is declared directly:
[tool.uv.build-backend]
module-name = "widgets"A flat layout with the package at the project root instead of under src/ sets an empty module root:
[tool.uv.build-backend]
module-root = ""A PEP 420 implicit namespace package, where several distributions share a top-level name, uses a dotted module name and leaves the shared directory without an __init__.py:
[tool.uv.build-backend]
module-name = "acme.plugins"A project that ships multiple top-level modules from a single distribution passes a list:
[tool.uv.build-backend]
module-name = ["foo", "bar"]Control what lands in each artifact
The [tool.uv.build-backend] table holds eight keys:
| Key | Default | Effect |
|---|---|---|
module-name |
project name, normalized | Module directory to package; dotted for namespace packages |
module-root |
"src" |
Directory holding the module; "" for a flat layout |
namespace |
false |
Treats the module root as a PEP 420 namespace directory with no __init__.py |
data |
{} |
Directories copied into the wheel’s .data tree (scripts, headers, data, purelib, platlib) |
default-excludes |
true |
Drops __pycache__, *.pyc, and *.pyo |
source-include |
[] |
Extra globs for the sdist |
source-exclude |
[] |
Globs dropped from both the sdist and the wheel |
wheel-exclude |
[] |
Globs dropped from the wheel only |
There is no wheel-include. Anything the wheel should carry lives inside the module directory or comes in through data. uv ignores unrecognized keys under [tool.uv.build-backend], so a wheel-include key builds without complaint and has no effect.
Shipping tests in the sdist while keeping a generated CSV out of the wheel:
[tool.uv.build-backend]
source-include = ["tests/**"]
wheel-exclude = ["*.csv"]Include patterns are anchored to the project root, so src/** recurses from src/ but pyproject.toml matches only the top-level file. Exclude patterns are not anchored: __pycache__ matches at any depth unless you prefix it with /.
The data table maps a source directory to one of five wheel install locations:
[tool.uv.build-backend]
data = { headers = "include/headers", scripts = "bin" }That produces a wheel with <name>-<version>.data/headers/ and <name>-<version>.data/scripts/ next to the module. Console entry points still belong in [project.scripts]; data.scripts is for executables that are not entry points.
Check the four limits before switching
Four limits decide whether uv_build fits a project. None can be configured away; each one means a different backend or a step before the build.
Extension modules are out of scope. A package with C, C++, Fortran, or Rust sources needs maturin, scikit-build-core, or setuptools. The C extension, C++ extension, and Rust extension tutorials show those builds.
There is no plugin system. Hatchling loads third-party build behavior through plugins; whatever the eight settings do not cover, uv_build does not do.
Dynamic versions are rejected. Declaring dynamic = ["version"] to compute the version from a Git tag fails before any file is packaged:
$ uv build
Building source distribution...
error: Failed to build `/tmp/dynver`
Caused by: Invalid metadata format in: pyproject.toml
Caused by: TOML parse error at line 1, column 1
|
1 | [project]
| ^^^^^^^^^
missing field `version`
The message names a TOML parse error, but the TOML is valid. uv_build reads [project] into a structure with a required version field, and dynamic = ["version"] leaves that field absent, so the complaint surfaces at the top of the table. Look for dynamic, not for a typo.
Tag-driven versioning stays on Hatchling, as How to add dynamic versioning to uv projects covers.
Build scripts do not run. No hook executes code during the build, so generated source files, compiled assets, and downloaded artifacts must exist before uv build starts.
Note
uv’s Rust implementation is often summarized as building a package without installing Python. That describes the backend, not the uv build command, which resolves an interpreterThe program that reads and executes Python code. When you run "python3 hello.py", python3 is the interpreter.
before any backend runs and fails the same way for a Hatchling project. On a developer machine this never shows, because uv downloads a managed CPython when it finds none. Three things have to be true at once for it to bite: no interpreter on PATHA list of directories your terminal searches when you type a command. If a program is not in a PATH directory, the terminal reports "command not found."
, no managed CPython already installed, and downloads disabled. Then the build stops at No interpreter found for Python >=3.9 in virtual environments, managed installations, or search path. Slim and air-gapped CI images are where that combination occurs.
Choose between uv_build and Hatchling
For a pure Python package with a conventional layout and a version string in pyproject.toml, uv_build is the shorter path. uv init has already configured it, and uv build runs the backend compiled into the uv executable rather than installing anything.
Keep Hatchling when the project needs versions derived from Git tags, a build hook that generates code, a README assembled from several files, or force-include mappings that place a file at a different path inside the wheel. Why did uv originally use Hatch as a build backend? explains why so many existing uv projects are on Hatchling.
Switching an existing project means replacing the [build-system] table and translating any [tool.hatch.build] rules into [tool.uv.build-backend] keys. Before switching, save a listing with unzip -l dist/<name>-<version>-py3-none-any.whl > before.txt; then clear dist/, rebuild, and diff the new listing against it to catch a data file the narrower defaults left behind. Consumers see no difference: both backends emit standard wheels, and the Generator line that changes in WHEEL is not something installers read.
Learn More
- PEP 517 defines the hook API every backend implements, explained in What is PEP 517?
- PEP 420 defines the implicit namespace packages, without
__init__.py, thatmodule-name = "acme.plugins"builds - The uv build backend is now stable covers the launch and Astral’s build-speed benchmarks