How to replace tox with uv and a Makefile
A Makefile is already the control surface for many Python projects. make test, make lint, make format are muscle memory. Adopting tox on top means learning a second config format with its own INI DSL, its own matrix syntax, and its own per-environment caching story, just to loop pytest across Python versions.
uv makes that loop possible inside Make itself. Four pattern rules cover what most teams use tox for. The one piece that turns “works but slow” into a genuine tox replacement is uv’s UV_PROJECT_ENVIRONMENT variable, which lets each Python version keep its own virtual environment side by side instead of rebuilding .venv on every switch. This guide builds the Makefile from scratch, then shows where it stops being the right tool and tox-uv or nox takes over.
Prerequisites
- uv installed
- GNU Make (preinstalled on Linux and macOS; WSL or
choco install makeon Windows). macOS ships the older 3.81, which handles every pattern in this guide. - A Python project with a
pyproject.tomland pytest in a dev dependency group
Note
If you aren’t already committed to Make, a justfile is the simpler starting point and avoids Make’s inherited quirks. This guide covers Make because many Python projects already have one.
Set up the baseline targets
Start with the commands everyone runs daily. Each wraps a uv run call:
.PHONY: sync test lint typecheck format clean
sync:
uv sync
test:
uv run pytest
lint:
uv run ruff check
typecheck:
uv run ty check
format:
uv run ruff format
clean:
rm -rf .venv .venv-* dist *.egg-info .pytest_cache
This assumes Ruff, ty, and pytest are in the project’s dev group so uv run picks them up without manual installs.
Test against a specific Python version
Add a pattern rule that takes the Python version as the target suffix:
test-py%:
uv run --python $* pytest
make test-py3.12 now runs pytest against Python 3.12, downloading it if the interpreter isn’t already installed. The $* Make automatic variable expands to whatever matched %.
Keep per-version virtual environments side by side
Running uv run --python 3.12 pytest then uv run --python 3.13 pytest thrashes the project’s .venv directory. Each invocation removes and recreates it because .venv can only hold one Python version at a time. Four versions in a row means four full rebuilds.
UV_PROJECT_ENVIRONMENT fixes this. Point each version at its own directory:
test-py%:
UV_PROJECT_ENVIRONMENT=.venv-$* uv run --python $* pytest
make test-py3.10 creates .venv-3.10; make test-py3.11 creates .venv-3.11; both persist between runs. Rerunning the same version reuses its venv and skips the install. Add .venv-* to .gitignore so the per-version venvs don’t clutter git status.
Run the full matrix with test-all
Enumerate the supported Python versions and have test-all depend on each:
PYTHON_VERSIONS := 3.10 3.11 3.12 3.13
TEST_PY_TARGETS := $(addprefix test-py,$(PYTHON_VERSIONS))
.PHONY: test-all
test-all: $(TEST_PY_TARGETS)
test-py%:
UV_PROJECT_ENVIRONMENT=.venv-$* uv run --python $* pytest
Warning
Don’t list $(TEST_PY_TARGETS) in .PHONY. Declaring pattern-matched targets as phony prevents Make from firing the pattern rule, and make test-py3.12 silently reports “Nothing to be done.” Leave them off because no filesystem entry should match test-py3.12.
Run the matrix in parallel with make -j
Because each per-version venv is independent, they sync and test in parallel without contention:
$ make -j4 test-all
On a fresh Debian container with Python downloads already cached, the sample project runs:
| Command | Time |
|---|---|
make test-all (serial, cold) |
3.73s |
make test-all (serial, warm) |
1.51s |
make -j4 test-all (parallel, cold) |
1.16s |
make -j4 test-all (parallel, warm) |
0.63s |
For an apples-to-apples comparison against tox -p on three Python versions and the same test suite:
| Command | Time |
|---|---|
uvx --with tox-uv tox -p (cold) |
6.48s |
uvx --with tox-uv tox -p (warm) |
2.80s |
make -j3 test-all (cold) |
1.95s |
make -j3 test-all (warm) |
0.95s |
uv plus Make wins because it skips sdist packaging. tox builds a source distribution per environment and installs from it; uv run --python syncs the project directly from the checkout into each per-version venv (as a normal install, not an editable one).
Split the matrix by dependency version
Multi-version Python is one dimension. Libraries often also test against multiple versions of a dependency (pydantic 1 versus 2, SQLAlchemy 1.4 versus 2.0). Declare the variants as extras:
[project.optional-dependencies]
pd1 = ["pydantic<2"]
pd2 = ["pydantic>=2,<3"]
[tool.uv]
conflicts = [[{ extra = "pd1" }, { extra = "pd2" }]]The [tool.uv] conflicts declaration is required. Without it, uv’s universal resolver tries to lock both extras at once and refuses:
× No solution found when resolving dependencies:
Because matrix[pd2] depends on pydantic>=2,<3 and matrix[pd1] depends
on pydantic<2, matrix[pd1] and matrix[pd2] are incompatible.
Then add a target for each variant in the Makefile:
PYTHON_VERSIONS := 3.11 3.12 3.13
PYDANTIC_VARIANTS := pd1 pd2
MATRIX := $(foreach py,$(PYTHON_VERSIONS),$(foreach pd,$(PYDANTIC_VARIANTS),test-py$(py)-$(pd)))
.PHONY: matrix
matrix: $(MATRIX)
test-py%-pd1:
UV_PROJECT_ENVIRONMENT=.venv-py$*-pd1 uv run --python $* --extra pd1 pytest
test-py%-pd2:
UV_PROJECT_ENVIRONMENT=.venv-py$*-pd2 uv run --python $* --extra pd2 pytest
make -j6 matrix runs all six cells (three Python versions × two pydantic variants) in parallel. Two variants per axis stays readable, but three or more pattern rules per axis is where tox.ini’s declarative py{311,312,313}-pydantic{1,2,3} syntax becomes easier to maintain.
Exclude per-version venvs from sdists
If the project builds a distribution or is tested with tox on the same checkout, the .venv-*/ directories get swept into the source archive by default and break extraction with “symlink path is absolute, but external symlinks are not allowed.” Tell the build backend to skip them:
[tool.hatch.build.targets.sdist]
exclude = [".venv-*", ".tox"]Teach Make to list the targets
Make doesn’t self-document. A help target that lists the common commands makes the project approachable for contributors:
.PHONY: help
help:
@echo "Targets:"
@echo " make sync Install dependencies into .venv"
@echo " make test Run tests on the default Python"
@echo " make test-py3.12 Run tests on a specific Python version"
@echo " make test-all Run tests on every supported version"
@echo " make -j4 test-all Run the matrix in parallel"
@echo " make lint Run ruff check"
@echo " make typecheck Run ty check"
@echo " make format Run ruff format"
@echo " make clean Remove build artifacts and per-version venvs"
Switch to tox or nox when Make strains
A Makefile replacement stays honest as long as the matrix is small and every target is a single command. Reach for tox with tox-uv or nox when:
- The matrix has more than two axes or more than two variants per axis
- You need named test environments that run distinct command sequences (docs, coverage, benchmarks, integration)
- You want
--lowestdependency resolution, which tox-uv exposes throughuv_resolution - CI operators need to skim the matrix from a single declarative file
For the full tradeoff, see Do you still need tox or nox if you use uv?.