Adrienne Vermorel
Automating the Boring Parts: Hooks and Custom Commands for Analytics Engineers
There’s a moment in every dbt project where you catch yourself typing the same command for the fifteenth time that day. dbt build --select +my_model. Tab to the terminal. Wait. Check the output. Run sqlfluff. Fix the formatting. Run again. None of this is hard, but it adds up.
I’ve been using Claude Code’s hook system and custom slash commands for a few weeks now, and they’ve made the daily workflow noticeably smoother. The repetitive bits happen automatically. The dangerous commands get caught before they run.
This guide is for analytics engineers who are already comfortable with dbt and want to remove some of that friction. We’ll look at how hooks can automate formatting and validation, and how custom slash commands can turn multi-step workflows into single invocations.
What are Claude Code hooks?
If you’ve used git hooks, the concept is similar. Claude Code hooks are scripts that fire automatically at specific moments: before a tool runs, after it completes, when a session starts, when Claude finishes responding. You configure them once, and they work in the background.
The hooks that matter most for dbt work:
PreToolUse fires before Claude executes something. This is your safety net. You can inspect the command, check if it’s about to do something dangerous, and block it if needed. You can use this to prevent accidental --full-refresh on production.
PostToolUse fires after a tool completes. Good for auto-formatting. Every time Claude edits a SQL file, my formatter runs automatically.
Stop fires when Claude finishes responding (the “end of turn”). This is where quality gates live. Run dbt compile to catch syntax errors. Lint the modified files. If something fails, Claude sees the error and can fix it immediately.
SessionStart fires when you begin a session. I use this to show context: what branch am I on, which models have I modified, did anything fail in the last run.
The configuration lives in .claude/settings.json (project-level, version-controlled) or ~/.claude.json (personal). The basic structure:
{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": ".claude/hooks/format-sql.sh" } ] } ] }}The matcher filters which tools trigger the hook. Edit|Write means “only fire when Claude edits or creates files.” You can get more specific: Bash(dbt:*) matches only bash commands starting with dbt.
Auto-formatting SQL
This is the hook I’d install first. It’s simple, it’s fast, and you’ll notice the benefit immediately.
Every time Claude writes or edits a SQL file, this hook runs sqlfluff (or whatever formatter you prefer) automatically. No more context-switching to format, no more inconsistent style in PRs.
Create .claude/hooks/format-sql.sh:
#!/usr/bin/env bashset -euo pipefail
# The hook receives JSON on stdin with details about what just happenedinput=$(cat)file_path=$(echo "$input" | jq -r '.tool_input.file_path // empty')
# Only process SQL filesif [[ "$file_path" == *.sql ]]; then sqlfluff fix "$file_path" --dialect bigquery --force 2>/dev/null || true echo "✨ Formatted: $file_path" >&2fiAnd the configuration:
{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": ".claude/hooks/format-sql.sh" } ] } ] }}Now formatting happens on every file change. One less thing to remember.
Blocking dangerous commands
We’ve all had that moment. You meant to run against dev, but the command went to production. Or you accidentally triggered a full refresh on a table that takes four hours to rebuild.
PreToolUse hooks let you catch these before they happen. The script inspects the command, and if it looks dangerous, it blocks execution with exit code 2.
Create .claude/hooks/dbt-safety.sh:
#!/usr/bin/env bashset -euo pipefail
input=$(cat)command=$(echo "$input" | jq -r '.tool_input.command // empty')
# Is this a dbt command targeting production?if echo "$command" | grep -q "dbt" && echo "$command" | grep -q "\-\-target.*prod"; then
# Block full-refresh on production if echo "$command" | grep -q "\-\-full-refresh"; then echo "🛑 Blocked: --full-refresh on production. Run this manually if you're sure." >&2 exit 2 fi
# Block run/build without explicit selectors if echo "$command" | grep -qE "dbt (run|build)" && ! echo "$command" | grep -qE "\-\-(select|models)"; then echo "🛑 Blocked: dbt run on production needs explicit --select" >&2 exit 2 fifi
exit 0Exit code 2 blocks the action and shows your error message to Claude. Exit code 0 lets things proceed. Other exit codes log warnings but don’t block.
{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": ".claude/hooks/dbt-safety.sh" } ] } ] }}Quality gates at the end of each turn
The Stop hook runs checks after all the changes are done. Think of it as a mini-CI that runs locally, immediately.
My Stop hook compiles modified models to catch syntax errors. If compilation fails, Claude sees the error right away and can fix it.
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" >&2fi{ "hooks": { "Stop": [ { "matcher": "", "hooks": [ { "type": "command", "command": ".claude/hooks/dbt-quality-check.sh" } ] } ] }}The empty matcher means “fire on every stop, regardless of what tools were used.” You could make this more specific if you only want it after file edits.
Loading context at session start
SessionStart hooks run once when you begin a Claude Code session. Mine reminds me where I am in the project.
Create .claude/hooks/dbt-context.sh:
#!/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 "" >&2Small thing, but useful when switching between projects.
Custom slash commands
Hooks fire automatically. Slash commands are the opposite: workflows you invoke on demand with /command-name. Think of them as saved prompts that can also execute bash and read files.
Commands are Markdown files. Put them in .claude/commands/ for project-specific commands (version-controlled, shared with your team) or ~/.claude/commands/ for personal ones.
Build a model with its upstream dependencies
Instead of typing dbt build --select +stg_orders, I type /project:dbt-build +stg_orders.
Create .claude/commands/dbt-build.md:
---allowed-tools: Bash(dbt:*), Bash(git:*)argument-hint: [+]model_namedescription: Build a dbt model (prefix with + for upstream deps)---
## What we're working with
Current branch: !`git branch --show-current`
Recently modified:!`git diff --name-only HEAD | grep "\.sql$" | head -5`
## Your task
Build the model: **$ARGUMENTS**
Run `dbt build --select $ARGUMENTS` and let me know:- Did it succeed?- How long did it take?- If tests failed, what went wrong and how should I fix it?The ! prefix runs bash and includes the output as context. $ARGUMENTS captures whatever you type after the command name. The frontmatter specifies which tools the command can use.
Generate a base model from a source
When I’m adding a new source table, I want the base model to follow our conventions automatically. This command reads existing base models to understand the patterns, then generates a new one.
Create .claude/commands/dbt-base.md:
---allowed-tools: Bash(dbt:*), Read, Write, Globargument-hint: source_name table_namedescription: Generate a base model following project conventions---
## Our conventions
Here's how our existing base models look:!`ls models/base/base__*.sql | head -3 | xargs head -30`
## Your task
Create a base model for source **$1**, table **$2**.
1. Create `models/base/base__$1__$2.sql` with: - A source CTE pulling from `{{ source('$1', '$2') }}` - A renamed CTE with clean snake_case column names - Appropriate type casting (especially for dates)
1. Add the model to `models/base/_base__$1__models.yml` with: - A clear description - Primary key uniqueness + not_null tests - Not-null tests on required columns
1. Run `dbt compile --select base__$1__$2` to verify it worksUsage: /project:dbt-base ga4 events
Build only what you’ve changed
When you’re iterating on a feature branch, you usually just want to build the models you’ve touched.
Create .claude/commands/dbt-modified.md:
---allowed-tools: Bash(dbt:*), Bash(git:*)description: Build only the models modified in this branch---
## What's changed
Models modified vs main:!`git diff --name-only origin/main...HEAD | grep "models/.*\.sql$" || echo "No model changes found"`
## Your task
Build only the modified models using `dbt build --select state:modified`.
If anything fails, tell me what went wrong and suggest a fix.Audit a model for best practices
This command checks structure, documentation, tests, and dependencies.
Create .claude/commands/dbt-audit.md:
---allowed-tools: Read, Glob, Bash(dbt:*)argument-hint: model_namedescription: Audit a model for dbt best practices---
## Your task
Audit **$ARGUMENTS** and tell me if anything looks off.
Check:1. **Code quality**: Uses CTEs (not subqueries), has comments where logic is complex, follows naming conventions2. **Documentation**: Model is described in YAML, key columns are documented3. **Testing**: Has primary key tests (unique + not_null), critical columns are tested4. **Dependencies**: Run `dbt ls --select +$ARGUMENTS` — is it referencing base models or raw tables directly?
Be specific. If something's missing, show me exactly what to add.Clean up your dev environment
Create .claude/commands/dbt-cleanup.md:
---allowed-tools: Bash(dbt:*), Bash(rm:*)description: Clean up dbt development artifacts---
## Your task
Clean up the local environment:
1. Remove `target/` (compiled artifacts)2. Remove `dbt_packages/` (installed packages)3. Run `dbt clean`4. Run `dbt deps` to reinstall packages fresh
Tell me how much space was freed up.Connecting the dbt MCP server
This part is optional but useful. The official dbt MCP server gives Claude Code awareness of your project structure (models, sources, tests, lineage) without you needing to explain it every time.
Install it:
claude mcp add dbt -s project -- uvx dbt-mcpConfigure in .claude/settings.json:
{ "mcpServers": { "dbt": { "command": "uvx", "args": ["dbt-mcp"], "env": { "DBT_PROJECT_DIR": "/path/to/your/dbt/project", "DBT_PATH": "/usr/local/bin/dbt" } } }}With the MCP server running, Claude can list your models, understand dependencies, and execute dbt commands with full project context.
Putting it together
A practical example of how hooks and commands work together:
You’re adding a new base model for a GA4 source table. You run /project:dbt-base ga4 events. Claude creates the model file and YAML entry. The PostToolUse hook formats the SQL. Claude runs compile to verify. The Stop hook catches any syntax errors before Claude finishes. You iterate with normal conversation. Every edit gets auto-formatted, every turn gets validated.
No manual formatting. No forgotten compile checks.
A few practical notes
Keep hooks fast. PostToolUse fires on every file change. If your formatter takes 3 seconds, that lag becomes noticeable. Save slow operations for Stop hooks.
Exit codes work differently than you’d expect. In most Unix tools, any non-zero exit code means “something went wrong.” Claude Code hooks are pickier:
- Exit 0 → Success, the action proceeds normally
- Exit 2 → Block the action and show your error message to Claude
- Exit 1, 3, or any other code → Log a warning, but the action still proceeds
So if you write a PreToolUse hook that exits with code 1 when it detects a problem, the command will still run — Claude just sees a warning in the logs. Only exit code 2 actually stops things.
This tripped me up at first. I had a safety hook exiting with code 1 on dangerous commands, wondering why they kept running anyway.
Matchers are case-sensitive. bash won’t match Bash. Check the exact tool names in Claude’s output.
Version control your hooks. Put .claude/settings.json and .claude/hooks/ in git. When you onboard someone new, they get all your automation for free.
Debug with --debug. Run claude --debug to see exactly when hooks fire and what they return.
Where to start
Don’t try to implement everything at once. Pick the one thing that annoys you most.
For most people, that’s formatting. Install the PostToolUse formatter hook, use it for a week, and see how it feels. Once that’s working, add a Stop hook for compilation checks. Then start building slash commands for your most-repeated workflows.
The goal is to remove the bits that interrupt your thinking. When you don’t have to remember to format, or worry about breaking production, or type the same build command repeatedly, you can focus on the actual analytics work.