Project Setup

Keeping pre-commit Hook Versions Current with autoupdate

Update pre-commit hooks safely: autoupdate options, frozen SHA pins, renamed hook ids, scheduled update pull requests, and a check that hook and project tool versions match.

Updated

Every hook in .pre-commit-config.yaml is pinned to a rev, and that pin is what makes hooks reproducible: everyone runs the same Ruff, the same formatter, the same checks. It is also a dependency that ages. Left alone for a year, the pins drift far behind the versions your editor and CI use; new lint rules, bug fixes and Python-version support never arrive; and the eventual catch-up update changes so much at once that nobody wants to review it. pre-commit autoupdate moves pins forward, but running it well involves a few decisions — tags or commit hashes, everything at once or one repository at a time, by hand or by bot — and one trap that catches most projects: the same tool pinned at different versions in pre-commit and in pyproject.toml. This guide covers all of it for a Python CLI repository. It belongs to the pre-commit topic.

Prerequisites

What autoupdate does

For each remote repository in the configuration, pre-commit autoupdate fetches its tags, picks the newest, and rewrites the rev. It does not change hook ids, arguments or any other settings — which is exactly why an update occasionally needs a human.

What autoupdate changes pre-commit autoupdate fetches each hook repository’s tags, picks the newest and rewrites the rev, leaving hook ids and arguments untouched. What autoupdate changes Fetch tags per repository Pick newest release tag Rewrite rev tag or frozen SHA You review ids, new rules diff Renamed hook ids and new lint rules are the parts autoupdate cannot handle for you.
pre-commit autoupdate                                   # every repository
pre-commit autoupdate --repo https://github.com/astral-sh/ruff-pre-commit   # one repository
pre-commit autoupdate --freeze                          # pin to commit SHAs
pre-commit autoupdate --bleeding-edge                   # newest commit on the default branch

--bleeding-edge tracks unreleased code and has no place in a project others depend on. The other three are everyday tools.

The recipe

Choose tags or frozen hashes

A tag such as v0.16.10 is readable but mutable: whoever controls the hook repository could move it. --freeze pins the exact commit instead and keeps the tag as a comment:

  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: f12be1ebaa5351c1fc76472de98db2c3446c8253  # frozen: v0.16.10
    hooks:
      - id: ruff-check
      - id: ruff-format

Frozen pins are the same idea as pinning GitHub Actions to commit SHAs, discussed in securing the supply chain of a Python CLI: hooks run code on every developer's machine, so an immutable reference is the safer default for anything outside your own organisation. Use --freeze consistently, so the file has one convention.

Review renamed and removed hooks

Because autoupdate only changes rev, a hook repository that renames a hook leaves your configuration pointing at the old id. Sometimes the old id survives as an alias — after updating ruff-pre-commit across a major rename, the output shows ruff (legacy alias) for a configuration still using id: ruff, where current releases call it ruff-check. Sometimes it disappears and the next run fails with "hook id not present in repository". After every update, run the hooks once and read the names:

pre-commit autoupdate --freeze
pre-commit run --all-files
git diff .pre-commit-config.yaml

New releases of linters also add rules or change formatting, so the first run after an update may fix or flag code. Commit those changes with the update, in the same pull request, so the history explains why fifty files were reformatted.

An update with a renamed hook Terminal session running autoupdate with frozen pins and seeing a hook reported as a legacy alias after the update. An update with a renamed hook bash $ pre-commit autoupdate --freeze [https://github.com/astral-sh/ruff-pre-commit] updating v0.6.9 -> v0.16.10 (frozen) $ pre-commit run --all-files ruff (legacy alias)......................................Passed # rename the hook id to ruff-check before the alias disappears Always run the hooks once after updating and read the names.

Keep hook and project versions in sync

The same tool often appears twice: Ruff as a pre-commit hook and in the project's lint dependency group, used by editors and CI. If the hook says 0.16.10 and uv.lock says 0.15.2, formatting flips back and forth between the two, and CI disagrees with the commit hook. A small check makes the drift visible:

# scripts/check_tool_versions.py
"""Fail if a tool's pre-commit rev differs from its version in uv.lock."""
from __future__ import annotations

import re
import sys
import tomllib
from pathlib import Path

HOOK_REPOS = {
    "https://github.com/astral-sh/ruff-pre-commit": "ruff",
    "https://github.com/pre-commit/mirrors-mypy": "mypy",
}


def hook_versions(config_text: str) -> dict[str, str]:
    found = {}
    for repo, package in HOOK_REPOS.items():
        m = re.search(rf"repo:\s*{re.escape(repo)}\s*\n\s*rev:\s*(\S+)(?:\s*#\s*frozen:\s*(\S+))?",
                      config_text)
        if m:
            tag = m.group(2) or m.group(1)       # frozen pins keep the tag in a comment
            found[package] = tag.lstrip("v")
    return found


def locked_versions(lock_text: str) -> dict[str, str]:
    lock = tomllib.loads(lock_text)
    return {p["name"]: p["version"] for p in lock.get("package", [])}


def main() -> int:
    hooks = hook_versions(Path(".pre-commit-config.yaml").read_text())
    locked = locked_versions(Path("uv.lock").read_text())
    problems = [f"{name}: pre-commit {hv} vs uv.lock {locked[name]}"
                for name, hv in hooks.items() if name in locked and locked[name] != hv]
    for p in problems:
        print(f"version mismatch {p}", file=sys.stderr)
    return 1 if problems else 0


if __name__ == "__main__":
    raise SystemExit(main())

Run it in CI and as a local hook. When it fails, update whichever side is behind — uv lock --upgrade-package ruff or pre-commit autoupdate --repo … — in the same change. An alternative that removes the duplication entirely is a local hook with language: system and entry: uv run ruff check, so the hook always uses the locked version; speeding up slow pre-commit hooks uses that pattern for mypy. The trade-off is that the hook then needs the project environment to exist.

Automate the update

A weekly workflow that runs autoupdate and opens a pull request turns maintenance into a five-minute review:

# .github/workflows/pre-commit-autoupdate.yml
name: pre-commit autoupdate
on:
  schedule:
    - cron: "30 5 * * 1"
  workflow_dispatch:

permissions:
  contents: write
  pull-requests: write

jobs:
  autoupdate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pipx install pre-commit==4.6.2
      - run: pre-commit autoupdate --freeze
      - run: pre-commit run --all-files || true          # apply formatter changes to the PR
      - uses: peter-evans/create-pull-request@v7
        with:
          branch: pre-commit-autoupdate
          title: "Update pre-commit hooks"
          body: "Automated `pre-commit autoupdate --freeze`. CI runs the hooks on this branch."

pre-commit.ci offers the same weekly pull requests without a workflow, and Renovate has a pre-commit manager that can group hook updates with other dependency updates. Whichever you choose, let the normal CI — including the version-sync check — run on the update branch.

UX considerations

  • Small, regular updates. Weekly or monthly, one pull request at a time. A year's worth of updates in one change is unreviewable.
  • Update reformatting separately from features. Keep the "hooks changed the code" commit out of feature branches, so blame and review stay readable; .git-blame-ignore-revs can hide large reformat commits from git blame.
  • Pin pre-commit itself in CI and document the version for contributors, so the tool reading the configuration is as reproducible as the hooks.
  • Read the changelogs of major bumps. Linters announce rule removals and renames there; autoupdate will not.
How often to update Trade-offs of updating pre-commit hooks weekly, monthly or yearly. How often to update Cadence Diff size Review effort Weekly (bot) one or two pins minutes Monthly a few pins, small reformat a short review Yearly everything, big reformat an afternoon Small regular updates are cheaper than one heroic catch-up.

Testing the behaviour

The sync check is pure parsing, so it is easy to test with inline fixtures:

# tests/test_check_tool_versions.py
from scripts.check_tool_versions import hook_versions, locked_versions

CONFIG = """
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: f12be1ebaa5351c1fc76472de98db2c3446c8253  # frozen: v0.16.10
    hooks:
      - id: ruff-check
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.11.2
    hooks:
      - id: mypy
"""

LOCK = """
version = 1
[[package]]
name = "ruff"
version = "0.16.10"
[[package]]
name = "mypy"
version = "1.10.0"
"""


def test_frozen_pins_use_the_tag_comment():
    assert hook_versions(CONFIG) == {"ruff": "0.16.10", "mypy": "1.11.2"}


def test_mismatch_is_detected():
    hooks, locked = hook_versions(CONFIG), locked_versions(LOCK)
    assert [n for n, v in hooks.items() if locked.get(n) != v] == ["mypy"]

Then run the whole loop once by hand: downgrade one hook's rev, run pre-commit autoupdate --freeze, run the hooks, and check that the version-sync script points at the remaining mismatch.

Conclusion

Hook pins should move forward regularly and deliberately. Use pre-commit autoupdate --freeze for immutable pins with readable comments, update one repository at a time when a change needs attention, run the hooks right after updating to catch renamed ids and new formatting, keep hook and project tool versions in sync with a small check, and let a weekly bot open the pull request. Maintenance then costs minutes a month instead of an afternoon a year.

Frequently asked questions

Does autoupdate update additional_dependencies?

No. Versions listed in additional_dependencies (common with mirrors-mypy) stay where they are. That is one more reason to prefer local hooks that use the project environment, where uv.lock controls every version.

Can autoupdate pick pre-release tags?

It picks the newest tag according to the repository's tag list, which is normally the newest release. If a hook repository publishes pre-release tags you do not want, pin that repository by hand and exclude it from automated updates.

How do I roll back a bad hook update?

Revert the update commit, or set the old rev again. pre-commit keeps old environments in its cache until pre-commit gc, so switching back is instant.

Should a CLI that ships its own hook test against autoupdate?

Yes — users will run pre-commit autoupdate to get your new releases, so tag releases properly and test the hook as described in shipping your CLI as a pre-commit hook.

What about hooks from the same organisation as the project?

Internal hook repositories — a company's shared license-header or secret-scanning hooks — deserve the same treatment, with one addition: release them with real tags. A hook repository that is only ever referenced by branch name forces every consumer onto --bleeding-edge behaviour, so a broken commit to the hook breaks every repository at once. Tagged releases plus a weekly autoupdate give each project a review step before the change reaches it.