Introduction to Tox
Tox is a generic virtualenv management and command-line testing tool for Python projects. It allows you to run your test suite (and other commands) across multiple Python interpreter versions in isolated environments, ensuring your package works consistently regardless of the runtime. Originally created by Holger Krekel, Tox has become a staple in the Python packaging and testing ecosystem, used by projects like pytest, requests, and many others.
At its core, Tox automates a tedious workflow: creating virtual environments, installing your project and its dependencies, then running your tests. Instead of manually switching between Python versions or juggling virtual environments, you define a single configuration file and let Tox handle the rest. This makes it invaluable for libraries that need to support Python 3.8, 3.9, 3.10, 3.11, and 3.12 simultaneously.
Why Tox Matters
Python's flexibility in supporting multiple interpreter versions is both a blessing and a curse. While users appreciate the freedom to choose their runtime, maintainers face the burden of verifying compatibility. Tox addresses several pain points:
- Reproducibility: Every test run starts from a clean virtual environment, eliminating "works on my machine" issues caused by stale dependencies.
- Matrix testing: Test against multiple Python versions, dependency versions, or platforms in a single command.
- CI parity: The same
tox.iniconfiguration runs locally and in CI systems like GitHub Actions, GitLab CI, or Jenkins. - Automation: Bundle linting, type checking, documentation builds, and testing into one declarative configuration.
- Contributor onboarding: New contributors can run
toxand immediately execute the full quality gate without reading extensive setup docs.
Installing Tox
Tox is distributed as a standard Python package. The recommended approach is to install it globally or via pipx so it remains available across all your projects. Tox 4.x requires Python 3.7 or later to run, though it can still test projects targeting older interpreters if those interpreters are installed on your system.
# Install via pip
pip install tox
# Or install via pipx for isolation
pipx install tox
# Verify the installation
tox --version
Note that Tox only manages environments; it does not install Python interpreters themselves. You must have the target Python versions already available on your system. Tools like pyenv, asdf, or system package managers can provide these. On Windows, the official Python installer or py launcher works well.
Anatomy of a Tox Configuration
Tox reads its configuration from tox.ini by default, though it can also read from pyproject.toml using the [tool.tox] section. The configuration is organized into sections, each serving a specific purpose. Let's examine the structure of a minimal but realistic configuration.
The Core Sections
The [tox] section contains global settings. The [testenv] section defines the default environment template, which individual environments can override. Named environments like [testenv:lint] provide specialized configurations. Here is a complete example for a hypothetical package called mylib:
[tox]
envlist = py38, py39, py310, py311, py312, lint, typecheck
isolated_build = True
[testenv]
description = Run the test suite under {basepython}
deps =
pytest
pytest-cov
-e .
commands =
pytest {posargs:tests/} --cov=mylib --cov-report=term-missing
[testenv:lint]
description = Run flake8 and isort checks
skip_install = True
deps =
flake8
isort
commands =
flake8 mylib tests
isort --check-only --diff mylib tests
[testenv:typecheck]
description = Run mypy static type checking
deps =
mypy
-e .
commands =
mypy mylib
[testenv:docs]
description = Build the Sphinx documentation
deps =
sphinx
-e .
commands =
sphinx-build -b html docs docs/_build/html
Let's break down the key directives. The envlist setting defines which environments run by default when you invoke tox without arguments. The isolated_build = True flag tells Tox to use PEP 517 builds, which is required for projects with a pyproject.toml build system declaration. The deps list specifies additional packages installed into each environment, while -e . installs your project in editable mode. The commands list contains the actual commands Tox executes, and {posargs} forwards any extra arguments from the command line.
Environment Naming Conventions
Tox uses a naming convention where pyXY maps to Python X.Y. For example, py311 means Python 3.11. You can also use python3.11 or pypy3 for PyPy. Custom environment names like lint or docs are arbitrary labels you choose. The {basepython} substitution automatically resolves to the interpreter for the current environment, which is useful for descriptive output.
Building Your First Tox Project
Let's walk through creating a complete project from scratch. We will build a small utility package called textstats that computes statistics about text input. Start by creating the project structure:
textstats/
├── pyproject.toml
├── tox.ini
├── src/
│ └── textstats/
│ ├── __init__.py
│ └── core.py
└── tests/
├── __init__.py
└── test_core.py
Step 1: Define the Package Metadata
Create pyproject.toml with the build system declaration and project metadata. We use setuptools as the build backend for simplicity:
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "textstats"
version = "0.1.0"
description = "A small library for computing text statistics"
readme = "README.md"
requires-python = ">=3.8"
license = {text = "MIT"}
authors = [{name = "Your Name", email = "you@example.com"}]
dependencies = []
[project.optional-dependencies]
dev = ["pytest", "pytest-cov"]
[tool.setuptools.packages.find]
where = ["src"]
Step 2: Write the Package Code
Create src/textstats/core.py with the actual implementation:
"""Core text statistics functions."""
from __future__ import annotations
from collections import Counter
from typing import Dict, List
def word_count(text: str) -> int:
"""Return the number of words in the text."""
return len(text.split())
def char_count(text: str, include_spaces: bool = True) -> int:
"""Return the number of characters in the text."""
if include_spaces:
return len(text)
return len(text.replace(" ", ""))
def word_frequencies(text: str) -> Dict[str, int]:
"""Return a dictionary mapping each word to its frequency."""
words = text.lower().split()
return dict(Counter(words))
def average_word_length(text: str) -> float:
"""Return the average length of words in the text."""
words = text.split()
if not words:
return 0.0
return sum(len(w) for w in words) / len(words)
def top_words(text: str, n: int = 5) -> List[str]:
"""Return the n most common words, sorted by frequency."""
freq = word_frequencies(text)
sorted_words = sorted(freq.items(), key=lambda x: x[1], reverse=True)
return [word for word, _ in sorted_words[:n]]
And create src/textstats/__init__.py to expose the public API:
"""TextStats: compute statistics about text input."""
from textstats.core import (
average_word_length,
char_count,
top_words,
word_count,
word_frequencies,
)
__all__ = [
"average_word_length",
"char_count",
"top_words",
"word_count",
"word_frequencies",
]
__version__ = "0.1.0"
Step 3: Write the Tests
Create tests/test_core.py with comprehensive test cases:
"""Tests for textstats.core."""
import pytest
from textstats.core import (
average_word_length,
char_count,
top_words,
word_count,
word_frequencies,
)
class TestWordCount:
def test_simple_sentence(self):
assert word_count("hello world") == 2
def test_empty_string(self):
assert word_count("") == 0
def test_multiple_spaces(self):
assert word_count("one two three") == 3
def test_leading_trailing_whitespace(self):
assert word_count(" hello world ") == 2
class TestCharCount:
def test_with_spaces(self):
assert char_count("hello world") == 11
def test_without_spaces(self):
assert char_count("hello world", include_spaces=False) == 10
def test_empty_string(self):
assert char_count("") == 0
class TestWordFrequencies:
def test_basic_frequency(self):
result = word_frequencies("the cat sat on the mat")
assert result == {"the": 2, "cat": 1, "sat": 1, "on": 1, "mat": 1}
def test_case_insensitive(self):
result = word_frequencies("Hello hello HELLO")
assert result == {"hello": 3}
class TestAverageWordLength:
def test_known_average(self):
# "aa bb" => (2 + 2) / 2 = 2.0
assert average_word_length("aa bb") == 2.0
def test_empty_returns_zero(self):
assert average_word_length("") == 0.0
def test_mixed_lengths(self):
# "a bb ccc" => (1 + 2 + 3) / 3 = 2.0
assert average_word_length("a bb ccc") == 2.0
class TestTopWords:
def test_top_three(self):
text = "apple banana apple cherry banana apple"
assert top_words(text, n=3) == ["apple", "banana", "cherry"]
def test_default_n(self):
text = "one two three four five six seven"
result = top_words(text)
assert len(result) == 5
Step 4: Configure Tox
Now create tox.ini in the project root:
[tox]
envlist = py38, py39, py310, py311, py312, lint, typecheck
isolated_build = True
[testenv]
description = Run pytest under {basepython}
deps =
pytest
pytest-cov
commands =
pytest {posargs} --cov=textstats --cov-report=term-missing
[testenv:lint]
description = Run flake8 linting
skip_install = True
deps = flake8 >= 6.0
commands = flake8 src/textstats tests
[testenv:typecheck]
description = Run mypy type checking
deps =
mypy
commands = mypy src/textstats
[flake8]
max-line-length = 100
extend-ignore = E203, W503
Step 5: Run Tox
From the project root, execute Tox. The first run will take longer because Tox must create each virtual environment and install dependencies:
# Run all environments
tox
# Run only specific environments
tox -e py311
tox -e py311,lint
# Pass arguments to the underlying command
tox -e py311 -- tests/test_core.py::TestWordCount
# Recreate environments from scratch
tox -r
# List available environments
tox -l
# Show verbose output
tox -v
When the run completes, Tox prints a summary table showing which environments passed and which failed. A successful run looks like this:
py38: OK (12.45 seconds)
py39: OK (11.82 seconds)
py310: OK (11.30 seconds)
py311: OK (10.95 seconds)
py312: OK (10.72 seconds)
lint: OK (3.21 seconds)
typecheck: OK (4.56 seconds)
congratulations :) (66.01 seconds)
Advanced Configuration Techniques
Factor and Generative Environments
Tox supports generative environment lists using factors, which let you define a matrix of environments compactly. This is powerful when you need to test combinations of Python versions and dependency versions:
[tox]
envlist = py{38,39,310,311,312}-{min,latest}
isolated_build = True
[testenv]
description = Run tests under {basepython} with {factor} dependencies
deps =
min: pytest==7.0
min: pyyaml==5.4
latest: pytest
latest: pyyaml
-e .
commands = pytest {posargs}
This generates ten environments: py38-min, py38-latest, py39-min, and so on through py312-latest. The {factor} substitution expands to the matched factor name, allowing descriptive output and conditional logic.
Conditional Logic with Factor Expressions
You can use factor expressions to apply settings only when certain factors are present. This is useful for platform-specific dependencies or version-specific workarounds:
[testenv]
deps =
pytest
py312: pytest==8.0.0 # Pin for 3.12 only
platform_linux: pyinotify
platform_darwin: pyobjc
commands = pytest
Sharing Configuration Across Environments
The [testenv] section acts as a template. Named environments inherit from it and can override individual keys. To share settings without making them defaults, use a base environment and reference it:
[testenv]
deps = pytest
commands = pytest
[testenv:integration]
deps =
{[testenv]deps}
pytest-asyncio
httpx
commands =
{[testenv]commands}
pytest tests/integration/
Using Pyproject.toml Instead of Tox.ini
Tox 4 supports configuration directly in pyproject.toml, keeping all project configuration in one file. The syntax uses TOML's table structure:
[tool.tox]
envlist = ["py38", "py39", "py310", "py311", "py312", "lint"]
isolated_build = true
[tool.tox.testenv]
description = "Run pytest under {basepython}"
deps = ["pytest", "pytest-cov"]
commands = [["pytest", "{posargs}", "--cov=textstats"]]
[tool.tox.testenv.lint]
description = "Run flake8"
skip_install = true
deps = ["flake8"]
commands = [["flake8", "src/textstats", "tests"]]
Integrating Tox with CI/CD
One of Tox's greatest strengths is that the same configuration runs in CI. Here is a GitHub Actions workflow that runs the full Tox matrix on every push and pull request:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install tox
run: pip install tox
- name: Run tests
run: tox -e py$(echo ${{ matrix.python-version }} | tr -d '.')
Alternatively, you can use the tox-gh plugin, which maps GitHub Actions' Python version to Tox environments automatically, simplifying the workflow:
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install tox tox-gh
- run: tox
With tox-gh, add a [gh] section to your tox.ini:
[gh]
python =
3.8 = py38
3.9 = py39
3.10 = py310
3.11 = py311
3.12 = py312, lint, typecheck
Best Practices
Keep Environments Focused
Each environment should have a single, clear responsibility. Avoid cramming linting, testing, and documentation into one environment. Separate environments run in parallel by default in Tox 4, which speeds up your workflow and makes failures easier to diagnose.
Pin Development Dependencies
While production dependencies should be flexible, development and testing tools benefit from pinning. This ensures reproducible CI runs and prevents a new flake8 release from suddenly breaking your build. Use a constraints.txt file or pin versions directly in the deps list:
[testenv:lint]
deps =
flake8==7.0.0
isort==5.13.2
black==24.1.0
commands =
flake8 src tests
isort --check-only src tests
black --check src tests
Use Parallel Execution
Tox 4 runs environments in parallel by default when you use the -p flag. This dramatically reduces total runtime, especially for large matrices:
# Run all environments in parallel
tox -p
# Limit parallel workers
tox -p 4
# Parallel with auto-detection of CPU cores
tox -p auto
Leverage Environment Caching
Tox caches virtual environments between runs. Avoid using -r (recreate) unless you have changed something fundamental, like the Python version or core dependencies. For dependency changes, Tox detects modifications and reinstalls automatically.
Provide a Default Environment List
Always define a sensible envlist so that running bare tox does something useful. Contributors should be able to clone the repository, run tox, and get meaningful feedback without reading documentation first.
Document Your Environments
Use the description key for every environment. The tox -l and tox -av commands display these descriptions, helping users understand what each environment does:
$ tox -av
default environments:
py38 -> [no description provided]
py39 -> Run pytest under Python 3.9
lint -> Run flake8 and isort checks
typecheck -> Run mypy static type checking
Handle Platform Differences Gracefully
Use the platform key to restrict environments to specific operating systems. This prevents confusing failures when a Windows-only dependency is installed on Linux:
[testenv:windows-only]
platform = win32
deps = pywin32
commands = pytest tests/windows/
Common Pitfalls and Troubleshooting
Missing Python Interpreters
If Tox cannot find a requested interpreter, it fails with a clear error. Ensure all target Python versions are installed and discoverable. On macOS with pyenv, run pyenv versions to verify. On Linux, check which python3.11. Tox uses the standard discovery mechanism, so if python3.11 works in your shell, Tox will find it.
Editable Installs and Path Issues
Using -e . in deps performs an editable install, which links your source directory into the virtual environment. This is convenient for development but can cause issues if your project layout is non-standard. Ensure your pyproject.toml correctly declares package locations, especially when using a src/ layout.
Dependency Conflicts Between Environments
Each Tox environment is fully isolated, so conflicts between environments are impossible. However, conflicts within a single environment can occur. If your project requires requests==2.28 but a test tool needs requests>=2.31, Tox will report the resolution failure. Resolve this by upgrading your project's dependency or pinning the test tool to a compatible version.
Slow First Runs
The initial Tox run creates all virtual environments from scratch, which can take several minutes for large dependency trees. Subsequent runs reuse cached environments and are much faster. If runs remain slow, consider using pip's caching by setting PIP_CACHE_DIR or using a faster installer like uv via the installer setting:
[testenv]
installer = uv
Conclusion
Tox is a powerful, declarative tool that brings discipline and reproducibility to Python testing. By defining your test matrix, linting, type checking, and documentation builds in a single configuration file, you create a contract that works identically on every developer's machine and in every CI pipeline. Starting with a simple tox.ini and gradually adding environments as your project grows is a pragmatic approach that pays dividends immediately. The investment in learning Tox's configuration syntax, factor system, and integration patterns is modest compared to the confidence you gain from knowing your package works across the entire Python version matrix you claim to support. Whether you are maintaining a small utility library or a large framework, Tox provides the infrastructure to test thoroughly, release confidently, and onboard contributors effortlessly.