Writing AI workflows & prompting
Claude Code hooks for analytics repos
How Claude Code hooks work, from events and matchers to exit codes and blocking, and five hooks for a data repository that lint Python and SQL, run fast data tests, protect generated and production files, keep secrets unread and log every command.
A rule in CLAUDE.md is advice: Claude reads it and usually follows it. A hook is a command that Claude Code itself runs at a fixed point, whatever the model decides. For the checks that must happen every time in a data repository, such as formatting, tests, and staying out of production config, that difference is the whole point.
This article covers how hooks work and a set of five for an analytics repo, with the scripts to download. It follows the hooks reference and the hooks guide at the time of writing.
How hooks work
A hook has three levels: an event (a point in Claude Code’s lifecycle), a matcher that filters when it fires, and one or more handlers that run. Most handlers are shell commands; hooks can also call an HTTP endpoint, an MCP tool, a single model prompt or a subagent. A command handler gets the event as JSON on stdin and answers with its exit code, stderr and, optionally, JSON on stdout.
The events that matter for data work
Claude Code has more than thirty events. These are the ones I’d reach for first:
| Event | Fires | Can block? |
|---|---|---|
SessionStart |
When a session starts or resumes, and after /clear or compaction |
No; its stdout is added to Claude’s context |
UserPromptSubmit |
When you submit a prompt, before Claude sees it | Yes |
PreToolUse |
After Claude chooses a tool and its arguments, before the call runs | Yes |
PermissionRequest |
When Claude Code is about to ask you for permission | Through JSON only |
PostToolUse |
After a tool call succeeds | No; the tool has already run, so it can only give Claude feedback |
Stop |
When Claude finishes responding, but not when you interrupt it | Yes: keeps Claude working |
Notification |
When Claude needs your input or permission | No |
SessionEnd |
When the session ends | No |
Where hooks live
Hooks go under a hooks key in a settings file: ~/.claude/settings.json for all your projects, .claude/settings.json for a project and everyone who clones it, or .claude/settings.local.json for you in one project. Organisations can set them in managed policy, and plugins, skills and subagents can carry their own. Hooks from different files merge rather than replace each other. Type /hooks in a session to see everything that’s registered and where it came from.
Matchers
For tool events, the matcher is tested against the tool name. A matcher made only of letters, digits, _, -, spaces, commas and | is an exact name or a list of names, so Edit|Write matches exactly those two tools. Anything else is treated as an unanchored regular expression: mcp__warehouse-dev__.* matches every tool from one MCP server, and Edit.* would also catch NotebookEdit. Matchers are case-sensitive, and an empty or missing matcher fires on everything. Some events, including Stop and UserPromptSubmit, take no matcher at all.
A handler’s if field narrows further using permission-rule syntax, such as "if": "Bash(git *)". The documentation calls this filter best-effort, so don’t rely on it alone to enforce anything.
Exit codes and blocking
| Exit code | Meaning |
|---|---|
| 0 | No objection. For PreToolUse this is not an approval: the normal permission checks still run |
| 2 | Block, on events that can block. Stderr becomes the message, and for PreToolUse, PostToolUse and Stop it goes to Claude, so write it as an instruction |
| Anything else | A non-blocking error. The action goes ahead and you see a hook error notice |
The third row catches people out: exit code 1 does not block. A policy hook that fails with the usual Unix error code lets the action through. So does a shell-form hook whose script path is mistyped, since the shell exits 127, and a PreToolUse command hook that times out. Watch for the hook error notice the first time a new gate runs. The exec-form handlers below fail the other way: python3 exits 2 when it can’t open the script, so a mistyped path blocks every matching call until you fix it.
For finer control, exit 0 and print JSON instead. A PreToolUse hook can return permissionDecision as "deny", "ask" or "allow"; PostToolUse and Stop use "decision": "block" with a reason. PreToolUse hooks run before the permission mode is checked, so a deny holds even in bypassPermissions mode. The reverse isn’t true: a hook’s "allow" can skip a prompt, but it can’t get past a deny rule in settings.
Five hooks for a data repository
Here is the whole configuration, for .claude/settings.json, with the scripts in .claude/hooks/. Download settings.json and the four scripts: block_protected_edits.py, block_secret_reads.py, lint_changed_file.py and data_tests_on_stop.py. They use only Python’s standard library; the logging hook needs jq. The lint and test hooks run ruff, sqlfluff and pytest through uv run, so add those as dev dependencies of the project.
{
"permissions": {
"deny": [
"Read(.env)",
"Read(.env.*)",
"Read(!.env.example)",
"Read(secrets/**)",
"Edit(/config/prod/**)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/block_protected_edits.py"]
}
]
},
{
"matcher": "Read",
"hooks": [
{
"type": "command",
"command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/block_secret_reads.py"]
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -c '{ts: (now | todate), session: .session_id, cwd: .cwd, command: .tool_input.command}' >> ~/.claude/bash-commands.jsonl"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/lint_changed_file.py"],
"timeout": 60
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/data_tests_on_stop.py"],
"timeout": 300
}
]
}
]
}
}
The handlers with args run in exec form: Claude Code starts python3 directly with the script path as one argument, with no shell in between, which the reference recommends for any hook that uses a path placeholder such as ${CLAUDE_PROJECT_DIR}. The logging hook has no args, so it runs through a shell and can use a pipe and >>.
1. Format and lint after every edit
lint_changed_file.py runs after each Edit or Write. For a Python file it runs ruff check --fix and ruff format, then ruff check again. For a SQL file it runs sqlfluff fix then sqlfluff lint, but only when the repo has a .sqlfluff file, because sqlfluff needs to be told the SQL dialect. Whatever can be fixed is fixed quietly. If problems remain, the script prints them to stderr and exits 2, so Claude sees something like this and deals with it in the same turn:
Lint problems left in bad.py after auto-fix:
F821 Undefined name `y`
--> pipeline/bad.py:2:16
|
1 | def f(x):
2 | return x + y
| ^
PostToolUse can’t undo the edit, and it doesn’t need to. The point is feedback while the change is still in Claude’s hands.
2. Run the fast data tests before Claude stops
data_tests_on_stop.py runs a quick test command, uv run pytest -q -x tests/data, when Claude tries to finish. Since Stop fires after every response, the script first checks whether anything under sql/, pipeline/ or tests/ differs from the last commit, and whether that exact state already passed earlier in the session. Most of the time it exits without running anything.
When the tests fail, it exits 2 and Claude carries on working with the failure output as its reason. The input’s stop_hook_active field is true when Claude is already continuing because of a stop hook. The script uses it to give Claude one retry, then lets the turn end and shows you a warning instead of looping. Claude Code has its own backstop too: it overrides a Stop hook that blocks eight times in a row.
Keep this suite to tests that run in seconds, on small fixtures with known answers. Data tests that catch real problems and Testing analytics code with pytest cover what to put in it. For slower suites, a PostToolUse hook with "async": true runs in the background and hands its result to Claude on the next turn.
3. Block edits to generated folders and production config
The hard control here is the deny rule Edit(/config/prod/**). Permission rules are enforced by Claude Code, and Edit rules cover every built-in editing tool. Read and Edit deny rules also cover file commands Claude Code recognises in Bash, such as sed and tee, and redirection targets. A hook on Edit|Write sees none of those shell commands.
The hook earns its place by explaining. A permission denial tells Claude no; this script tells it what to do instead:
PROTECTED = {
"/generated/": "Files here are written by the build. Change the source and rerun the build.",
"/config/prod/": "This is production configuration. Describe the change in your reply; "
"a person makes it through a reviewed pull request.",
}
def normalise(path: str) -> str:
"""Forward slashes, '..' resolved, with a leading and trailing slash for segment matching."""
return posixpath.normpath(path.replace("\\", "/")).rstrip("/") + "/"
File paths reach hooks as absolute paths, with backslashes on Windows. The reference’s advice is to normalise separators and match a path segment rather than anchor at the start, which is what normalise and the "/generated/" keys do.
4. Keep secrets files unread
The deny rules Read(.env), Read(.env.*) and Read(secrets/**) are the main control, and Read(!.env.example) carves the harmless template back out. block_secret_reads.py adds a second layer on Read for key files and .ssh, with a message telling Claude to ask which environment variable holds a setting.
Know the limits. PreToolUse doesn’t fire for files you pull into a prompt with @, while Read deny rules make a best-effort attempt to cover those too. And neither a rule nor a hook stops a Python script from opening a file itself. The dependable fix is to keep real credentials out of the working directory; the documentation points to the sandbox for enforcement at the operating system level.
5. Log every command
The jq one-liner appends each Bash command to a JSON Lines file, one object per command with the time, session ID, working directory and the command itself. It’s on PreToolUse rather than PostToolUse so commands that are then denied are logged too, and it writes to ~/.claude/ so the log never lands in the repo. On Windows, match Bash|PowerShell: where the PowerShell tool is enabled, a hook that matches only Bash can miss shell commands. The same pattern with a matcher like mcp__warehouse-dev__.* records every query an MCP database server runs; MCP servers for data work covers that side.
Security
The reference is blunt about this:
Command hooks execute shell commands with your full user permissions. They can modify, delete, or access any files your user account can access. Review and test all hook commands before adding them to your configuration.
A hook in a project’s .claude/settings.json runs on the machine of everyone who works in the repo, so review changes to it like any other code. In an interactive session, Claude Code holds back hooks until you accept the workspace trust dialog for the folder. claude -p and SDK sessions never show that dialog and treat the folder as trusted, so hooks committed to a repository run straight away. Before you script claude -p over a repository you didn’t write, read its .claude/ folder, or start with --bare, or turn hooks off for that run with --settings '{"disableAllHooks": true}'.
The reference’s own checklist for writing hooks: validate and sanitise input, quote shell variables ("$VAR", not $VAR), check paths for .., use absolute paths for scripts, and skip sensitive files such as .env, .git/ and keys.
Testing a hook
Test each script by piping it the JSON it will receive, and check the exit code:
echo '{"tool_name":"Read","tool_input":{"file_path":"/repo/.env"}}' \
| python3 .claude/hooks/block_secret_reads.py; echo "exit $?"
Then open /hooks to confirm it’s registered under the right event, and for anything puzzling start Claude Code with claude --debug-file /tmp/claude.log, which records which hooks matched, their exit codes and their output.
For where hooks fit alongside memory files and permissions, see A Claude Code workflow for analysts and BI developers. For procedures that need judgement rather than enforcement, Skills for repeatable analysis in Claude Code is the other half of the picture.
Tags
- claude-code
- hooks
- ruff
- sqlfluff
- data-tests
- security