Skip to content

How to write Claude Code hooks for Python projects

Claude Code hooks run commands on session events. Use them to format Python edits or return check failures to the agent. Instructions in CLAUDE.md guide the model; hooks run independently when their configured event matches.

Prerequisites

  • Claude Code and uv installed in the environment running the session
  • A uv project with Ruff installed as a development dependency
  • A .claude/hooks/ directory in the project

uv run executes the command inside the project’s virtual environment, ensuring all dependencies are installed first.

Choose the event

Event When it runs Effect of exit code 2
PreToolUse Before a tool call Blocks that call
PostToolUse After a successful tool call Gives Claude feedback; the tool already ran
UserPromptSubmit Before a submitted prompt is processed Blocks that prompt
Stop When Claude tries to end a turn Returns feedback and asks Claude to continue

Hooks receive JSON on stdin. Exit 0 means success; other nonzero codes normally report a non-blocking error. Exit 2 has the event-specific effects in the table, with stderr returned as feedback. Plain stdout on exit 0 reaches the model for some events, such as UserPromptSubmit, but not all events. See the hooks reference for JSON output and failure handling.

Check common bare Python commands

Save this small PreToolUse guardrail:

.claude/hooks/enforce-uv.py
import json
import shlex
import sys

command = json.load(sys.stdin).get("tool_input", {}).get("command", "")
try:
    words = shlex.split(command)
except ValueError:
    sys.exit(0)
if words and words[0] in {"pip", "pip3", "python", "python3", "pytest", "ruff"}:
    print("Use uv add/remove for dependencies, or uv run for Python tools.", file=sys.stderr)
    sys.exit(2)

Register it in .claude/settings.json:

.claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|PowerShell",
        "hooks": [
          {
            "type": "command",
            "command": "uv",
            "args": [
              "run",
              "--no-project",
              "python",
              "${CLAUDE_PROJECT_DIR}/.claude/hooks/enforce-uv.py"
            ],
            "timeout": 30
          }
        ]
      }
    ]
  }
}

This catches a bare first command such as pip install requests or pytest, while allowing uv run python -m pytest. It does not parse full Bash or PowerShell programs: cd app && pip install requests, aliases, and subprocess calls can bypass it. Use it for corrective feedback, not as a security boundary. Permissions and sandboxing control access separately.

Format Python files after edits

Save this PostToolUse script:

.claude/hooks/ruff-after-edit.py
import json
import os
from pathlib import Path
import subprocess
import sys

payload = json.load(sys.stdin)
root = Path(payload.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or ".")
path = Path(payload.get("tool_input", {}).get("file_path", ""))
if not path.is_absolute():
    path = root / path
if path.suffix not in {".py", ".pyi"} or not path.is_file():
    sys.exit(0)

failed = False
for args in (["check", "--fix", "--force-exclude"], ["format", "--force-exclude"]):
    result = subprocess.run(
        ["uv", "run", "ruff", *args, str(path)],
        cwd=root, capture_output=True, text=True,
    )
    if result.returncode:
        print(result.stdout + result.stderr, file=sys.stderr)
        failed = True
sys.exit(2 if failed else 0)

Register it under PostToolUse in the same settings file:

.claude/settings.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "uv",
            "args": [
              "run",
              "--no-project",
              "python",
              "${CLAUDE_PROJECT_DIR}/.claude/hooks/ruff-after-edit.py"
            ],
            "timeout": 30
          }
        ]
      }
    ]
  }
}

The script runs safe lint fixes and formatting on .py and .pyi files. --force-exclude respects Ruff’s configured exclusions even though the file is named explicitly. Remaining diagnostics reach Claude on stderr with exit 2 instead of being discarded.

Write|Edit covers those two tools. It does not catch changes made by shell commands, external editors, or every other agent tool. Keep checks in pre-commit and CI.

Return a type-check failure once

For an existing project using Pyrefly, save this Stop hook:

.claude/hooks/check-types.py
import json
import os
import subprocess
import sys

payload = json.load(sys.stdin)
# Let Claude stop after one corrective attempt; report remaining failures.
if payload.get("stop_hook_active"):
    sys.exit(0)
result = subprocess.run(
    ['uv', 'run', 'pyrefly', 'check'],
    cwd=payload.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or ".",
    capture_output=True, text=True,
)
if result.returncode:
    print(result.stdout + result.stderr, file=sys.stderr)
    print("Fix the failure or report why verification is blocked.", file=sys.stderr)
    sys.exit(2)

Register it in .claude/settings.json:

.claude/settings.json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "uv",
            "args": [
              "run",
              "--no-project",
              "python",
              "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-types.py"
            ],
            "timeout": 120
          }
        ]
      }
    ]
  }
}

Install Pyrefly in the project before enabling this hook. Replace the command with the project’s existing type checker if it uses another tool. Start from a passing baseline, or limit the checker to the code in scope.

The stop_hook_active check lets Claude stop after one corrective attempt. Without a bound, persistent failures make the agent repeat work. The hook asks for a fix or an explicit blocker report; a required CI check is still the completion gate.

Merge and verify the hooks

The JSON examples show individual events. Merge them into one hooks object instead of replacing existing configuration. Each handler uses exec form (command plus args), so uv starts directly on macOS, Linux, and Windows without shell quoting or a jq dependency.

--no-project starts these standard-library handlers without synchronizing the surrounding project. The Ruff and Pyrefly commands inside them still use uv run with the active project’s dependencies.

Use the payload’s cwd for checks in the active directory. Claude Code keeps CLAUDE_PROJECT_DIR at the original checkout after entering a worktree; use it to locate the installed handler, with cwd deciding which checkout to check.

Open /hooks in Claude Code to confirm registration. After editing hook settings, review any approval dialog and start a fresh session if the change has not loaded. Test the uv hook with a sample payload:

echo '{"tool_name":"Bash","tool_input":{"command":"pip install requests"}}' | uv run --no-project python .claude/hooks/enforce-uv.py

It prints the uv guidance and exits 2. Replacing the command value with uv run python -m pytest should produce no output and exit 0. This pipe works in Bash and PowerShell; inspect $? in Bash or $LASTEXITCODE in PowerShell for the exit code.

Then ask Claude to edit a Python file and inspect the diff. Run uv run ruff check . and uv run ruff format --check . to confirm the result. A configured hook is useful only if it runs in the session’s environment and produces the expected file changes.

Last updated on