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:
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:
{
"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:
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:
{
"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:
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:
{
"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.pyIt 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.