Intermediate
You push your code, CI turns red, and the error reads: “ModuleNotFoundError: No module named ‘pytest’.” But you ran the tests locally and everything passed. The difference? Your laptop has months of accumulated packages that silently filled in the gaps. The CI machine doesn’t. This is the “works on my machine” problem, and it bites Python projects of every size. Running tests in a dirty local environment is like spell-checking a document while autocorrect is on — you’ll miss things that would fail for everyone else.
nox solves this by creating a fresh, isolated virtual environment every time you run your tests. It reads a plain Python file called noxfile.py to know what to install and what to run. There’s no TOML config syntax to memorize, no INI format quirks — just Python functions decorated with @nox.session. If you already know how to write Python, you already know how to write nox sessions. Installation requires nothing more than pip install nox.
In this article we’ll cover everything you need to get productive with nox: writing your first noxfile, running tests with pytest in an isolated environment, testing across multiple Python versions, adding linting sessions, and using parameterized sessions to reduce duplication. By the end you’ll have a complete noxfile that mirrors what real-world Python projects use in CI/CD pipelines.
nox Quick Example: Running Tests in Isolation
Before diving into every feature, here is the minimum nox setup that gets your pytest tests running in a clean virtual environment every time:
# noxfile.py
import nox
@nox.session
def tests(session):
session.install("pytest")
session.install(".") # install your own package
session.run("pytest", "tests/")
With this file saved at the root of your project, run the session from your terminal:
nox -s tests
nox > Running session tests
nox > Creating virtual environment (virtualenv) using python3.12 in .nox/tests
nox > python -m pip install pytest
nox > python -m pip install .
nox > pytest tests/
========================= test session starts ==========================
collected 4 items
tests/test_math.py .... [100%]
========================= 4 passed in 0.32s ============================
nox > Session tests was successful.
nox created a fresh virtual environment in .nox/tests/, installed your dependencies from scratch, and ran pytest. The session.install() calls map directly to pip install, and session.run() maps to running a command inside that environment. Every subsequent nox -s tests call rebuilds the environment by default, so you’re always testing against a clean slate.
The sections below cover the full nox feature set — parameterized sessions, multi-version testing, linting, and more — so you can replace a fragile shell script or a long tox config with a single readable Python file.
What is nox and Why Use It?
nox is a command-line tool for automating Python testing. You define sessions — each session is a Python function that installs dependencies and runs commands. When you execute a session, nox creates a dedicated virtual environment for it so nothing from your system Python leaks in.
The closest comparison is tox, which has served the Python ecosystem well for years. The key difference is that tox uses a configuration file (tox.ini or pyproject.toml sections) while nox uses a Python file. This means you can use loops, conditionals, environment variables, and any Python logic you want to control your sessions. tox’s config syntax eventually becomes a DSL of its own; nox stays Python.
| Feature | nox | tox |
|---|---|---|
| Configuration format | Python (noxfile.py) | INI / TOML config file |
| Conditional logic | Plain Python if/for statements | Limited config-level conditionals |
| Multi-version testing | @nox.session(python=["3.11","3.12"]) | [tox] envlist = py311,py312 |
| Reuse environments | nox -r flag | -e / skip-missing-interpreters |
| Learning curve | Low — just Python | Medium — needs config knowledge |
nox is the default automation tool for Google’s open-source Python projects and is used in CPython’s own test infrastructure. If your project is small, a simple noxfile with two sessions (tests + lint) is enough. If it grows, you can extend the same file rather than learning a new syntax.
Installing nox
Install nox globally with pip so it’s available across all your projects:
# install_nox.sh
pip install nox
Successfully installed nox-2024.4.15 virtualenv-20.26.0
Verify the installation:
# verify_nox.sh
nox --version
2024.4.15
nox is intentionally installed outside your project’s virtualenv. It’s a task runner, not a dependency. Your noxfile.py tells it what to install inside each session’s isolated environment.
Writing Your First noxfile
Create a file named noxfile.py at the root of your project — the same directory that contains your pyproject.toml or setup.py. A complete noxfile for a simple Python project looks like this:
# noxfile.py
import nox
# Reuse the virtual environment between runs with `nox -r` to speed things up
nox.options.reuse_existing_virtualenvs = True
@nox.session(python="3.12")
def tests(session):
"""Run the test suite with pytest."""
# Install test dependencies
session.install("pytest", "pytest-cov")
# Install the project itself in editable mode
session.install("-e", ".")
# Run pytest with coverage
session.run(
"pytest",
"--cov=mypackage",
"--cov-report=term-missing",
"tests/",
)
@nox.session(python="3.12")
def lint(session):
"""Check code style with flake8."""
session.install("flake8")
session.run("flake8", "mypackage/", "tests/")
Run both sessions sequentially with nox (no arguments), or run a specific one with nox -s tests. The nox.options.reuse_existing_virtualenvs = True line tells nox to skip rebuilding the environment if it already exists — this makes repeated local runs much faster. On CI you’d omit this setting so every run starts clean.
nox
nox > Running session tests
nox > Re-using existing virtual environment at .nox/tests.
nox > pytest --cov=mypackage --cov-report=term-missing tests/
========================= test session starts ==========================
collected 8 items
tests/test_utils.py ........ [100%]
---------- coverage: platform darwin, python 3.12 ----------
Name Stmts Miss Cover
-------------------------------------------
mypackage/utils.py 24 2 92%
nox > Session tests was successful.
nox > Running session lint
nox > Re-using existing virtual environment at .nox/lint.
nox > flake8 mypackage/ tests/
nox > Session lint was successful.
2 sessions ran successfully.
The session.install() method accepts the same arguments as pip install: package names, version pins ("pytest>=7.0"), and flags like "-e", "." for editable installs. The session.run() method takes the command as separate string arguments — this is intentional since it avoids shell injection issues and makes the call easy to read.
Testing Across Multiple Python Versions
One of nox’s most powerful features is running the same test suite against multiple Python versions without any extra tooling. Pass a list of version strings to the python parameter of @nox.session, and nox creates a separate environment for each:
# noxfile.py (multi-version excerpt)
import nox
@nox.session(python=["3.10", "3.11", "3.12", "3.13"])
def tests(session):
"""Run tests on all supported Python versions."""
session.install("pytest", "pytest-cov")
session.install("-e", ".")
session.run("pytest", "tests/")
nox -s tests
nox > Running session tests-3.10
nox > Creating virtual environment (virtualenv) using python3.10 in .nox/tests-3.10
...
nox > Session tests-3.10 was successful.
nox > Running session tests-3.11
nox > Creating virtual environment (virtualenv) using python3.11 in .nox/tests-3.11
...
nox > Session tests-3.11 was successful.
nox > Running session tests-3.12
...
nox > Session tests-3.12 was successful.
nox > Running session tests-3.13
...
nox > Session tests-3.13 was successful.
4 sessions ran successfully.
If a Python version isn’t installed on the machine, nox will skip it by default (or fail — use nox --no-error-on-missing-interpreters to keep going regardless). On CI you can set up your matrix to install exactly the versions you specify, then run nox -s tests and let nox handle the rest.
Run only a specific version by appending it to the session name: nox -s "tests-3.12". This is useful when you’re debugging a failure that only shows up on one Python version.
Adding Linting and Formatting Sessions
Testing sessions are the most common use of nox, but code quality checks are a natural second session. Here is a realistic setup using flake8 for linting and black for formatting:
# noxfile.py (linting session)
import nox
@nox.session(python="3.12")
def lint(session):
"""Run flake8 for style issues."""
session.install("flake8", "flake8-bugbear")
session.run("flake8", "mypackage/", "tests/", "--max-line-length=88")
@nox.session(python="3.12")
def format_check(session):
"""Check if black would reformat any file."""
session.install("black")
# --check exits non-zero if any file would be reformatted
session.run("black", "--check", "mypackage/", "tests/")
@nox.session(python="3.12")
def type_check(session):
"""Run mypy for static type checking."""
session.install("mypy")
session.install("-e", ".")
session.run("mypy", "mypackage/")
nox -s lint format_check
nox > Running session lint
nox > Creating virtual environment (virtualenv) using python3.12 in .nox/lint
nox > python -m pip install flake8 flake8-bugbear
nox > flake8 mypackage/ tests/ --max-line-length=88
nox > Session lint was successful.
nox > Running session format_check
nox > Creating virtual environment (virtualenv) using python3.12 in .nox/format_check
nox > python -m pip install black
nox > black --check mypackage/ tests/
All done! -- 6 files would be left unchanged.
nox > Session format_check was successful.
2 sessions ran successfully.
Each session installs only the tools it needs. The lint session doesn’t install black, and the format_check session doesn’t install flake8. This keeps each environment minimal and avoids version conflicts between tools.
Parameterized Sessions to Avoid Repetition
When you need to run the same session with different arguments — for example, testing against multiple database backends or different dependency combinations — use @nox.parametrize:
# noxfile.py (parameterized session)
import nox
DJANGO_VERSIONS = ["4.2", "5.0", "5.1"]
@nox.session(python="3.12")
@nox.parametrize("django", DJANGO_VERSIONS)
def tests_django(session, django):
"""Test against multiple Django versions."""
session.install(f"django=={django}", "pytest", "pytest-django")
session.install("-e", ".")
session.run("pytest", "tests/")
nox -s "tests_django(django='5.1')"
nox > Running session tests_django(django='5.1')
nox > Creating virtual environment (virtualenv) using python3.12 in .nox/tests_django-django-5-1
nox > python -m pip install django==5.1 pytest pytest-django
nox > python -m pip install -e .
nox > pytest tests/
========================= test session starts ==========================
collected 12 items
tests/test_views.py ............ [100%]
========================= 12 passed in 0.91s ============================
nox > Session tests_django(django='5.1') was successful.
@nox.parametrize generates one session per value in the list. Run all three with nox -s tests_django, or target one with the quoted name shown above. This pattern is far cleaner than duplicating the same session function three times with different package pins.
Passing Arguments Through to pytest
When debugging a specific test, you want to pass flags like -k test_login or -x directly to pytest without modifying the noxfile. nox supports this via the session.posargs mechanism:
# noxfile.py (posargs example)
import nox
@nox.session(python="3.12")
def tests(session):
"""Run tests. Pass extra pytest args after '--': nox -s tests -- -k test_login -v"""
session.install("pytest")
session.install("-e", ".")
# session.posargs contains anything after '--' on the command line
session.run("pytest", "tests/", *session.posargs)
nox -s tests -- -k "test_login" -v
nox > Running session tests
nox > pytest tests/ -k test_login -v
========================= test session starts ==========================
collected 8 items / 7 deselected / 1 selected
tests/test_auth.py::test_login PASSED [100%]
========================= 1 passed, 7 deselected in 0.14s ==============
nox > Session tests was successful.
The double dash -- separates nox arguments from pytest arguments. session.posargs is just a list — when it’s empty (no -- on the command line), the unpacking adds nothing. When it has values, they’re appended to the pytest command. This pattern works for any underlying tool, not just pytest.
Real-Life Example: A Complete Project noxfile
Here is a production-ready noxfile for a Python library that needs tests, coverage, linting, type checking, and documentation. This is modeled on the pattern used by real open-source packages:
# noxfile.py (complete project noxfile)
import nox
# Reuse envs locally, but not in CI
nox.options.reuse_existing_virtualenvs = True
# Default sessions run when you type 'nox' with no arguments
nox.options.sessions = ["tests", "lint"]
SOURCE_DIR = "src/mylib"
TEST_DIR = "tests"
@nox.session(python=["3.11", "3.12", "3.13"])
def tests(session):
"""Run the full test suite."""
session.install("pytest", "pytest-cov", "pytest-xdist")
session.install("-e", ".[dev]") # install with dev extras
session.run(
"pytest",
"--cov=" + SOURCE_DIR,
"--cov-report=term-missing",
"--cov-fail-under=85",
"-n", "auto", # parallel tests via pytest-xdist
TEST_DIR,
*session.posargs,
)
@nox.session(python="3.12")
def lint(session):
"""Run flake8, black check, and isort check."""
session.install("flake8", "flake8-bugbear", "black", "isort")
session.run("flake8", SOURCE_DIR, TEST_DIR, "--max-line-length=88")
session.run("black", "--check", SOURCE_DIR, TEST_DIR)
session.run("isort", "--check-only", "--profile=black", SOURCE_DIR)
@nox.session(python="3.12")
def typing(session):
"""Run mypy for type checking."""
session.install("mypy", "types-requests")
session.install("-e", ".")
session.run("mypy", SOURCE_DIR)
@nox.session(python="3.12")
def docs(session):
"""Build the Sphinx documentation."""
session.install("sphinx", "furo")
session.install("-e", ".")
session.run("sphinx-build", "-W", "docs/", "docs/_build/html/")
@nox.session(python="3.12")
def release_check(session):
"""Verify the package builds cleanly before releasing."""
session.install("build", "twine")
session.run("python", "-m", "build")
session.run("twine", "check", "dist/*")
nox
nox > Running session tests-3.11
...
nox > Session tests-3.11 was successful.
nox > Running session tests-3.12
...
nox > Session tests-3.12 was successful.
nox > Running session tests-3.13
...
nox > Session tests-3.13 was successful.
nox > Running session lint
...
nox > Session lint was successful.
4 sessions ran successfully.
This noxfile covers the typical CI pipeline in one readable file: parallel tests on three Python versions with coverage enforcement, code style checks, and type checking. The release_check session is only run manually when you’re preparing a release — it doesn’t appear in nox.options.sessions so it’s skipped in the normal flow. You can extend this by adding sessions for integration tests, database migrations, or any project-specific tasks.
Frequently Asked Questions
When should I use nox instead of tox?
Use nox when you want to express your test automation as Python code rather than configuration. nox is a better fit if you have conditional logic in your sessions (for example, running a session only on Linux), if you need to loop over a dynamic list of parameters, or if your team already writes Python well and finds INI-format config files annoying to maintain. tox is a mature choice if you prefer convention over configuration and your project’s needs are simple enough to express in a static config file.
How do I speed up nox when environment creation is slow?
Add nox.options.reuse_existing_virtualenvs = True to your noxfile to skip recreating environments that already exist. When dependencies change, run nox --reuse-existing-virtualenvs False -s tests or simply delete the .nox/ directory to force a clean rebuild. On CI, never reuse environments — always start clean to catch dependency drift early.
Can I install from a requirements.txt file inside a nox session?
Yes. Use session.install("-r", "requirements.txt"). You can combine this with pinned package installs in the same session. For projects that use pip-tools or pip-compile, the noxfile can also be the place where you regenerate your lock files — run pip-compile as a separate session and document it in the same file as your tests.
How do I set environment variables inside a nox session?
Use session.env to pass a dictionary of environment variables to a specific session.run() call: session.run("pytest", "tests/", env={"DATABASE_URL": "sqlite:///:memory:"}). To set variables for the entire session, assign to session.env at the start of the function: session.env["DEBUG"] = "1". This is cleaner than setting environment variables globally because each session’s environment is isolated from the others.
How do I run nox in GitHub Actions?
Use the setup-python action to install your target Python versions, then install nox with pip install nox, and finally run nox -s tests. For multi-version testing, use a matrix strategy to set up each version on a separate runner and pass nox -s "tests-${{ matrix.python-version }}" to target only the relevant session. This mirrors what you’d test locally but runs in parallel across the matrix.
How do I conditionally skip a session?
Call session.skip("reason") anywhere inside the session function to stop and mark the session as skipped rather than failed. A common pattern is to skip platform-specific sessions: if sys.platform != "linux": session.skip("Linux only"). This lets you define all sessions in one noxfile and let each environment (Windows, macOS, Linux) naturally skip what doesn’t apply to it.
Conclusion
nox removes the gap between “tests pass on my machine” and “tests pass everywhere.” By creating a fresh virtual environment for each session, it guarantees that every test run starts from the same known state — no stale packages, no version mismatches, no implicit dependencies inherited from your system Python. The noxfile.py is just Python, so all the conditional logic, loops, and parameterization you already know applies directly without learning a config DSL.
The real-life noxfile in this article is a solid foundation for most Python projects. Try extending it: add a session that runs your integration tests against a real database, add a @nox.parametrize decorator to test against multiple versions of a key dependency, or wire it into your GitHub Actions matrix. The official nox documentation at https://nox.thea.codes/en/stable/ covers advanced topics like session groups, backend selection (virtualenv vs. venv vs. conda), and the full configuration reference.