Linters & formatters

Pre-commit Hooks for Python: Run Linters and Formatters Before Every Commit

Linters & formatters · · Published 2026-10-27

Sepia photograph of the inside of a wooden railway signal box, with a long row of signal levers under tall windows
Inside the signal box at Bundamba railway station, Queensland. Public domain.

The best time to catch a style problem is before it is committed. Git can run a script at that moment, and the pre-commit framework turns that mechanism into something a whole team can share: a single configuration file listing the checks, installed with one command.

Git hooks in one paragraph

Git looks in .git/hooks/ for executable files with specific names. If a file named pre-commit exists, git runs it after you type git commit and before it records the commit; if the script exits with a non-zero status, the commit is aborted. The mechanism is simple, but it has two weaknesses for teams: the hooks folder is not part of the repository, so it cannot be shared through version control, and each tool a hook calls has to be installed by every developer. The pre-commit framework solves both.

The pre-commit framework

pre-commit is an open-source tool, written in Python, that manages hooks for many languages. You describe the hooks in a .pre-commit-config.yaml file at the root of the repository, commit it, and each developer installs the git hook once. pre-commit then creates an isolated environment for each hook repository, pinned to the version you specify, so everyone runs exactly the same tool versions, independent of what is installed in their project environment.

A configuration for a Python project, following the examples in the documentation of pre-commit and of ruff's hook repository:

repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v6.0.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-added-large-files
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.17.3
    hooks:
      - id: ruff-check
        args: [--fix]
      - id: ruff-format

Each entry names a repository that publishes hooks, a rev (a tag, so the version is fixed), and the hook ids to use. The first repository provides small general-purpose hooks: removing trailing whitespace (the problem pycodestyle reports as W291), making sure files end with exactly one newline (W292 and W391), validating YAML syntax and refusing accidentally added large files. The second runs ruff's linter, applying safe fixes, and then its formatter. Put the linter with --fix before the formatter, as ruff's documentation recommends, since fixes can leave code that needs reformatting. The rev values above were the latest tags in October 2026; use whatever pre-commit autoupdate gives you.

If your project uses separate tools, the same structure applies: Black, isort and flake8 each publish a hook in their own repositories.

Installing and running

python -m pip install pre-commit
pre-commit install            # writes .git/hooks/pre-commit
pre-commit run --all-files    # run every hook on the whole repository

After pre-commit install, every git commit runs the hooks on the staged files only, which keeps it fast. When a hook modifies files, for example by removing trailing whitespace, the commit fails and the modified files are left unstaged: review the changes, git add them and commit again. This is deliberate, so that no change enters a commit without you seeing it.

Run pre-commit run --all-files when you first add pre-commit or a new hook, because otherwise existing files are only checked when someone happens to modify them. Doing it in a dedicated commit keeps the clean-up separate from real changes, as our guide to adopting a style guide recommends.

Keeping hooks up to date

pre-commit autoupdate updates each rev to the latest tag of its repository. Treat the result like any dependency upgrade: run the hooks on all files, look at what changed, and commit the configuration together with any reformatting. Pinning versions in the file is what makes results reproducible; a formatter that silently changed version on one laptop would produce diffs nobody asked for.

Skipping, and why not to make a habit of it

git commit --no-verify skips the hooks entirely, and the SKIP environment variable skips named hooks, as in SKIP=ruff-check git commit. Both are useful in emergencies, for example a work-in-progress commit on a private branch. Because skipping is always possible, hooks are a convenience for developers, not an enforcement mechanism. The enforcement belongs in CI.

The same checks in CI

Run the identical configuration in continuous integration with pre-commit run --all-files, so a pull request that bypassed the local hooks still fails. Alternatively, call the tools directly in CI (ruff check . and ruff format --check .), but then keep the versions in sync with the hook configuration. Some projects also use pre-commit.ci, a hosted service from the same author, which runs the hooks on pull requests and can push fixes.

New clone, new machine

Because the hooks folder is not versioned, every fresh clone needs pre-commit install again, and so does every new computer. It is worth adding to the project's setup instructions, next to creating the virtual environment. If you are setting up a new laptop because you are relocating as a software developer, keeping a short list of such commands saves an afternoon of rediscovering them.

What to put in hooks, and what not

To see what a style hook would report on a single snippet before committing, paste it into the checker on our home page. For running the tools by hand, see how to check style locally.

Sources