Do you still need tox or nox if you use uv?
For many projects, no. uv can run your test suite against multiple Python versions without any extra tool. But tox and nox do more than version switching, and the gap between “run pytest on 3.12” and “test five Python versions against two dependency sets in parallel” is where they still matter.
Run a Python-version matrix with uv
uv run --python selects a Python version and executes a command in your project’s environment. If that version is not installed, uv downloads it automatically:
uv run --python 3.12 pytestWrap this in a loop to test multiple versions:
for version in 3.11 3.12 3.13 3.14; do
echo "=== Python $version ==="
uv run --python $version pytest
doneA justfile recipe makes this repeatable:
test-all:
#!/usr/bin/env bash
for version in 3.11 3.12 3.13 3.14; do
echo "=== Python $version ==="
uv run --python $version pytest || exit 1
doneIf your project already uses a Makefile, a pattern rule covers the same ground and pairs with UV_PROJECT_ENVIRONMENT to keep a separate virtual environment per Python version:
test-py%:
UV_PROJECT_ENVIRONMENT=.venv-$* uv run --python $* pytest
test-all: test-py3.10 test-py3.11 test-py3.12 test-py3.13make -j4 test-all runs the matrix in parallel. See How to replace tox with uv and a Makefile for the complete walkthrough, including the dependency-variant matrix and the gotchas it exposes.
All three forms (shell loop, justfile, Makefile) cover the most common multi-version testing scenario: same dependencies, same test command, different Python interpreter. For an application or library with a single dependency set and a flat test suite, any of them is enough.
Extend the test matrix with tox or nox
Both tools are test orchestrators. They manage environments, dependencies, and commands as a unit. The features that justify adding one to your stack:
Test different dependency versions
Testing against multiple dependency versions (not just Python versions) is the strongest case for tox or nox. A library that supports SQLAlchemy 1.4 and 2.0 needs to test both. tox expresses this declaratively:
[tox]
env_list = py{312,313}-sqlalchemy{14,20}
[testenv]
deps =
pytest
sqlalchemy14: sqlalchemy>=1.4,<2
sqlalchemy20: sqlalchemy>=2.0,<3
commands = pytest {posargs:tests}nox expresses the same thing in Python:
import nox
@nox.session(python=["3.12", "3.13"])
@nox.parametrize("sqlalchemy", ["1.4", "2.0"])
def tests(session, sqlalchemy):
session.install(f"sqlalchemy~={sqlalchemy}.0", "pytest")
session.run("pytest")A shell loop needs separate environments and dependency pins for each iteration. An orchestrator keeps that setup in one configuration.
Run environments in parallel
tox runs all environments concurrently with tox p. nox supports parallel execution through external tools or its own --sessions flag. Running four Python versions in parallel cuts CI wall-clock time proportionally. A shell loop runs them sequentially; a Makefile with per-version targets can parallelize with make -j, though it lacks tox’s logging-per-environment and result aggregation.
Group commands by environment
Test orchestrators group related commands: install dependencies, run linters, run tests, generate coverage, upload reports. Each group runs in its own isolated environment. Encoding these steps in tox or nox means the CI configuration stays thin (just tox or nox), while the test logic lives in the project and works identically on any developer’s machine.
Reuse environments between runs
tox and nox cache virtual environments between runs and only recreate them when dependencies change. A shell loop with uv run --python rebuilds the project environment on every Python version switch because .venv can only hold one interpreter at a time. Setting UV_PROJECT_ENVIRONMENT=.venv-X.Y per version keeps each one on disk, which closes most of the gap for local development; tox and nox still win when dependency trees are large enough that the install itself is the expensive step.
Choose between tox and nox
The main difference is configuration format:
- tox uses INI-based configuration (
tox.ini). The format is concise for standard matrices but becomes awkward when logic enters the picture (conditional dependencies, platform-specific commands, dynamic environment names). - nox uses Python (
noxfile.py). Any logic you can write in Python, you can use in your test sessions. This flexibility costs a steeper learning curve.
For a project with a static test matrix (a few Python versions, maybe two dependency variants), tox is the simpler choice. For anything that requires conditional logic or dynamic configuration, nox handles it more cleanly.
Use uv inside tox and nox
Neither tox nor nox requires giving up uv’s speed. The tox-uv plugin replaces pip and virtualenv with uv inside tox environments, and nox has a built-in uv backend that does the same. In both cases, uv handles the slow parts (environment creation and dependency installation) while the orchestrator handles the coordination.
Install tox with uv backing:
uv tool install tox --with tox-uvEnable uv in nox:
@nox.session(python=["3.12", "3.13"], venv_backend="uv")
def tests(session):
session.install("pytest")
session.run("pytest")Choose a runner for your test suite
Use uv run --python with a shell loop, justfile, or pattern-rule Makefile when:
- You test against multiple Python versions with a single dependency set
- Your test command is one line (
pytest,pytest --cov) - You do not need dependency variant testing
- You already use one of those runners for the project’s other commands (lint, format, build)
Add tox or nox when:
- You test against combinations of Python versions and dependency versions
- You run multiple distinct commands per test environment (lint, test, coverage, docs)
- You want parallel environment execution
- You maintain a library with a wide support matrix
- You need environment caching for large dependency trees