PreToolUse vs PostToolUse: Which Claude Code Hook to Use
7 min read · Reviewed 2026-06-12
PreToolUse and PostToolUse are the two most-used Claude Code hook events — and the difference between them decides whether you can prevent a bad action or only react to it.
This guide explains exactly what each event does, shows a complete blocking PreToolUse hook next to a complete PostToolUse formatter, walks through their differing stdin payloads, covers matcher syntax, and gives you a one-line rule for choosing.
What is the difference between PreToolUse and PostToolUse?
PreToolUse runs before a tool executes. It sees the tool input — the command, the file path, the URL — and it can block the action entirely by returning a block decision. Nothing happens until your hook says yes.
PostToolUse runs after the tool has already completed. It receives the tool’s result and can react — format the file, run a linter, log the command — but it cannot undo what just happened. By the time it runs, the change exists.
What does a PreToolUse hook look like?
A PreToolUse hook inspects tool_input and returns a block decision to stop the action. This one — adapted from the HookStack secret-detection hook — refuses any Bash command that looks like it contains an API key:
// .claude/hooks/pre-bash-secret-detection.mjs
import { readFileSync } from 'fs'
import { fileURLToPath } from 'url'
const SECRET_PATTERNS = [
/sk-(?:ant-|proj-)?[a-zA-Z0-9_-]{32,}/, // Anthropic / OpenAI keys
/ghp_[a-zA-Z0-9]{36}/, // GitHub PAT
/(?:password|secret|token)\s*=\s*['"][^'"]{6,}/i,
]
export function run(input) {
if (input.tool_name !== 'Bash') return null
const command = input.tool_input?.command ?? ''
const hit = SECRET_PATTERNS.find((p) => p.test(command))
return hit
? { decision: 'block', reason: 'Possible secret in the command. Move it to an env var before running.' }
: null
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const input = JSON.parse(readFileSync(0, 'utf8'))
const result = run(input)
if (result) process.stdout.write(JSON.stringify(result))
}A PreToolUse hook blocks by writing { "decision": "block", "reason": "…" } to stdout. Always give an actionable reason — the model reads it and can correct course. (The runtime also supports a stricter hookSpecificOutput.permissionDecision: "deny" form; the top-level decision shown here is the convention HookStack ships.)
What does a PostToolUse hook look like?
A PostToolUse hook reacts to a completed change. This one lints a file right after it is written or edited, surfacing any problems on stderr so the next step can fix them. Note the extension filter — you do not want to launch Biome on a Markdown file:
// .claude/hooks/biome-check.mjs
import { readFileSync } from 'fs'
import { execSync } from 'child_process'
import { fileURLToPath } from 'url'
export function run(input, { exec = (c) => execSync(c, { stdio: 'pipe', timeout: 15_000 }) } = {}) {
const filePath = input.tool_input?.file_path ?? ''
if (!filePath || !/\.[cm]?[jt]sx?$/.test(filePath)) return null // only JS/TS files
try {
exec(`npx --no-install biome lint --error-on-warnings "${filePath}"`)
return null // clean
} catch (err) {
const output = err.stdout?.toString() ?? ''
return output ? { message: `Biome: ${output.trim()}\n` } : null
}
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const input = JSON.parse(readFileSync(0, 'utf8'))
const result = run(input)
if (result?.message) process.stderr.write(result.message)
}Because the change already landed, the hook’s job is to make problems visible, not to prevent them. It writes the lint output to stderr; Claude reads it and fixes the file on the next turn.
How do the stdin payloads differ?
Both events receive the common fields (session_id, cwd, hook_event_name, tool_name, tool_input). The difference is that PostToolUse adds a tool_response field with the result of the tool that just ran:
// PreToolUse — before the tool runs (no result yet)
{ "hook_event_name": "PreToolUse", "tool_name": "Write",
"tool_input": { "file_path": "src/api.ts", "content": "…" } }
// PostToolUse — after the tool ran (result included)
{ "hook_event_name": "PostToolUse", "tool_name": "Write",
"tool_input": { "file_path": "src/api.ts", "content": "…" },
"tool_response": "File written successfully" }That extra tool_response is exactly why PostToolUse can react to outcomes — it knows whether the write succeeded and what the tool returned.
How do matchers decide which tool a hook runs on?
The matcher on each hook group filters by tool name. The syntax is small:
"Bash"— exact match, runs only on Bash calls."Write|Edit"— pipe-separated list, runs on either Write or Edit."*"(or omitting the matcher) — runs on every tool.- Anything with a special character is treated as a JavaScript regex — e.g.
"mcp__memory__.*"matches every tool from thememoryMCP server.
So a secret-detection PreToolUse hook uses "Bash", while a formatter PostToolUse hook uses "Write|Edit" because both write to disk.
Are PostToolUse hooks really non-blocking?
Mostly yes — but be precise about why. By the time PostToolUse runs, the change already exists, so it cannot un-write a file. It can, however, still return a block decision that tells Claude to stop and reconsider. What makes HookStack’s PostToolUse hooks “non-blocking” is an implementation convention, not a runtime guarantee: they wrap external tools in a silent try/catch so a missing binary (Biome not installed, for example) just exits quietly instead of breaking your session. If you write your own PostToolUse hook that throws or returns a block decision, it will interrupt the flow.
A simple decision rule
- Need to prevent or validate an action? → PreToolUse.
- Need to react to a completed action? → PostToolUse.
The two compose well: a PreToolUse guard stops dangerous writes, while a PostToolUse formatter cleans up the writes that are allowed.
Frequently asked questions
- Is PreToolUse a blocking hook?
- Yes. PreToolUse runs before the tool and can block it by returning { "decision": "block", "reason": "…" } on stdout. PostToolUse runs after the change has landed, so it cannot prevent it.
- What is the difference in the stdin payload?
- Both get tool_name and tool_input. PostToolUse additionally receives tool_response — the result of the tool that just ran — which is why it can react to outcomes.
- Are PostToolUse hooks always non-blocking?
- Not inherently. HookStack PostToolUse hooks exit quietly on missing tools by convention (silent try/catch), but the runtime still lets a PostToolUse hook return a block decision. The non-blocking behavior is a design choice, not a hard rule.
- Can I use both events together?
- Yes, and it is a common combination — PreToolUse to guard, PostToolUse to format and check.
Related hooks
- Destructive command blockingStops a disk-wiping shell command before it runs
- Secret detection before Bash executionCatch a leaked API key before it ever runs
- Biome lint after writeLint errors fixed in the same loop, not in CI
- Typecheck after parallel file editsOne tsc pass after a batch, not one per file
Sources
Read next
- What Are Claude Code Hooks? A Practical GuideClaude Code hooks are commands that run automatically at lifecycle events. See the lifecycle, a complete working hook, and the settings.json that wires it up.
- How to Secure Claude Code with HooksSecure Claude Code with deterministic hooks: block destructive commands, prevent secrets leaking, protect .env, and guard the main branch every run.
- Claude Code Hooks Not Working? A Troubleshooting GuideClaude Code hook not firing, output ignored, or breaking your session? Diagnose the common causes — bad matcher, invalid JSON, wrong exit code, paths — fix each.
- Write Your First Claude Code Hook in 5 MinutesA step-by-step tutorial: create the hooks folder, write a Bash command logger, register it in settings.json, test it, and watch it run. No deps beyond Node.
- Automate Code Quality in Claude Code with HooksAutomate code quality in Claude Code with hooks: run formatters, lint on write, typecheck after edits, and gate tests at stop. PostToolUse and Stop patterns.