The Claude Code Hooks note covers PreToolUse and PostToolUse — hooks that fire before and after each individual tool action. These handle per-operation concerns: formatting a SQL file after an edit, blocking a dangerous command before it runs.
Two other hook lifecycle points serve different purposes. Stop fires when Claude finishes responding — after all the edits, commands, and reasoning are done for that turn. SessionStart fires once when a Claude Code session begins. Together, they handle end-of-turn quality validation and session initialization.
Stop Hooks: Quality Gates After Each Turn
The Stop hook runs after Claude’s entire response is complete. Think of it as a mini-CI that runs locally, immediately after every turn. This is the right place for checks that are too slow or too broad to run after every individual file edit but too important to skip entirely.
The most practical Stop hook for dbt work: compile modified models to catch syntax errors before Claude moves on.
Create .claude/hooks/dbt-quality-check.sh:
#!/usr/bin/env bashset -euo pipefail
# Check if any SQL files were modifiedmodified_sql=$(git diff --name-only HEAD 2>/dev/null | grep "\.sql$" || true)
if [[ -n "$modified_sql" ]]; then echo "Checking modified models..." >&2
# Compile to catch syntax errors if ! dbt compile --select state:modified 2>&1; then echo "Compilation failed — check the errors above" >&2 exit 2 # Block so Claude can see and fix the issue fi
# Lint (warning only, don't block) sqlfluff lint $modified_sql --dialect bigquery 2>&1 || true
echo "All checks passed" >&2fiRegister it with an empty matcher, which means “fire on every stop, regardless of what tools were used”:
{ "hooks": { "Stop": [ { "matcher": "", "hooks": [ { "type": "command", "command": ".claude/hooks/dbt-quality-check.sh" } ] } ] }}When the hook exits with code 2 (compilation failed), Claude sees the error immediately and can fix it in the next turn. When it exits with code 0 (all checks passed), Claude proceeds normally. The sqlfluff lint runs as a warning — it doesn’t block, but Claude sees any style issues in the output.
Why Stop Instead of PostToolUse?
You could run dbt compile as a PostToolUse hook after every file edit. The problem is speed. dbt compile --select state:modified takes a few seconds even on a small project. If Claude edits five SQL files in a single turn, that’s five compilation runs — each one adding latency to Claude’s workflow.
The Stop hook runs once after all edits are done. One compilation check covers all the changes from the entire turn. The tradeoff: you don’t catch errors between individual edits within the same turn. But Claude will see the errors at the end of the turn and fix them, which is usually good enough.
The rule of thumb: fast checks go in PostToolUse, slow checks go in Stop. Auto-formatting a single file with sqlfluff is fast — put it in PostToolUse. Compiling the dbt project is slower — put it in Stop.
Extending the Quality Gate
The basic compilation check is a starting point. You can layer additional checks:
Run dbt tests on modified models to catch data issues, not just syntax errors:
if [[ -n "$modified_sql" ]]; then dbt test --select state:modified 2>&1 || truefiKeep this as a warning (not a block) since test failures on development data might be expected while iterating.
Check for missing documentation when new models are created:
new_models=$(git diff --name-only HEAD 2>/dev/null | grep "models/.*\.sql$" | while read f; do model=$(basename "$f" .sql) grep -rq "$model" models/ --include='*.yml' || echo "$f"done)
if [[ -n "$new_models" ]]; then echo "Warning: new models without YAML documentation:" >&2 echo "$new_models" >&2fiSessionStart Hooks: Loading Context
SessionStart hooks run once when you begin a Claude Code session. Their purpose is different from Stop hooks — they’re not enforcing quality, they’re providing context.
A SessionStart hook for dbt projects can show you where you left off:
#!/usr/bin/env bash
echo "" >&2echo "dbt project context" >&2echo "──────────────────────" >&2
# Current branchbranch=$(git branch --show-current 2>/dev/null || echo "unknown")echo "Branch: $branch" >&2
# Modified modelsmodified=$(git diff --name-only HEAD 2>/dev/null | grep "models/.*\.sql$" | wc -l | tr -d ' ')echo "Modified models: $modified" >&2
# Last run statusif [[ -f "target/run_results.json" ]]; then failures=$(jq '[.results[] | select(.status == "error")] | length' target/run_results.json 2>/dev/null || echo "?") echo "Last run failures: $failures" >&2fi
echo "──────────────────────" >&2echo "" >&2The output goes to stderr, which Claude sees as context at the start of the conversation. It’s a small thing, but useful when switching between projects or picking up work the next morning.
Practical SessionStart Ideas
Show open PRs for the current branch:
pr_count=$(gh pr list --head "$(git branch --show-current)" --json number --jq 'length' 2>/dev/null || echo "?")echo "Open PRs for this branch: $pr_count" >&2Show which dbt packages are outdated:
if [[ -f "packages.yml" ]]; then echo "Installed packages:" >&2 dbt deps --dry-run 2>&1 | grep -i "update" || echo "All packages up to date" >&2fiShow recent CI status:
last_run=$(gh run list --limit 1 --json status,conclusion --jq '.[0] | "\(.status): \(.conclusion // "in progress")"' 2>/dev/null || echo "unknown")echo "Last CI run: $last_run" >&2Keep SessionStart hooks fast. They run at the beginning of every session, and a slow hook means waiting several seconds before you can start working. If a check takes more than a second or two, it probably doesn’t belong in SessionStart.
Combining All Four Hook Types
A complete .claude/settings.json for a dbt project might use all four lifecycle hooks together:
{ "hooks": { "SessionStart": [ { "matcher": "", "hooks": [ { "type": "command", "command": ".claude/hooks/dbt-context.sh" } ] } ], "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": ".claude/hooks/dbt-safety.sh" } ] } ], "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": ".claude/hooks/format-sql.sh" } ] } ], "Stop": [ { "matcher": "", "hooks": [ { "type": "command", "command": ".claude/hooks/dbt-quality-check.sh" } ] } ] }}Each hook type handles a different concern:
| Hook | When | Purpose | Speed Requirement |
|---|---|---|---|
| SessionStart | Session begins | Load context | Fast (runs once) |
| PreToolUse | Before each action | Block dangerous operations | Fast (runs per-tool) |
| PostToolUse | After each action | Auto-format, lint | Fast (runs per-tool) |
| Stop | End of turn | Compile, test, validate | Can be slower (runs once per turn) |
The progression is deliberate: SessionStart orients you, PreToolUse prevents mistakes, PostToolUse cleans up, and Stop validates the whole turn. Each layer catches what the others miss.