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 four 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
Stop When Claude finishes a turn Surfaces feedback to Claude so it can iterate

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'."}}

Catch type errors before Claude finishes

This Stop hook runs a type checker after every turn and surfaces failures back to Claude so the next turn can fix them. Type errors are tighter feedback than test failures: the message points at the line and the wrong type, so the model can act on it without rerunning anything.

The hook is a one-liner. Register it in .claude/settings.json:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "uv run ty check >&2 || exit 2",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

The >&2 redirect sends ty’s output to stderr, and exit 2 is what Claude Code reads as a blocking failure that needs to be surfaced to the model. Without both pieces, Claude sees a clean exit and moves on.

Drop in any other type checker the same way: uv run mypy ., uvx pyrefly check, or uvx pyright. The handbook recommends ty for new projects; see How do mypy, pyright, and ty compare? for the trade-offs.

Caution

The hook exits 2 on any type error, not just the ones Claude introduced. On a codebase with hundreds of pre-existing errors, every turn ends with the hook failing on legacy code, and Claude wastes turns chasing those instead of finishing the task. Either scope the command to the path Claude is editing (uv run ty check src/newmodule/) or configure the type checker to ignore legacy modules (see How to gradually adopt type checking in an existing Python project) before enabling the hook.

Complete configuration

Here is a single .claude/settings.json combining all four 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"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "uv run ty check >&2 || exit 2",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

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...