# What is the uv build backend (uv_build)?


`uv_build` is the [build backend](https://pydevtools.com/handbook/explanation/what-is-a-build-backend.md) written by the [uv](https://pydevtools.com/handbook/reference/uv.md) team. It reads project metadata from [pyproject.toml](https://pydevtools.com/handbook/reference/pyproject.toml.md) and produces [wheels](https://pydevtools.com/handbook/reference/wheel.md) and [source distributions](https://pydevtools.com/handbook/reference/sdist.md), the same job [Hatchling](https://pydevtools.com/handbook/reference/hatch.md) and [setuptools](https://pydevtools.com/handbook/reference/setuptools.md) do.

`uv init` writes it into the {{< term "build-system" >}} table of the packaged projects it creates, unless `--build-backend` names another:

```toml {filename="pyproject.toml"}
[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](https://pypi.org/project/uv-build/) from [PyPI](https://pydevtools.com/handbook/explanation/what-is-pypi.md). 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:

```console
$ 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:

```console
$ 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:

```console
$ 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:

```console
$ 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:

```toml {filename="pyproject.toml"}
[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:

```console
$ 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:

```toml {filename="pyproject.toml"}
[tool.uv.build-backend]
module-name = "widgets"
```

A [flat layout](https://pydevtools.com/handbook/explanation/src-layout-vs-flat-layout.md) with the package at the project root instead of under `src/` sets an empty module root:

```toml {filename="pyproject.toml"}
[tool.uv.build-backend]
module-root = ""
```

A [PEP 420](https://peps.python.org/pep-0420/) 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`:

```toml {filename="pyproject.toml"}
[tool.uv.build-backend]
module-name = "acme.plugins"
```

A project that ships multiple top-level modules from a single distribution passes a list:

```toml {filename="pyproject.toml"}
[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:

```toml {filename="pyproject.toml"}
[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:

```toml {filename="pyproject.toml"}
[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](https://www.maturin.rs/), [scikit-build-core](https://pydevtools.com/handbook/reference/scikit-build-core.md), or [setuptools](https://pydevtools.com/handbook/reference/setuptools.md). The [C extension](https://pydevtools.com/handbook/tutorial/build-a-python-library-with-a-c-extension.md), [C++ extension](https://pydevtools.com/handbook/tutorial/build-a-python-library-with-a-cpp-extension.md), and [Rust extension](https://pydevtools.com/handbook/tutorial/build-a-python-library-with-a-rust-extension.md) 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:

```console
$ 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](https://pydevtools.com/handbook/how-to/how-to-add-dynamic-versioning-to-uv-projects.md) 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 {{< term "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 {{< term "path" "PATH" >}}, 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](https://pydevtools.com/handbook/reference/hatch.md) 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?](https://pydevtools.com/handbook/explanation/why-does-uv-use-hatch-as-a-backend.md) 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](https://peps.python.org/pep-0517/) defines the hook API every backend implements, explained in [What is PEP 517?](https://pydevtools.com/handbook/explanation/what-is-pep-517.md)
- [PEP 420](https://peps.python.org/pep-0420/) defines the implicit namespace packages, without `__init__.py`, that `module-name = "acme.plugins"` builds
- [The uv build backend is now stable](/blog/uv-build-backend/) covers the launch and Astral's build-speed benchmarks
