# 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](https://pydevtools.com/handbook/reference/tox.md) 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](https://pydevtools.com/handbook/reference/uv.md) 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](https://pydevtools.com/handbook/reference/tox-uv.md) or [nox](https://pydevtools.com/handbook/reference/nox.md) takes over.

## Prerequisites

- [uv](https://pydevtools.com/handbook/reference/uv.md) 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](https://pydevtools.com/handbook/reference/pytest.md) in a dev [dependency group](https://pydevtools.com/handbook/explanation/understanding-dependency-groups-in-uv.md)

> [!NOTE]
> If you aren't already committed to Make, a [justfile](https://just.systems) 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`](https://pydevtools.com/handbook/reference/uv.md) call:

```makefile {filename="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](https://pydevtools.com/handbook/reference/ruff.md), [ty](https://pydevtools.com/handbook/reference/ty.md), 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 {filename="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 {filename="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 {filename="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:

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

```toml {filename="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:

```console
× 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 {filename="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:

```toml {filename="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 {filename="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](https://pydevtools.com/handbook/reference/tox.md) with [tox-uv](https://pydevtools.com/handbook/reference/tox-uv.md) or [nox](https://pydevtools.com/handbook/reference/nox.md) 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?](https://pydevtools.com/handbook/explanation/do-you-still-need-tox-or-nox-if-you-use-uv.md).

## Learn more

- [How to test against multiple Python versions using uv](https://pydevtools.com/handbook/how-to/how-to-test-against-multiple-python-versions-using-uv.md)
- [How to use uv to speed up tox](https://pydevtools.com/handbook/how-to/how-to-use-uv-to-speed-up-tox.md)
- [Understanding dependency groups in uv](https://pydevtools.com/handbook/explanation/understanding-dependency-groups-in-uv.md)
- [uv's project-environment configuration](https://docs.astral.sh/uv/concepts/projects/config/#project-environment-path)
- [uv's conflicting-extras docs](https://docs.astral.sh/uv/concepts/projects/config/#conflicting-dependencies)
- [GNU Make manual](https://www.gnu.org/software/make/manual/)
