How to write Claude Code hooks for Python projects
Claude Code hooks are scripts that run at specific points during a session, letting you enforce project conventions programmatically. A CLAUDE.md file suggests how Claude should behave; hooks guarantee it by blocking disallowed commands, auto-formatting files, or injecting context before Claude sees your prompt. See the official hooks documentation for the full reference.
How hooks work
Hooks fire on events during a Claude Code session. Claude Code supports over 25 event types (SessionStart, PreToolUse, PostToolUse, UserPromptSubmit, Stop, and more). This guide covers the three most useful for Python projects:
| Event | When it fires | What exit 2 does |
|---|---|---|
| PreToolUse | Before Claude runs a tool (Bash, Write, Edit, etc.) | Blocks the tool call |
| PostToolUse | After a tool executes successfully | Surfaces feedback to Claude (the tool already ran) |
| UserPromptSubmit | When you submit a prompt, before Claude processes it | Blocks the prompt from being sent |
Every hook receives JSON on stdin. For tool events, the JSON includes tool_name and tool_input (containing the tool’s parameters). Your hook’s exit code controls what happens next:
- Exit 0: success. Claude parses any JSON you write to stdout.
- Exit 1: non-blocking error. Execution continues.
- Exit 2: blocking error. The action is stopped and your stderr message is shown.
Hooks run with CLAUDE_PROJECT_DIR set to the project root. You can also add an "if" condition to a hook handler to filter which tool calls trigger it (e.g., "if": "Edit(*.py)" runs only on Python file edits).
Prerequisites
- Claude Code installed
- uv installed
- jq installed (for the shell-based Ruff hook)
- A Python project with Ruff as a dev dependency (
uv add --dev ruff)
Block pip and enforce uv
This PreToolUse hook intercepts Bash commands and blocks anything that should use uv instead.
Create .claude/hooks/enforce-uv.py in your project:
#!/usr/bin/env python3
import json
import sys
data = json.load(sys.stdin)
command = data.get("tool_input", {}).get("command", "")
redirects = {
"pip install": "uv add",
"pip3 install": "uv add",
"pip uninstall": "uv remove",
"pip3 uninstall": "uv remove",
"python -m pip": "uv add / uv remove",
"python3 -m pip": "uv add / uv remove",
"python -m pytest": "uv run pytest",
"python3 -m pytest": "uv run pytest",
"python -m ruff": "uv run ruff",
"python3 -m ruff": "uv run ruff",
}
for pattern, replacement in redirects.items():
if pattern in command:
print(f"Blocked: '{pattern}' detected. Use '{replacement}' instead.", file=sys.stderr)
sys.exit(2)
# Block bare python/pytest/ruff unless prefixed with uv run
bare_commands = {"python ", "python3 ", "pytest", "ruff "}
for bare in bare_commands:
if command == bare.strip() or (command.startswith(bare) and not command.startswith("uv run")):
print(f"Blocked: use 'uv run {command}' instead.", file=sys.stderr)
sys.exit(2)Register it in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/enforce-uv.py"
}
]
}
]
}
}When Claude tries to run pip install requests, the hook blocks the command and prints:
Blocked: 'pip install' detected. Use 'uv add' instead.
Claude can use this feedback to retry with the correct command.
Auto-format with Ruff after edits
This PostToolUse hook runs Ruff on every Python file Claude edits as a best-effort formatting pass. Since the tool already ran, this hook cannot undo the edit; it applies fixes on top of what Claude wrote.
Create .claude/hooks/ruff-after-edit.sh in your project:
#!/usr/bin/env bash
input=$(cat)
file_path=$(echo "$input" | jq -r '.tool_input.file_path')
if [[ "$file_path" == *.py ]] && [[ -f "$file_path" ]]; then
uv run ruff check --fix --quiet "$file_path" 2>/dev/null
uv run ruff format --quiet "$file_path" 2>/dev/null
fi
exit 0Register it in .claude/settings.json under PostToolUse:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/ruff-after-edit.sh"
}
]
}
]
}
}Tip
The "if" field can narrow when the hook runs. Adding "if": "Edit(*.py)" to the handler means it only fires for Python file edits. The in-script check handles both Write and Edit with a single condition, so this example keeps the check in the script.
Add workflow reminders
This UserPromptSubmit hook scans your prompt for keywords and injects reminders about project conventions as additional context that Claude reads before responding.
Create .claude/hooks/workflow-reminders.py in your project:
#!/usr/bin/env python3
import json
import sys
data = json.load(sys.stdin)
prompt = data.get("prompt", "").lower()
reminders = {
"install": "Use 'uv add <package>', not pip install.",
"test": "Run tests with 'uv run pytest'.",
"format": "Format with 'uv run ruff format .'.",
"lint": "Lint with 'uv run ruff check .'.",
"type": "Type-check with 'uv run ty check'.",
}
matched = [msg for keyword, msg in reminders.items() if keyword in prompt]
if matched:
output = {
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": " ".join(matched),
}
}
json.dump(output, sys.stdout)Register it in .claude/settings.json under UserPromptSubmit:
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/workflow-reminders.py"
}
]
}
]
}
}When you type “install pandas and write a test”, the hook outputs JSON that Claude reads as additional context:
{"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": "Use 'uv add <package>', not pip install. Run tests with 'uv run pytest'."}}Complete configuration
Here is a single .claude/settings.json combining all three hooks:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/enforce-uv.py"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/ruff-after-edit.sh"
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/workflow-reminders.py"
}
]
}
]
}
}Tip
Place hooks in .claude/settings.json at your project root so they travel with the repository and apply to every team member. For personal hooks that apply to all your projects, use ~/.claude/settings.json instead.
Test a hook manually
Pipe sample JSON to a hook script to verify it works before committing:
echo '{"tool_name":"Bash","tool_input":{"command":"pip install requests"}}' \
| python3 .claude/hooks/enforce-uv.py
echo $? # Should print 2 (blocked)echo '{"tool_name":"Bash","tool_input":{"command":"uv add requests"}}' \
| python3 .claude/hooks/enforce-uv.py
echo $? # Should print 0 (allowed)If a hook runs too slowly, add a "timeout" in seconds to the handler configuration to prevent it from blocking the session.
Learn More
- Claude Code hooks documentation
- Claude Code hooks for uv projects for an alternative uv enforcement approach
- How to configure Claude Code to use uv for the CLAUDE.md-based approach
- How to use the pydevtools CLAUDE.md template
- How to install Astral plugins for Claude Code
- Ruff reference
- uv reference