# How to configure Claude Code to run your pre-commit hooks


[Claude Code](https://code.claude.com/) edits your files, but the checks that gate your commit do not run until you type `git commit`. By then the agent has declared itself done, and you are the one reading the [pre-commit](https://pydevtools.com/handbook/how-to/how-to-set-up-pre-commit-hooks-for-a-python-project.md) failures. Wire pre-commit into the edit cycle instead, and Claude fixes its own lint and type violations before it hands the work back.

The trick is to run pre-commit itself, not a copy of it.

## Run your hooks, not a copy of them

The [Claude Code hooks guide](https://pydevtools.com/handbook/how-to/how-to-write-claude-code-hooks-for-python-projects.md) shows how to wire individual tools (a PostToolUse hook that calls [Ruff](https://pydevtools.com/handbook/reference/ruff.md), a Stop hook that calls [ty](https://pydevtools.com/handbook/reference/ty.md)) into a session. That works, but it duplicates the tools your `.pre-commit-config.yaml` already lists. Two copies of "what this project checks" drift apart: someone adds a hook to pre-commit, the Claude Code config never learns about it, and the agent passes its own checks while failing the commit.

Point Claude Code at `pre-commit` and the duplication disappears. The agent is checked against the same hooks that gate `git commit` and run in CI. Add a hook to `.pre-commit-config.yaml` and Claude picks it up on the next turn with no second edit.

## Prerequisites

* [Claude Code](https://code.claude.com/) installed
* [uv](https://pydevtools.com/handbook/reference/uv.md) installed (the hooks call pre-commit through [uvx](https://pydevtools.com/handbook/explanation/when-to-use-uv-run-vs-uvx.md))
* A Python project with [pre-commit configured](https://pydevtools.com/handbook/how-to/how-to-set-up-pre-commit-hooks-for-a-python-project.md) and a `.pre-commit-config.yaml` in the project root
* [jq](https://jqlang.github.io/jq/) installed (the PostToolUse hook reads the edited file path from JSON)

## Warm the hook cache before the session

pre-commit builds an isolated environment for each hook the first time it runs. A Ruff-only config takes a few seconds cold and drops to a fraction of a second once cached; more hooks add to the cold time. Run it once before opening a Claude Code session so the first in-session edit does not stall on those builds:

```console
$ uvx pre-commit run --all-files
```

After that, fast hooks (Ruff, the file-format checks, ty) return in a fraction of a second on the warm cache. A slow hook changes that math once it runs on every edit, which the PostToolUse hook below has to account for.

## Gate the end of every turn with a Stop hook

A `Stop` hook fires when Claude Code thinks it has finished. If the hook exits 2, Claude Code treats it as a blocking error, reads the message on stderr, and keeps working. That is the hook to run pre-commit in: the agent cannot call a turn done while its changes still fail a hook.

Create `.claude/hooks/precommit-stop.sh` in your project:

```bash {filename=".claude/hooks/precommit-stop.sh"}
#!/usr/bin/env bash
# Run pre-commit against the files changed since the last commit.
files=$(git diff --name-only --diff-filter=d HEAD)
[ -z "$files" ] && exit 0
uvx pre-commit run --files $files >&2 || exit 2
```

`git diff --name-only --diff-filter=d HEAD` lists the changed paths and drops deletions, so pre-commit never receives a file that no longer exists. The `>&2` redirect sends pre-commit's report to stderr, where Claude Code reads it, and `exit 2` is what marks the turn as not done.

Register it in `.claude/settings.json`:

```json {filename=".claude/settings.json"}
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/precommit-stop.sh",
            "timeout": 120
          }
        ]
      }
    ]
  }
}
```

When Claude leaves an undefined name in a file, the turn ends like this and Claude reads it and fixes the line:

```console
ruff check...............................................................Failed
- hook id: ruff-check
- exit code: 1

F821 Undefined name `missing_name`
 --> app.py:2:12
```

## Auto-fix as Claude edits with a PostToolUse hook

The Stop hook is the gate. A `PostToolUse` hook on `Write|Edit` is the tighter loop: it runs pre-commit on each file the moment Claude writes it, so formatting and import sorting land immediately instead of piling up until the end of the turn.

Create `.claude/hooks/precommit-edit.sh`:

```bash {filename=".claude/hooks/precommit-edit.sh"}
#!/usr/bin/env bash
input=$(cat)
file_path=$(echo "$input" | jq -r '.tool_input.file_path')
[[ "$file_path" == *.py && -f "$file_path" ]] || exit 0
uvx pre-commit run --files "$file_path" >&2 || exit 2
```

The script reads the edited path from the JSON on stdin, skips anything that is not an existing `.py` file, and runs pre-commit on that one file. Register it under `PostToolUse`:

```json {filename=".claude/settings.json"}
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/precommit-edit.sh",
            "timeout": 60
          }
        ]
      }
    ]
  }
}
```

Run both together: the PostToolUse hook keeps each edit clean, and the Stop hook guarantees the whole change set passes before the agent stops.

> [!WARNING]
> The PostToolUse hook runs on every edit, so its cost is the slowest hook in your config times the number of edits in a turn. Ruff, the file checks, and ty each finish in well under a second and stay imperceptible. A slow whole-project hook like [mypy](https://pydevtools.com/handbook/reference/mypy.md) on a large codebase does not: it re-runs on every write and drags the loop. Keep slow checks out of the per-edit hook and let the once-per-turn Stop hook carry them. Skip one inline with `SKIP=mypy uvx pre-commit run --files "$file_path"`, or drop the PostToolUse hook entirely and rely on the Stop hook, which still enforces the full config before the agent finishes.

## Check the changed files, not the whole repository

Both hooks use `pre-commit run --files`, never `--all-files`. `--all-files` reports pre-existing violations in code that Claude never touched, the Stop hook exits 2 on those, and the agent burns turns chasing legacy errors instead of finishing the task. Scoping to changed files judges Claude on its own work.

`--files` only bounds hooks that take a file list, such as Ruff. A whole-project checker like ty ignores the list and re-checks everything on each run, because an edit in one module can break types in another. It stays fast, but it re-surfaces type errors anywhere in the project every turn. Quiet pre-existing ones through the checker's own ignore config rather than expecting `--files` to scope them; see [how to gradually adopt type checking in an existing Python project](https://pydevtools.com/handbook/how-to/how-to-gradually-adopt-type-checking-in-an-existing-python-project.md) for that approach.

## Why does an auto-fix show as passing?

Run the PostToolUse hook on a brand-new file full of unused imports and pre-commit rewrites it but reports `Passed` with exit 0. That is expected. pre-commit decides "files were modified by this hook" by diffing against the Git index, and an unstaged or untracked file has no staged version to compare against. The fix still lands in the working file; pre-commit just has nothing to flag.

This is the behavior you want. Auto-fixable issues (formatting and import order) get fixed silently, and only the errors Claude has to resolve by hand (an undefined name or a type error) exit non-zero and interrupt the agent. The Stop hook surfaces those every time, on staged and unstaged files alike.

## Document the workflow in CLAUDE.md

Hooks enforce the loop; a `CLAUDE.md` note explains it so the agent reaches for pre-commit on its own even mid-turn:

```markdown {filename="CLAUDE.md"}
## Checks

Run `uvx pre-commit run --files <changed files>` after editing Python
files and fix anything it reports. These are the same hooks that gate
`git commit`, so a clean pre-commit run means the change is ready.
```

The instruction is the floor, not the enforcement. Claude Code reads `CLAUDE.md` as guidance it can skip; the Stop hook is what guarantees the checks actually run. Pair the two so the policy is both documented and enforced.

The Stop hook only runs while Claude Code is editing, so keep `uvx pre-commit run --all-files` in CI as the backstop for commits it never sees, and add the [`--no-verify` defenses](https://pydevtools.com/handbook/how-to/how-to-stop-ai-agents-from-bypassing-pre-commit-hooks.md) if the agent tries to skip the local hook.

## Learn More

* [How to set up pre-commit hooks for a Python project](https://pydevtools.com/handbook/how-to/how-to-set-up-pre-commit-hooks-for-a-python-project.md) for the underlying `.pre-commit-config.yaml` setup
* [How to write Claude Code hooks for Python projects](https://pydevtools.com/handbook/how-to/how-to-write-claude-code-hooks-for-python-projects.md) for the full PreToolUse, PostToolUse, and Stop hook reference
* [How to stop AI agents from bypassing pre-commit hooks](https://pydevtools.com/handbook/how-to/how-to-stop-ai-agents-from-bypassing-pre-commit-hooks.md) for the `--no-verify` defenses that complement this proactive setup
* [How to configure Ruff with Claude Code](https://pydevtools.com/handbook/how-to/how-to-configure-ruff-with-claude-code.md) for the Ruff-only version of the edit-cycle loop
* [The project setup tutorial](https://pydevtools.com/handbook/tutorial/set-up-a-python-project-for-claude-code.md) builds pre-commit, CLAUDE.md, hooks, and skills together on a fresh uv project
* [Claude Code hooks documentation](https://code.claude.com/docs/en/hooks) and [pre-commit.com](https://pre-commit.com/) for the upstream references
