A dbt run --full-refresh on a large fact table can burn a month's warehouse budget in minutes. The irony: the expensive command is usually the one nobody watches. This workflow puts a human decision in front of every full refresh, backfill, and heavy model run — only when it's expensive, never in the happy path.
dbt full-refresh approval workflow
A Python guard that runs before dbt run:
- Inspects the command line for
--full-refresh,--select, and the model's cost tier - Skips the gate for cheap incremental runs (zero friction)
- Creates an Actionbox approval with warehouse-cost context for anything risky
- Blocks until a data engineer approves or rejects;
rejectfails the job without touching the warehouse
Step 1: the guard script
import os
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from actionbox import Actionbox
box = Actionbox(api_key=os.environ["ACTIONBOX_TOKEN"])
RISKY_FLAGS = ("--full-refresh", "--backfill", "--select", "--defer")
def run(args):
if not any(flag in args for flag in RISKY_FLAGS):
return # cheap path: no gate
decision = box.ask(
title=f"Run `dbt {' '.join(args)}` against {os.environ['DBT_TARGET']}?",
description=(
"Full refresh or selected build detected. Warehouse cost tier: "
f"{os.environ.get('COST_TIER', 'unknown')}. "
"This may take >30 minutes and consume significant compute."
),
interaction={"type": "boolean", "label": "Approve this dbt run?"},
)
if decision is not True:
print("dbt run rejected or expired")
sys.exit(1)
subprocess.run(["dbt", *args], check=True)
if __name__ == "__main__":
run(sys.argv[1:])Step 2: wire it into your scheduler
Every orchestrator can call this — Dagster, Airflow, Prefect, or plain cron. The decision blocks the process until a human answers (or it times out).
python scripts/guard_dbt.py run --full-refresh --select fct_ordersIf your pipeline runs in a container, pass the token in:
steps:
- name: Guard full refresh
env:
ACTIONBOX_TOKEN: ${{ secrets.ACTIONBOX_TOKEN }}
run: python scripts/guard_dbt.py run --full-refresh --select fct_ordersStep 3: context that makes approvers confident
A bare "approve this?" is ignored. Attach what an on-call data engineer actually needs:
decision = box.ask(
title="Full refresh of fct_orders?",
context=[{
"type": "key_value",
"items": {
"model": "fct_orders",
"last_refresh": "2026-07-20",
"row_count": "1.2B",
"estimated_cost": "$214",
"dry_run_passed": "true",
}}],
interaction={"type": "boolean", "label": "Proceed"},
)The dashboard renders this before the buttons — approvers decide in seconds, not meetings.
Timeout policy
Warehouse jobs are usually scheduled at night, and nobody is awake to approve. Set a deliberate timeout policy:
- Nightly batch: allow 60 minutes, then
on_expirereturnsexpired→ page the on-call engineer - Interactive runs: 15 minutes, reject on timeout — nobody leaves a $2K job waiting on a coin flip
decision = box.ask(
...,
expires_at=(datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),
on_expire={"type": "return_expired"},
)Why this pattern scales
The same guard works for any expensive data operation — not just dbt:
snowsqlbackfills with a row-count estimate- Spark jobs over a spend threshold
terraform applywith a resource diff summary (see the Terraform destroy approval gate)
Because the decision is an API call, not a Slack thread, it works from any scheduler, records who decided and why, and keeps a full audit trail for warehouse-cost review.
Try it
pip install actionboxin your dbt runner- Set
ACTIONBOX_TOKENfrom a Source you create in the dashboard - Run a
--full-refreshand watch the gate pause
Create a free Source · Python SDK · GitHub Actions deployment gate