Skip to content

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 make on Windows). macOS ships the older 3.81, which handles every pattern in this guide.
  • A Python project with a pyproject.toml and 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:

Makefile
.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:

Makefile
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:

Makefile
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:

Makefile
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:

pyproject.toml
[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:

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:

pyproject.toml
[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:

Makefile
.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 --lowest dependency resolution, which tox-uv exposes through uv_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?.

Learn more

Last updated on

Please submit corrections and feedback...