Skip to content

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:

.claude/hooks/enforce-uv.py
#!/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:

.claude/hooks/ruff-after-edit.sh
#!/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 0

Register 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:

.claude/hooks/workflow-reminders.py
#!/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:

.claude/settings.json
{
  "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

Last updated on

Please submit corrections and feedback...