If you've used Claude Code hooks for any length of time, you've probably hit this moment: you wire up prettier --write to a PostToolUse hook, the next Edit call kicks it off, and suddenly your terminal is scrolling forever. Edit, PostToolUse, Edit, PostToolUse — Ctrl+C does almost nothing, and your CPU fan starts ramping up. I remember the first time it happened to me. Watching that scroll, my finger hovering on the keyboard, was a small but very real lesson in how easy it is to accidentally build a loop in a system that's designed to react to its own output.
The official docs warn you about hook loops in passing, but in practice this is one of the most common ways a hooks setup blows up in real projects. In this article I'll walk through why these loops happen and the patterns I use day-to-day to make sure they don't.
Why hooks loop in the first place
PostToolUse fires right after Claude Code runs any tool — Edit, Write, Bash, and so on. That makes it tempting to chain extra logic onto every tool call. The problem is that whatever your hook does often looks, from the outside, like another tool call or a file change. Once that's true, the hook becomes its own trigger.
A few patterns I see over and over:
- Formatters that re-trigger Edit. A PostToolUse hook on
Editrunsprettier --write. The file changes. Claude observes the new state and decides another edit is needed. The Edit fires PostToolUse again. Round and round. - Auto-commit hooks that look like Bash calls. A PostToolUse hook on
Writeshells out togit add . && git commit -m "...". From the runtime's view, that's a Bash invocation, so a separateBash-matched PostToolUse hook fires on it. If that second hook does anything Claude can observe, you're in a loop. - Marker files that look like Edits. Hooks that drop a
.claude-last-run.jsonfor bookkeeping get picked up by another hook watching for file changes, which then writes its own marker — same pattern, different outfit.
The common shape is always the same: the hook's side effect overlaps with the hook's trigger condition. As long as those two things touch, there's no logical reason the loop will ever stop on its own.
First aid — stopping a runaway hook safely
When a hook is actively looping, Ctrl+C alone may not be enough. The Claude Code session can keep running in the background even if the visible output goes quiet. The first thing I do is the same every time: I physically empty the hooks block in settings.json so nothing can fire on the next call.
# Drain hooks so the loop can't restart
cd ~/.claude
cp settings.json settings.json.bak
jq '.hooks = {}' settings.json > settings.json.tmp && mv settings.json.tmp settings.jsonDon't forget that hooks can live in multiple scopes. Project-level .claude/settings.json, the personal .claude/settings.local.json, and the user-global ~/.claude/settings.json are all merged together. Search across them so you don't miss one:
grep -rln '"hooks"' ~/.claude .claude 2>/dev/nullOnce the bleeding has stopped, restore your hooks one at a time from the backup. Whichever hook brings the loop back is the one you need to redesign.
Three patterns to prevent loops by design
After triage comes the real work. Here are the three patterns I rely on in production projects.
Pattern 1: a re-entry marker
The simplest and highest-leverage trick is to give the hook a way to recognize that it itself is already running. I use the CLAUDE_HOOK_DEPTH environment variable as a one-bit reentrancy flag.
#!/bin/bash
# .claude/hooks/format.sh — guard against re-entry
if [ -n "${CLAUDE_HOOK_DEPTH:-}" ]; then
# We were called from inside another hook — don't go deeper
exit 0
fi
export CLAUDE_HOOK_DEPTH=1
# Real work
prettier --write "$1" >/dev/null 2>&1
# Expected output: nothing on stdout, so Claude has no reason to re-evaluate
exit 0The other thing to watch for is silence. Hooks like this should produce no stdout. If you echo the result, Claude reads it as fresh signal and may decide further action is needed — that's another way loops sneak in. For pure side-effect hooks like formatters, success means say nothing.
Pattern 2: tighten the matcher to exclude your own work
The matcher field in the hook config accepts patterns. If you take the time to be specific, you can keep your hook from reacting to its own side effects.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"command": ".claude/hooks/format.sh",
"exclude_paths": [".claude-last-run.json", "node_modules/**", ".next/**"]
},
{
"matcher": "Bash",
"command": ".claude/hooks/log-bash.sh",
"exclude_commands_matching": "^(git |prettier|eslint|jq )"
}
]
}
}Excluding paths and commands isn't just about preventing loops — it also makes the logs much easier to read after the fact. When hooks misbehave, my first instinct is always to check whether the matcher is doing what I think it's doing. For a deeper walkthrough of why hooks sometimes don't fire at all, see How to Troubleshoot Claude Code Hooks That Don't Fire.
Pattern 3: file locks so only one runs at a time
The last line of defense is flock. When several hooks touch the same files — formatter, committer, notifier — wrapping each one in a lock means even if multiple are triggered at once, they queue up instead of looping.
#!/bin/bash
# .claude/hooks/safe-format.sh
LOCK_FILE="/tmp/claude-hooks.lock"
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
# Already running — give up rather than queueing
exit 0
fi
prettier --write "$1" >/dev/null 2>&1
# The lock is released automatically on exitLocks are great for short side effects (formatting, linting). They're a poor fit for long-running tasks because subsequent hook calls will fail to acquire the lock. I keep locks for sub-second work and use the re-entry marker (Pattern 1) for anything that takes meaningful time.
Designing logs that catch loops early
A loop is best caught before it becomes an emergency. I keep a small counter inside my hooks that trips a fuse if the same hook fires too many times in too short a window.
#!/bin/bash
# Self-defeating hook: stops itself if it fires too often
COUNT_FILE="/tmp/claude-hook-count"
NOW=$(date +%s)
LAST=$(cat "$COUNT_FILE.time" 2>/dev/null || echo 0)
COUNT=$(cat "$COUNT_FILE" 2>/dev/null || echo 0)
if [ $((NOW - LAST)) -lt 5 ]; then
COUNT=$((COUNT + 1))
else
COUNT=1
fi
echo "$COUNT" > "$COUNT_FILE"
echo "$NOW" > "$COUNT_FILE.time"
if [ "$COUNT" -ge 10 ]; then
echo "⚠️ Hook fired 10+ times in 5s — likely a loop. Skipping." >&2
exit 0
fi
# Real work below this lineWith a circuit breaker like this, a misconfiguration that would have looped forever now becomes a few noisy lines on stderr and a clean exit. For a deeper take on observability for production hooks, Debugging Claude Code Hooks in Production covers the layered approach I use when even logs aren't telling the whole story.
Variations of the same bug worth recognizing
Once you've debugged a hook loop a few times, you start spotting the same shape in places you wouldn't expect. A few real examples from my own projects, in case they help you see patterns earlier:
- Cross-tool loops between Edit and Bash. A PostToolUse on
Editruns a shell formatter, and a PostToolUse onBashlogs every command. The formatter shells out, the logger fires, the logger writes a status line to a file, the file change is observed — and now you have two hooks taking turns. The fix is the same idea as Pattern 2: exclude formatter commands from the Bash matcher. - Hooks that update their own config. I once had a hook that wrote a
last_run_atfield back into the project's settings file. Settings is read every cycle, so editing it counts as an event. After three minutes I had a settings file with thousands of timestamps and a clear lesson: hooks should never write to anything in.claude/. - Notifier hooks that retry on failure. A Slack notifier failed because of a timeout, returned non-zero, and Claude treated that as "this needs another attempt." A retry loop disguised as a hook loop. The fix here is simple: notifier-style hooks should always exit 0, even on failure, and log their own errors elsewhere.
When you start seeing these as the same problem in different clothes, you'll catch them in code review before they ship.
A small step you can take today
There's no single switch that makes hook loops go away. But the underlying principle is small enough to internalize quickly: never let a hook's side effect overlap with its trigger condition. If you keep that one rule in mind, you'll catch most loops before you write them.
If you want to act on this today, pick exactly one of these three:
- Open every PostToolUse hook you have and check whether it writes anything to stdout. For formatters and linters, the right answer is almost always silence.
- Add a single
exclude_pathsorexclude_commands_matchingentry to your existing matcher so your hook won't react to its own work. - Drop a circuit-breaker script — like the counter above — into
.claude/hooks/so that any future misconfiguration self-terminates after 10 firings in 5 seconds.
That third one is the foundation I'd recommend if you're planning to grow your hook setup further. For a more complete map of what's possible with hooks once you've made them safe, Claude Code Hooks Master Guide is a good next stop.