← Blog

Python Build Automation: nox, tox, uv, Hatch and CI

September 16, 2026

Python build automation is the set of commands that take a project from "code on my laptop" to "tested wheel on PyPI" without anyone typing them by hand. For most projects in 2026, the setup that works is small:

  • pyproject.toml with a build backend (hatchling or setuptools) describes the package.
  • One tool manages environments and dependencies — usually uv, Hatch, or Poetry.
  • One task runner holds the named jobs — nox, tox, or a justfile — so "run the tests" is the same command everywhere.
  • pre-commit catches lint issues before a commit exists.
  • GitHub Actions runs the same tasks on every push and publishes on a tag.

The mistake people make is picking five overlapping tools. You need one of each job, not one of each brand. Below: what each tool actually does, a quick comparison, a working noxfile.py, and a CI workflow you can adapt.

If you're after scripts that automate other work — renaming files, pulling APIs — that's a different topic, covered in Python for automation. This article is about automating the project itself.

Quick Comparison

ToolMain jobConfig lives inRuns named tasksBuilds / publishesBest fit
noxTask runner with per-session virtualenvsnoxfile.py (Python)YesVia commands you writeMulti-Python test matrices, logic in Python
toxTest environment managertox.tomlYesVia commands you writeLibraries with large version/dependency matrices
invokeGeneral task runnertasks.py (Python)YesVia commands you writeOps-style scripts, no env management needed
Make / justCommand runnerMakefile / justfileYesVia commands you writePolyglot repos, thin wrappers over other tools
uvPackage, env and Python managerpyproject.toml + uv.lockNouv build, uv publishFast default for new projects
HatchProject manager + env scriptspyproject.tomlYes (env scripts)hatch build, hatch publishOne tool for envs, tests, build, release
PoetryDependency manager + packagerpyproject.toml + poetry.lockNopoetry build, poetry publishTeams already standardized on it
pre-commitGit hook manager.pre-commit-config.yamlHooks onlyNoLint and format before commit
GitHub ActionsCI/CD runner.github/workflows/*.ymlYes (jobs)Yes, via stepsRunning all of the above on every push

Read the "Runs named tasks" column carefully. uv and Poetry manage environments and packaging, but neither CLI has a command for defining your own named tasks. That's why they're so often paired with nox or just.

How a Python build works: pyproject.toml and build backends

Every modern Python build starts in pyproject.toml. Three standards do the heavy lifting:

  • PEP 517 splits the work between a build frontend (the tool you run, like python -m build, uv build, or pip) and a build backend (the library that actually produces the sdist and wheel, through hooks such as build_wheel and build_sdist).
  • PEP 518 adds the [build-system] table, which tells the frontend which backend to install.
  • PEP 621 standardizes the [project] table — name, version, dependencies — so metadata isn't locked to one tool.

A fourth, PEP 735, defines [dependency-groups] for development-only dependencies like pytest and ruff. uv, Hatch and nox all read it, which means you can declare your dev tools once.

A minimal project looks like this:

[build-system]
requires = ["hatchling >= 1.26"]
build-backend = "hatchling.build"

[project]
name = "acme-tools"
version = "0.3.0"
requires-python = ">=3.10"
dependencies = ["httpx"]

[dependency-groups]
dev = ["pytest", "ruff"]

The Python Packaging User Guide lists the common backends side by side:

Backendbuild-backend valueTypical reason to choose it
hatchlinghatchling.buildClean defaults, pairs with Hatch
setuptoolssetuptools.build_metaC extensions, long-lived projects
flit-coreflit_core.buildapiPure-Python, minimal config
pdm-backendpdm.backendProjects using PDM
uv_builduv_buildPure-Python projects on uv
poetry-corepoetry.core.masonry.apiProjects using Poetry

The practical point: the backend is swappable. Because the interface is standard, any frontend can build your package without knowing which backend you picked.

Task runners: nox, tox, invoke, Make and just

A task runner answers one question: what does "run the tests" mean for this repo? Without one, the answer lives in a stale README or in CI YAML nobody runs locally.

nox: sessions written in Python

nox defines each task as a decorated Python function called a session. Each session gets its own virtualenv, and passing python=[...] creates one session per interpreter. Since sessions are plain Python, conditionals and loops need no special syntax.

Here's a realistic noxfile.py for the project above:

import nox

# Use uv to create environments when it's installed, virtualenv otherwise.
nox.options.default_venv_backend = "uv|virtualenv"
nox.options.sessions = ["lint", "tests"]

PYPROJECT = nox.project.load_toml("pyproject.toml")
DEV = nox.project.dependency_groups(PYPROJECT, "dev")


@nox.session
def lint(session):
    """Check style and formatting with ruff."""
    session.install("ruff")
    session.run("ruff", "check", ".")
    session.run("ruff", "format", "--check", ".")


@nox.session(python=["3.12", "3.13"])
def tests(session):
    """Run the test suite on each supported Python."""
    session.install(".", *DEV)
    session.run("pytest", *session.posargs)


@nox.session(default=False)
def build(session):
    """Build the sdist and wheel into dist/."""
    session.install("build")
    session.run("python", "-m", "build")

The commands you'll use daily:

  • nox runs the default sessions (lint and both test versions)
  • nox -s tests runs one session; nox -s build runs the one excluded by default=False
  • nox -s tests -- -k parser forwards arguments to pytest via session.posargs
  • nox -l lists sessions with their docstrings
  • nox -R reuses existing environments and skips reinstalling, for fast reruns

Nox also has opt-in parallel execution. Sessions that declare allow_parallel=True can run at the same time under --parallel.

tox: declarative environment matrices

tox solves the same problem declaratively. Its docs now recommend tox.toml and describe the INI format as deprecated: existing projects keep working, but INI gets no new features. You list environments in env_list, define shared settings under [env_run_base], and override per environment under [env.lint].

Common commands are tox (run the default list), tox run -e lint, tox run -e 3.13 -- -v to pass arguments through, and tox p to run environments in parallel. If you want uv speed without leaving tox, the tox-uv plugin replaces virtualenv and pip with uv inside tox environments.

nox or tox? Pick tox when your matrix is mostly data: Python versions crossed with dependency versions. Pick nox when a task needs real logic, like reading a file, branching on the platform, or chaining steps.

invoke, Make and just: runners without environments

These three run commands but don't create virtualenvs for you.

  • Invoke reads a tasks.py file of @task functions that call c.run("..."). Function arguments become CLI flags automatically, invoke --list shows what's available, and @task(pre=[clean]) runs a dependency first.
  • Make is already on most Unix-like developer machines, and a Makefile with test, lint and build targets is still a perfectly good front door. The friction is Make-isms like .PHONY and tab-sensitive syntax.
  • just describes itself as a command runner, not a build system. Recipes live in a justfile, take arguments, need no .PHONY, and just runs on Linux, macOS and Windows with no extra dependencies.

A common modern pattern: a thin justfile whose recipes are one-liners like uv run pytest. Environment handling stays in uv, and the justfile only gives people short, memorable names.

uv, Hatch and Poetry: one tool for the whole loop

These tools manage the project itself: dependencies, lockfiles, environments, and packaging.

uv: sync, run, build, publish

uv covers most of the loop with four commands:

StepCommandWhat it does
Installuv sync --lockedInstalls from uv.lock and errors if the lockfile is out of date
Runuv run pytestMakes sure the project environment is up to date, then runs the command
Versionuv version --bump minorUpdates the version in pyproject.toml
Builduv build --no-sourcesWrites sdist and wheel to dist/; --no-sources checks the build works without tool.uv.sources, as other build tools would see it
Publishuv publishUploads to PyPI; with Trusted Publishing from CI, no token is needed

uv add --dev pytest writes to the PEP 735 dev group, and uv syncs that group by default. uv doesn't define named tasks, so pair it with nox (set the uv backend, as in the noxfile above) or with just.

Hatch: environments with scripts

Hatch is the closest thing to an all-in-one. Environments are declared under [tool.hatch.envs.<name>] and can include dependency groups. Each environment can carry named scripts:

[tool.hatch.envs.test]
dependency-groups = ["dev"]

[tool.hatch.envs.test.scripts]
cov = "pytest --cov=acme_tools tests"

You run that with hatch run test:cov. Hatch also ships hatch test, which runs pytest (with coverage.py behind --cover) without a custom environment. hatch build produces the sdist and wheel, and hatch publish uploads whatever is in dist/. If your team wants one binary and no separate task runner, Hatch is the strongest case.

Poetry: dependency management and packaging

Poetry handles resolution and locking, and it packages with poetry build (--format wheel or sdist limits the output) and poetry publish --build. Its docs recommend poetry sync over poetry install to avoid stale packages, and since Poetry 2.0 they point new projects at the standard project.dependencies table. Like uv, Poetry has no task command. Teams on Poetry usually add a Makefile, just, or nox for the rest.

Wiring it into pre-commit and GitHub Actions

Local tasks only help if they also run where nobody can skip them.

pre-commit for the fast checks

pre-commit manages Git hooks from a .pre-commit-config.yaml file. Run pre-commit install once per clone and the hooks run on every commit. Run pre-commit run --all-files to check the whole repo (a useful CI step), and pre-commit autoupdate to bump hook versions. Keep it to seconds-long checks like ruff and whitespace fixes. Slow test suites belong in CI.

A GitHub Actions workflow

GitHub Actions runs your tasks on each push. The uv docs recommend the official astral-sh/setup-uv action, which can set the Python version per matrix entry and cache downloads:

name: ci

on:
  push:
    branches: [main]
    tags: ["v*"]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.12", "3.13"]
    steps:
      - uses: actions/checkout@v7
      - uses: astral-sh/setup-uv@v9.0.0
        with:
          python-version: ${{ matrix.python-version }}
          enable-cache: true
      - run: uv sync --locked
      - run: uv run ruff check .
      - run: uv run pytest

  publish:
    if: startsWith(github.ref, 'refs/tags/v')
    needs: test
    runs-on: ubuntu-latest
    environment: pypi
    permissions:
      id-token: write
    steps:
      - uses: actions/checkout@v7
      - uses: astral-sh/setup-uv@v9.0.0
      - run: uv build --no-sources
      - run: uv publish

Three details matter here:

  1. id-token: write is what enables Trusted Publishing. You register the repo, workflow and environment as a trusted publisher on PyPI, and no API token is ever stored in secrets.
  2. Publishing only runs on version tags and only after tests pass, so a broken build can't reach PyPI.
  3. Pin actions deliberately. The uv docs pin actions to full commit SHAs. Whatever you choose, check each action's releases page before copying a tag from any guide, including this one.

If you'd rather keep CI thin, replace the three run steps with uvx nox. The workflow then runs exactly what developers run locally. For how GitHub Actions compares with other automation platforms, see Pieces vs GitHub Actions vs n8n.

Which setup to pick

The right stack depends on who touches the repo, not on which tool is newest:

  • New pure-Python project: uv + hatchling or uv_build + a small noxfile or justfile + pre-commit + the workflow above.
  • Library with a wide compatibility matrix: tox (with tox-uv) or nox with parametrized sessions.
  • Team that wants one tool: Hatch, using env scripts and hatch test.
  • Existing Poetry project: keep Poetry, and add nox or just for named tasks instead of migrating.

Key points:

  • Standards (PEP 517, 518, 621, 735) mean the backend, env manager and task runner are independent choices.
  • uv and Poetry don't run named tasks. nox, tox, Hatch scripts, just, Make and invoke do.
  • The same command should run locally and in CI. That one rule prevents most "passes on my machine" failures.
  • Publish from CI with Trusted Publishing, gated on tags and passing tests.

Build automation is one layer in a bigger stack. The five layers of automation in computer programming shows where it sits next to formatting, deployment and AI code generation.

The same setup problem shows up well beyond Python packaging. If the setup step is where you keep stalling with other people's AI tools, Taku mirrors a working AI setup into a desktop workspace and runs it, so you can use a power user's configuration without reproducing their environment first. Taku is in Beta, and the Mac app is available now.

FAQ

What is Python build automation? It's scripting the repeatable steps of a Python project (installing dependencies, linting, testing, building the sdist and wheel, and publishing) so one command or one CI run does all of them the same way every time.

Is nox or tox better for Python build automation? Neither is strictly better. tox suits declarative matrices of Python and dependency versions in tox.toml. nox suits tasks that need logic, because sessions are Python functions. Both can use uv under the hood.

Can uv replace nox or tox? Partly. uv handles environments, locking, uv run, uv build and uv publish, but it has no named-task feature and no built-in multi-session matrix runner. Most uv projects still add nox, tox or a justfile.

Do I still need setup.py? Not for most projects. A pyproject.toml with a [build-system] table and a [project] table is enough for backends like hatchling or setuptools. Projects with complex C extensions may still keep setuptools configuration.

How do I automate publishing a Python package to PyPI? Build in CI with uv build, hatch build or python -m build, then upload with uv publish or the PyPA publish action. Use PyPI Trusted Publishing with id-token: write so no long-lived token is stored, and trigger the job on version tags.

What's the difference between Python build automation and Python for automation? Build automation targets your own codebase: tests, packaging, releases. "Python for automation" (sometimes searched as py automation) means using Python scripts to automate other work, like files, spreadsheets and APIs.