Claude Code Is Writing Bad Python Because You Skip This Setup

Claude Code Is Writing Bad Python Because You Skip This Setup

The Hidden Feature Most Claude Code Users Miss

I watched a developer spend 20 minutes yesterday manually running black and pytest after each Claude Code edit. They’d ask Claude to write a function, wait for it to finish, then run their quality checks, find issues, ask Claude to fix them, and repeat the cycle.

There’s a better way.

PostToolUse Hook : A Claude Code configuration that automatically executes a shell command after Claude edits or creates a file, enabling instant formatting, linting, testing, or any other automation without manual intervention.

PostToolUse hooks fire every time Claude touches a Python file. You configure them once in your settings, and from that point forward, every single edit gets automatically formatted, linted, and tested. No manual steps. No forgetting to run the linter. No discovering style violations in code review.

Black Formatter: The One Hook Everyone Needs

Start here. This single hook prevents 90% of formatting debates:

1
2
3
4
5
6
7
8
9
[[hooks]]
event = "PostToolUse"
run_in_background = false

[hooks.matcher]
tool_name = "edit_file"
file_paths = ["*.py"]

command = "black $CLAUDE_FILE_PATHS"

Every Python file Claude edits instantly conforms to PEP 8 standards. No more inconsistent indentation. No more weird line lengths. Black makes the formatting decisions so you don’t have to.

The run_in_background = false setting is intentional. You want formatting to complete before Claude continues, so subsequent edits build on properly formatted code.

The Modern Stack: Ruff + Black

Ruff : A Python linter written in Rust that replaces Flake8, Pylint, and isort with a single tool that runs 10-100x faster, making it practical to run on every file edit.

If you’re still running flake8 or pylint, you’re waiting too long. Ruff does the same job in milliseconds:

1
2
3
4
5
6
7
8
9
[[hooks]]
event = "PostToolUse"
run_in_background = false

[hooks.matcher]
tool_name = "edit_file"
file_paths = ["*.py"]

command = "ruff check --fix $CLAUDE_FILE_PATHS && black $CLAUDE_FILE_PATHS"

Ruff’s --fix flag automatically corrects many issues—unused imports, incorrect comparisons, and deprecated patterns. Then Black handles formatting. By the time Claude reports the edit is complete, your code is already clean.

Background Testing That Doesn’t Block

Here’s where it gets interesting. You want tests running constantly, but you don’t want to wait for them:

1
2
3
4
5
6
7
8
9
[[hooks]]
event = "PostToolUse"
run_in_background = true

[hooks.matcher]
tool_name = "edit_file"
file_paths = ["src/**/*.py"]

command = "python -m pytest tests/ -v"

Notice run_in_background = true. Claude continues working while tests run in parallel. You catch regressions immediately without slowing down the AI.

The file_paths = ["src/**/*.py"] pattern limits this to source files only. Editing test files doesn’t trigger test runs—that would create infinite loops.

Type Checking With Mypy

For typed Python codebases, add type validation:

1
2
3
4
5
6
7
8
9
[[hooks]]
event = "PostToolUse"
run_in_background = false

[hooks.matcher]
tool_name = "edit_file"
file_paths = ["*.py"]

command = "mypy $CLAUDE_FILE_PATHS"
Mypy : Python’s static type checker that validates type hints at development time, catching type mismatches before runtime and enabling IDE-level code intelligence.

This catches the “I changed this function’s signature but forgot to update the callers” errors that AI coding assistants love to create.

Security Scanning With Bandit

AI-generated code has a habit of introducing security vulnerabilities. Catch them immediately:

1
2
3
4
5
6
7
8
9
[[hooks]]
event = "PostToolUse"
run_in_background = false

[hooks.matcher]
tool_name = "edit_file"
file_paths = ["*.py"]

command = "bandit -r $CLAUDE_FILE_PATHS"

Bandit flags hardcoded credentials, unsafe SQL string building, insecure deserialization, and dozens of other security anti-patterns. When Claude writes eval(user_input) (it happens), you’ll know instantly.

The Complete Quality Pipeline

Set Up Python Quality Hooks

Configure Claude Code to automatically format, lint, type-check, security-scan, and test Python files on every edit

Create or Edit Your Settings File

Claude Code hooks go in your settings file. For global hooks, edit ~/.claude/settings.toml. For project-specific hooks, create a .claude/settings.toml in your project root:

1
2
mkdir -p .claude
touch .claude/settings.toml

Add the Formatting and Linting Hook

Start with the essentials that should run synchronously:

1
2
3
4
5
6
7
8
9
[[hooks]]
event = "PostToolUse"
run_in_background = false

[hooks.matcher]
tool_name = "edit_file"
file_paths = ["*.py"]

command = "ruff check --fix $CLAUDE_FILE_PATHS && black $CLAUDE_FILE_PATHS"

This handles formatting and linting on every edit.

Add Background Testing

Add a separate hook for tests that runs in parallel:

1
2
3
4
5
6
7
8
9
[[hooks]]
event = "PostToolUse"
run_in_background = true

[hooks.matcher]
tool_name = "edit_file"
file_paths = ["src/**/*.py"]

command = "python -m pytest tests/ -v --tb=short"

The --tb=short flag keeps test failure output concise.

Add Type Checking (Optional)

For typed codebases, add mypy validation:

1
2
3
4
5
6
7
8
9
[[hooks]]
event = "PostToolUse"
run_in_background = false

[hooks.matcher]
tool_name = "edit_file"
file_paths = ["src/**/*.py"]

command = "mypy $CLAUDE_FILE_PATHS --ignore-missing-imports"

The --ignore-missing-imports flag prevents noise from third-party packages without type stubs.

Specialized Hooks For Specific Workflows

Django Projects

Django has its own validation layer. Add this for model and migration files:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
[[hooks]]
event = "PostToolUse"
run_in_background = false

[hooks.matcher]
tool_name = "edit_file"
file_paths = ["*/models.py", "*/migrations/*.py"]

command = '''
black $CLAUDE_FILE_PATHS
python manage.py makemigrations --check --dry-run
python manage.py check
'''

This catches migration issues and model validation errors before they reach your database.

Import Organization

If you want imports sorted consistently (stdlib, then third-party, then local):

1
2
3
4
5
6
7
8
9
[[hooks]]
event = "PostToolUse"
run_in_background = false

[hooks.matcher]
tool_name = "edit_file"
file_paths = ["*.py"]

command = "isort $CLAUDE_FILE_PATHS --profile black && black $CLAUDE_FILE_PATHS"

The --profile black ensures isort’s output is compatible with Black’s formatting.

Dead Code Detection

Vulture finds unused functions and variables. Run it periodically to catch code rot:

1
2
3
4
5
6
7
8
9
[[hooks]]
event = "PostToolUse"
run_in_background = true

[hooks.matcher]
tool_name = "edit_file"
file_paths = ["src/**/*.py"]

command = "vulture src/"

Running in the background means it doesn’t slow you down, but you’ll see warnings when unused code accumulates.

Exit Codes Control Hook Behavior

Understanding exit codes gives you fine-grained control:

  • Exit 0: Success. No output shown to Claude.
  • Exit 1: Failure. Blocks the operation and shows the error.
  • Exit 2: Always shows output in chat, regardless of success or failure.

Use this for conditional handling:

1
2
3
4
5
6
7
8
command = '''
if black $CLAUDE_FILE_PATHS; then
  echo "Formatted successfully"
else
  echo "Formatting failed - check syntax"
  exit 1
fi
'''

When formatting fails (usually a syntax error from Claude), the hook blocks and you see exactly what went wrong.

If you want maximum impact with minimal complexity, start with just two hooks:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Synchronous formatting and linting
[[hooks]]
event = "PostToolUse"
run_in_background = false
[hooks.matcher]
tool_name = "edit_file"
file_paths = ["*.py"]
command = "ruff check --fix $CLAUDE_FILE_PATHS && black $CLAUDE_FILE_PATHS"

# Background testing
[[hooks]]
event = "PostToolUse"
run_in_background = true
[hooks.matcher]
tool_name = "edit_file"
file_paths = ["src/**/*.py"]
command = "python -m pytest tests/ -v --tb=short"

This covers the essentials. Add type checking and security scanning once you’ve validated the basic workflow works for your codebase.

When Hooks Don’t Work

Hooks aren’t magic. They break in predictable ways:

Slow hooks kill productivity. If your test suite takes 5 minutes, running it after every edit is impractical. Either run it in the background (accepting delayed feedback) or limit the hook to fast unit tests.

Conflicting tools cause chaos. If you have both isort and Black running, and they disagree on import formatting, you get an infinite loop of reformatting. Use isort --profile black to prevent this.

CI/CD should remain the authority. Hooks are for fast feedback during development. Your CI pipeline should run the full test suite, security scans, and comprehensive type checking. Hooks complement CI; they don’t replace it.

FAQ

Do hooks work with all Claude Code tools or just edit_file?

Hooks can match multiple tool names. The most common are edit_file and write_file. You can also use tool_name = "bash" to hook shell commands, but this requires careful configuration to avoid recursion.

How do I debug when a hook fails silently?

Add explicit echo statements to your hook command and temporarily set run_in_background = false. This forces output to appear in the Claude Code interface. Once debugging is complete, revert to your normal configuration.

Can I have different hooks for different projects?

Yes. Project-specific hooks go in .claude/settings.toml within the project directory. These override global hooks from ~/.claude/settings.toml. Use project-specific hooks for project-specific test commands or linting configurations.

Will hooks slow down Claude Code significantly?

Synchronous hooks add latency equal to their execution time. Ruff and Black typically complete in under 100ms, which is imperceptible. Keep synchronous hooks fast. Move slow operations (full test suites, type checking large codebases) to background hooks.

What happens if a hook command isn't installed?

The hook fails with a “command not found” error. Claude sees this error and may attempt to help, but won’t automatically install tools. Make sure all hook dependencies (black, ruff, mypy, etc.) are installed in your development environment.

Conclusion

Key Takeaways

  • PostToolUse hooks run automatically after Claude edits Python files, eliminating manual quality checks
  • Black + Ruff provides instant formatting and linting in under 100ms per file edit
  • Background hooks with run_in_background = true let tests run without blocking Claude’s workflow
  • Exit code 1 blocks operations and surfaces errors; exit code 0 runs silently; exit code 2 always shows output
  • Start with just formatting and linting hooks, then add type checking and security scanning
  • Project-specific hooks in .claude/settings.toml override global hooks for per-project customization
  • Hooks complement CI/CD pipelines; they don’t replace comprehensive testing and validation

The best development setup is invisible. You shouldn’t be thinking about running formatters or linters—that’s what hooks are for. Configure them once, and every Python file Claude touches gets automatically validated. The result is cleaner code, fewer review cycles, and less time spent on the mechanical parts of development.

Security runs on data.
Make it work for you.

Effortlessly test and evaluate web application security using Vibe Eval agents.