ServicesAboutNotesContact Get in touch →
EN FR
Note

dbt Production Safety Hooks

Using Claude Code PreToolUse hooks to block dangerous dbt commands before they execute — full-refresh on production, unscoped builds, and other high-risk operations

Planted
claude codedbtautomationdata quality

Claude Code Hooks can block dangerous dbt CLI commands before they execute. The existing hooks note covers blocking file edits to protected paths — this note covers blocking dangerous dbt CLI commands that target production, such as running against the wrong target, triggering --full-refresh on a long-rebuilding table, or running dbt build without a selector.

The key mechanism is a PreToolUse hook that inspects the command Claude is about to run. If it matches a dangerous pattern, the hook exits with code 2, which blocks execution and surfaces an error message to Claude. Exit 0 lets the command proceed. Any other exit code logs a warning but doesn’t block — a distinction that catches people off guard. See Claude Code Hooks for the full exit code behavior.

The Safety Script

Create .claude/hooks/dbt-safety.sh:

#!/usr/bin/env bash
set -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
fi
fi
exit 0

The hook reads the tool input from stdin as JSON, extracts the command string, and checks two patterns:

  1. --full-refresh on production — Always blocked. A full refresh rebuilds the table from scratch, dropping all existing data first. On a large production table, this means hours of rebuild time and potentially missing data while the rebuild runs. If you actually need a full refresh on prod, run it manually outside Claude Code.

  2. Unscoped dbt run or dbt build on production — Blocked unless --select or --models is specified. Without a selector, dbt runs everything in the project. On a production target, that’s a dangerous default.

Registering the Hook

The configuration goes in .claude/settings.json:

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/dbt-safety.sh"
}
]
}
]
}
}

The Bash matcher triggers the hook on every bash command Claude tries to run. The hook itself does the filtering — it only inspects commands that contain dbt and --target.*prod. Everything else passes through at exit 0.

Note that matchers are case-sensitive. bash won’t match Bash. Check the exact tool names in Claude’s output if your hooks aren’t firing.

Extending the Pattern

The basic script covers the two most common production mistakes, but you can extend it for your project’s specific risks:

Block dbt seed on production. Seeds replace table contents entirely. On production, this can overwrite lookup tables that other models depend on:

Terminal window
if echo "$command" | grep -qE "dbt seed" && echo "$command" | grep -q "\-\-target.*prod"; then
echo "Blocked: dbt seed on production. Seeds replace entire tables." >&2
exit 2
fi

Block dbt run-operation on production. Custom operations (like drop statements in macros) can be destructive:

Terminal window
if echo "$command" | grep -qE "dbt run-operation" && echo "$command" | grep -q "\-\-target.*prod"; then
echo "Blocked: dbt run-operation on production. Review the operation manually." >&2
exit 2
fi

Require --defer on production builds. If your workflow expects production builds to use --defer with a production manifest (so unchanged models aren’t rebuilt), you can enforce that:

Terminal window
if echo "$command" | grep -qE "dbt (run|build)" && echo "$command" | grep -q "\-\-target.*prod"; then
if ! echo "$command" | grep -q "\-\-defer"; then
echo "Blocked: production builds require --defer --state ./prod-manifest" >&2
exit 2
fi
fi

Why Not Just Use CLAUDE.md?

You could add “never run —full-refresh on production” to your CLAUDE.md. Claude would follow that instruction most of the time. But CLAUDE.md is probabilistic — a complex conversation, a long session, or a particularly persuasive prompt can cause instructions to be deprioritized.

Hooks are deterministic. The shell script either blocks the command (exit 2) or allows it (exit 0). There’s no room for LLM interpretation. For operations where the cost of a mistake is measured in hours of downtime or corrupted production data, deterministic enforcement is worth the setup overhead.

The practical approach: put the rule in both places. CLAUDE.md prevents Claude from trying the dangerous command in the first place. The hook catches it if Claude tries anyway. Defense in depth.

Version Control Your Safety Hooks

Put .claude/hooks/ and .claude/settings.json in git. When you onboard a new team member, they get all your production guardrails automatically. When someone on the team encounters a new dangerous pattern, they add a check to the shared safety script and everyone benefits on the next pull.

This pattern is an application of the layered review approach: catching dangerous operations at the earliest point in the workflow.