Nox: Automating Tests, Linting and Builds in Python Projects

Sooner or later every Python project develops a ritual: run the tests, run the linter, format the code, rebuild the docs, build an executable. Each step is its own command with flags, and skipping one is very easy. That's exactly how bugs get into a release that tests would have caught in a second.
Nox solves this: all the steps are described once in a regular Python file and then run with a single command. Below: how Nox works, how to use it to test a project on several Python versions, build documentation and binaries, and how it differs from tox.
What Nox is
Nox is an automation tool for Python projects. The configuration lives in a noxfile.py at the project root, and every task is a session function marked with the @nox.session decorator. For each session Nox creates an isolated virtual environment, installs the dependencies there and runs the commands.
Nox is installed like any command-line tool:
uv tool install nox
# or
pipx install nox
After that, just type nox in the project directory.
A sample project and the first session
Take a small project: in the project directory there's a string_utils.py module with two functions — reversing a string and checking for a palindrome — and in tests there are pytest tests. The tests directory should contain an __init__.py, otherwise importing the project module may fail.
The first session simply runs the tests:
import nox
@nox.session(python=["3.12", "3.13"])
def tests(session: nox.Session) -> None:
session.install("pytest")
session.run("pytest", "-v", "tests")
The nox command creates two environments and runs the tests on Python 3.12 and 3.13. The sessions are named tests-3.12 and tests-3.13.
Several Python versions with one command
Why test on several versions at once? The behavior of the standard library and third-party packages changes between versions: a function that exists in one version may be missing or behave differently in another. If your library promises support for Python 3.10–3.13, checking that by hand is slow and unreliable. With Nox you list the versions in the decorator, and one command shows on which one something broke.
Environments are faster to create with uv. Backends can be chained with |: Nox picks the first available one.
@nox.session(python=["3.12", "3.13"], venv_backend="uv|virtualenv")
def tests(session: nox.Session) -> None:
session.install("pytest")
session.run("pytest", "-v", "tests")
If uv isn't installed, virtualenv is used — the config keeps working on any machine.
Documentation and dependent sessions
Documentation from docstrings is easy to generate with pdoc. A session is just a command call:
@nox.session
def docs(session: nox.Session) -> None:
session.install("pdoc")
session.run("pdoc", "project", "-o", "docs")
After it runs, a docs directory appears with an index.html: modules, function signatures, docstring text and the source code behind a button.
Sessions can depend on each other. The requires parameter guarantees that one session runs only after another:
@nox.session(requires=["docs"])
def open_docs(session: nox.Session) -> None:
session.log("Documentation: docs/index.html")
You run a specific session with the -s flag: nox -s docs. And so that not everything runs by default, list the default sessions at the top of the file:
nox.options.sessions = ["tests"]
Now plain nox runs only the tests, and you build the docs explicitly when you need them.
Tags: groups of sessions
Sessions are convenient to group with tags. For example, style checks and auto-fixing:
@nox.session(tags=["style", "fix"])
def ruff_format(session: nox.Session) -> None:
session.install("ruff")
session.run("ruff", "format", "project", "tests")
@nox.session(tags=["style"])
def lint(session: nox.Session) -> None:
session.install("ruff")
session.run("ruff", "check", "project", "tests", "noxfile.py")
nox -t fix runs only the fixing, nox -t style runs all style checks. The linter, by the way, immediately catches small things like an unused sys import in the noxfile.py itself.
Building executables
Nox isn't just for checks — it's good for builds too. Here's a session that builds the app with PyInstaller into a single file without a console and removes the temporary build directory:
import secrets
import shutil
from pathlib import Path
@nox.session(python=["3.12", "3.13"])
def build(session: nox.Session) -> None:
session.install("pyinstaller")
name = f"app-{session.python}-{secrets.token_hex(4)}"
session.run(
"pyinstaller", "--onefile", "--noconsole",
"--name", name, "project/string_utils.py",
)
if Path("build").exists():
shutil.rmtree("build")
Building on two Python versions at once immediately shows where the project doesn't build. My first attempt failed because of a forgotten import — and that's good: the error surfaced at build time, not on a user's machine. In the end a single nox command tests the code, checks the style, builds the documentation and produces ready files in dist.
A dependency matrix and requirements.txt
Sometimes you need to test the code not on different Python versions but on different versions of a library. That's what nox.parametrize is for:
@nox.session
@nox.parametrize("django", ["4.2", "5.1"])
def tests_django(session: nox.Session, django: str) -> None:
session.install(f"django=={django}", "pytest")
session.run("pytest", "tests")
If dependencies are listed in a file, you install them in one line: session.install("-r", "requirements.txt"). nox -l shows the list of all sessions, including parametrized ones.
Nox or tox
Many people use tox, and it works great. The main difference is the configuration format. tox is configured declaratively in tox.ini or pyproject.toml, while Nox uses plain Python code. Inside a session you can write any logic: conditions, loops, file handling, name generation, cleaning directories, sending a report to Telegram.
| Nox | tox | |
|---|---|---|
| Configuration | Python code (noxfile.py) |
INI or TOML |
| Arbitrary logic | Yes, it's plain Python | Limited |
| Several Python versions | Yes | Yes |
| Learning curve | You need to know Python | You need to know the config format |
Nox is used by large projects — for example pip and urllib3, as well as the Hydra framework. If you already write Python, a config in Python is easier to read and maintain.
Conclusion
Nox turns a set of scattered commands into one reproducible scenario: tests on several Python versions, linting, formatting, documentation and builds run with one command and behave the same for you, your colleagues and CI. Start with a single session for tests and gradually move the other manual steps into it.
Good automation is especially valuable when a project grows and gets classes, modules and layers. How to design such code so it's easy to test is covered in detail in my Python OOP course. And how to avoid memory blowups in collections is explained in why a Python list can eat all your memory.