Every pyproject.toml has a [build-system] table, and the backend it names decides how your source tree becomes a wheel. For most CLIs the choice feels irrelevant — they all produce a wheel with your package and an entry point — until the day a template file is missing from the release, the version needs to come from a git tag, or a migration from Poetry raises the question of what replaces poetry-core. Backends differ in exactly those places: which files they include by default, what they can compute at build time, how much configuration they need, and how they behave when something is wrong. This guide compares the backends you are likely to meet for a pure-Python CLI, shows a working configuration for each, and gives a short decision procedure. It belongs to the packaging topic.
Prerequisites
- A CLI in the
src/layout with a[project]table and[project.scripts]entry point; see writing pyproject.toml metadata for a CLI. - A build frontend:
uv buildorpython -m build. The frontend is the same whatever backend you pick.
Frontend and backend
The split is worth getting straight because it explains why switching backends is cheap. The frontend (uv build, pip, python -m build) creates an isolated environment, installs whatever [build-system] requires lists, and calls a standard API (PEP 517) on the backend to produce an sdist and a wheel. Your [project] metadata is standard too (PEP 621), so every modern backend reads the same name, version, dependencies and scripts. Only the backend-specific [tool.*] table — which files to include, where the version comes from — changes when you switch.
The candidates
uv_build
[build-system]
requires = ["uv_build>=0.8,<0.9"]
build-backend = "uv_build"
uv's own backend, and what uv init --package writes. It is very fast — effectively instant for a pure-Python package — and strict by design: it expects src/<package>/, includes everything inside the package directory (so templates and other data files just work), and has a small set of options under [tool.uv.build-backend]. It does not run plugins, so it cannot compute a version from git tags. Choose it for new projects that keep a literal version in pyproject.toml.
Hatchling
[build-system]
requires = ["hatchling>=1.25", "hatch-vcs>=0.4"]
build-backend = "hatchling.build"
[tool.hatch.version]
source = "vcs"
Hatch's backend is the most popular general-purpose choice: sensible defaults (it detects the src/ layout and includes files inside the package), a rich plugin system, and well-documented include/exclude rules under [tool.hatch.build]. The hatch-vcs plugin derives the version from git tags, as in deriving versions from git tags with hatch-vcs. Choose it when you need build-time computation — dynamic versions, generated files — without setuptools' history.
setuptools
[build-system]
requires = ["setuptools>=75"]
build-backend = "setuptools.build_meta"
[tool.setuptools.package-data]
mytool = ["templates/*.txt", "py.typed"]
The original backend, still the only choice for C extensions built the classic way and for projects with a large setup.py heritage. Modern setuptools reads [project] metadata and discovers src/ packages automatically. The trap for CLIs is data files: without package-data (or MANIFEST.in plus include-package-data, or a VCS plugin such as setuptools-scm), non-Python files inside your package are left out of the wheel. A template directory that works perfectly from a checkout silently disappears from the release.
flit-core
[build-system]
requires = ["flit_core>=3.9,<4"]
build-backend = "flit_core.buildapi"
Minimal and dependency-free, designed for pure-Python packages. It includes the whole package directory and reads the version either from [project] or from a __version__ in the package. No plugins, few options — which is its appeal.
pdm-backend and poetry-core
pdm-backend (from PDM) and poetry-core (from Poetry) are complete backends that projects using those tools inherit. poetry-core 2.x reads standard [project] metadata, so a Poetry-managed CLI is no longer locked into Poetry-specific tables. Both are fine to keep; when migrating away from the tool, switching to Hatchling or uv_build is a small change, as shown in migrating a CLI from Poetry to uv.
Choosing
- Do you compile extensions? Use what the extension tooling expects — setuptools, scikit-build-core or maturin. Most CLIs do not.
- Do you need the version from git tags, or other build-time logic? Hatchling with the relevant plugin.
- Otherwise, for a new project: uv_build if you use uv, Hatchling if you want the broader ecosystem. For an existing project, keep the backend you have unless it is causing problems — switching is easy but not free.
Whatever you choose, pin it with a lower bound and, for fast-moving backends, an upper bound on the next major or minor version. Build behaviour should change when you decide, not when a release appears — the same reasoning as in securing the supply chain of a Python CLI.
Switching backends safely
Because metadata is standard, a switch usually touches only [build-system] and the backend's [tool.*] table. The risk is in what the wheel contains, so compare before and after:
git switch main && uv build --out-dir /tmp/before # old backend
git switch new-backend && uv build --out-dir /tmp/after # branch with the edited [build-system]
diff <(unzip -Z1 /tmp/before/*.whl | sort) <(unzip -Z1 /tmp/after/*.whl | sort)
diff <(unzip -p /tmp/before/*.whl '*/METADATA') <(unzip -p /tmp/after/*.whl '*/METADATA')
The file listing catches dropped data files and accidentally included tests; the metadata diff catches changed dependency markers, a lost Requires-Python or a different README content type. Small differences in metadata ordering are harmless; missing lines are not.
Editable installs
Development installs (uv sync, pip install -e .) also go through the backend, using the editable-install hooks from PEP 660, and backends implement them differently. Hatchling, uv_build and modern setuptools add a path hook or .pth file pointing at your src/ directory, so code edits take effect immediately; new data files and new entry points, however, only appear after re-running the install. If a freshly added subcommand is "not found" in development, re-sync before suspecting the code — and remember that an editable install hides packaging mistakes, which is why the wheel-contents test below builds a real wheel.
UX considerations
The users here are contributors and packagers downstream of you:
- Prefer defaults over configuration. The less backend-specific configuration a project has, the fewer surprises for the next person — and the easier the next switch.
- Keep the sdist complete. Linux distributions and conda-forge build from sdists. Build the wheel from the sdist (
uv builddoes this by default) to prove nothing is missing. - Ship
py.typedif the CLI exposes a library API, and make sure the backend includes it; it is a data file like any other. - Document the build in CONTRIBUTING: "
uv buildproducesdist/" is enough, but say it.
Testing the behaviour
Whatever the backend, assert on the built wheel in CI so packaging regressions fail a build rather than a release:
# tests/test_wheel_contents.py
import subprocess
import zipfile
from pathlib import Path
import pytest
REQUIRED = ["mytool/__init__.py", "mytool/templates/a.txt"]
@pytest.fixture(scope="session")
def wheel_names(tmp_path_factory) -> list[str]:
out = tmp_path_factory.mktemp("wheel")
subprocess.run(["uv", "build", "--wheel", "--out-dir", str(out)],
check=True, capture_output=True)
with zipfile.ZipFile(next(out.glob("*.whl"))) as zf:
return zf.namelist()
@pytest.mark.parametrize("path", REQUIRED)
def test_wheel_contains(wheel_names, path):
assert path in wheel_names
def test_wheel_has_console_script(wheel_names):
assert any(n.endswith(".dist-info/entry_points.txt") for n in wheel_names)
Listing the files that must ship turns "the templates went missing in 1.4.0" into a red test on the pull request that caused it. Bundling data files with importlib.resources covers reading those files at runtime.
Conclusion
For a pure-Python CLI, the backend mostly decides three things: which files land in the wheel, whether the version can be computed at build time, and how much configuration you maintain. uv_build is the fastest, simplest default for uv projects with literal versions; Hatchling covers dynamic versions and custom build logic; setuptools remains for extensions and legacy projects — with explicit package data. Pin the backend, keep configuration minimal, compare wheels when you switch, and test the wheel's contents in CI.
Frequently asked questions
Does the backend affect how fast my CLI starts?
No. The backend runs only at build time; the installed wheel is the same kind of archive whichever backend produced it. Startup speed depends on your imports — see profiling Python CLI startup time.
Do I still need setup.py?
Not for a pure-Python CLI. A pyproject.toml with [project] metadata is enough for every modern backend, including setuptools. Keep setup.py only for build logic that genuinely cannot be expressed declaratively.
Why does my data file appear in development but not after installing?
In development you import from the source tree, where every file exists. The wheel contains only what the backend included. setuptools is the usual culprit; add package-data, then add a wheel-contents test so it cannot regress.
Can I use uv_build without using uv for dependencies?
Yes. It is a standard PEP 517 backend; python -m build or pip wheel install it into the build environment like any other backend.