Format-on-edit is the easiest hook to write and the one most beginners get wrong. The naive prettier --write pipeline will spam Claude with formatter noise on a half-typed file, ping-pong with your eslint config, and reformat generated files into uncommittable diffs.
The pattern that holds up in a real codebase
.claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format-on-edit.sh"
}
]
}
]
}
}
.claude/hooks/format-on-edit.sh:
#!/usr/bin/env bash
set -euo pipefail
# Hooks receive the event JSON envelope on stdin. Edit, Write, and MultiEdit
# all expose the target as tool_input.file_path (MultiEdit applies all edits
# to a single file, so the path is at the top level too).
input=$(cat)
path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
[ -z "$path" ] && exit 0
# Resolve to absolute. CLAUDE_PROJECT_DIR points to the project root; cwd at
# hook time is the tool's working directory and may not be the project root.
case "$path" in
/*) abs="$path" ;;
*) abs="${CLAUDE_PROJECT_DIR:-$PWD}/$path" ;;
esac
[ -f "$abs" ] || exit 0
# Skip generated/vendored paths regardless of .prettierignore. Per-file
# prettier invocation does not always discover ignore files the way `prettier .`
# does, so do the filter in the script and treat .prettierignore as backup.
case "$abs" in
*/node_modules/*|*/dist/*|*/.next/*|*/build/*|*.gen.ts|*.gen.tsx|*.snap|*/package-lock.json|*/pnpm-lock.yaml|*/yarn.lock)
exit 0
;;
esac
# Run the formatter. Requires prettier installed in the project (npm i -D
# prettier). Swallow stderr and exit code so a formatter error never becomes
# noise that Claude tries to "fix" by reverting the edit.
npx prettier --write --log-level=warn "$abs" >/dev/null 2>&1 || true
exit 0
Make it executable once:
chmod +x .claude/hooks/format-on-edit.sh
Three things this script gets right that the one-liner in most blog posts gets wrong:
- It reads the JSON envelope from stdin (the documented way) and pulls
tool_input.file_pathwithjq. That handles Edit, Write, and MultiEdit uniformly. - It filters generated paths in the script, not only in
.prettierignore. Prettier loads.prettierignorerelative to its invocation cwd, so a per-file invocation can miss the ignore file you placed at the repo root. - It always exits 0. Formatter noise on a transient syntax error should never reach Claude as a system reminder, because Claude will try to “fix” the noise.
Footguns
The jq | xargs one-liner has a shell-expansion bug. The shape every blog post copies is jq -r '.tool_input.file_path' | xargs -I {} npx prettier --write {}. Two ways this breaks. First, paths with spaces survive xargs -I {} only because -I disables splitting, but they get re-split as soon as anything else in the chain expands them. The first dev who clones the repo into ~/My Documents/work/... finds out the hard way. Second, every prettier invocation pays the full npx resolver cost, so xargs for many files balloons your hook latency. Read the JSON once, extract file_path once, run prettier once. The script above does exactly that.
Formatter and linter ping-pong. If you run prettier in a hook and have eslint configured with autofix on save or on commit, the two tools fight. Eslint rewrites prettier’s output back to its preferred quote style or import order, the next edit triggers prettier, and you get a thirty-line “no-op” diff every commit. Pick one tool as the writer. The standard fix: prettier in the hook, plus eslint-config-prettier in your eslint config to disable every stylistic rule that conflicts with prettier. Add eslint-plugin-prettier only if you want CI to enforce prettier formatting as an eslint error. Never run eslint --fix in a second PostToolUse hook in the same session.
A formatter error becomes a Claude distraction, not a block. PostToolUse hooks cannot block the tool call: by the time the hook runs, the edit has already happened. What they can do is feed stderr back to Claude as a system reminder (exit 2) or print a one-line error notice in the transcript (any other nonzero exit). Either way Claude reads it on the next turn, often misinterprets the formatter complaint as an instruction to revert the edit, and you get whiplash. The script above swallows stderr and forces exit 0 for that reason. If you genuinely want to refuse an edit before it lands, that is a PreToolUse hook with exit code 2, not a PostToolUse hook.
Prettier on generated code corrupts builds. .next/, dist/, lockfiles, GraphQL schemas, Jest snapshots: prettier is happy to rewrite all of them, and the diffs are uncommittable noise that confuses CI. .prettierignore is supposed to handle this, but it only applies relative to the cwd prettier was invoked from. A hook that runs prettier --write /abs/path/file.ts from a directory without your ignore file may format files you meant to skip. Do the path filter inside the shell script (the case statement above) and treat .prettierignore as a backup.
Per-edit npx is the slow tax you forgot you were paying. npx prettier on every Edit call spawns a fresh Node process and resolves prettier from disk each time. On a session with twenty edits that is twenty Node startups, six to eight seconds you do not see in the timer because they pipeline behind tool calls. The fix when latency starts to bite: switch to biome or dprint (single-binary, sub-100ms cold start), or move formatting to a Stop hook that runs once at the end of the turn over every file Claude touched.
When NOT to use a format-on-edit hook
- The codebase has no agreed-on formatter. A hook that reformats files in a repo where half the team uses two-space and half uses tabs starts a war you do not want to fight on day one. Get the formatter agreement first; the hook second.
- The setting belongs in personal config, not project config. Hooks in
.claude/settings.jsonrun for every contributor who clones the repo. Formatting is not personal preference, it is project policy, so commit it. If you find yourself wanting to scope it per-developer, you are using the wrong file (you want.claude/settings.local.json, which is gitignored). - Your formatter is slow. Adding two seconds to every Edit/Write call adds up to minutes of lost throughput per session. The matcher only filters tool names (no path globs), so do extension filtering inside the script. Better, switch to a faster formatter (
biome,dprint), or move formatting to aStophook that runs once per turn over every file Claude touched. - You want eslint, not prettier. Eslint autofix rewrites code semantically, not just stylistically. Running it in a hook lets it reorder imports, change
consttolet, and otherwise edit code Claude will then re-read and not recognize. Use eslint as a checker in CI, not as a hook-driven writer.