dbt Hooks: Running SQL Before and After Your Models
Not everything a data pipeline needs to do fits neatly into โtransform this table into that table.โ Sometimes you need to grant read access to a newly created table, log that a run happened, or clean up a temporary object once a model finishes. dbt hooks exist exactly for this category of work โ SQL that needs to run around a model or a full invocation, not as the modelโs actual output.
The Four Hook Types
dbt gives you two pairs of hooks, scoped at different levels:
| Hook | Scope | Runs |
|---|---|---|
pre-hook | Single model | Immediately before that model builds |
post-hook | Single model | Immediately after that model builds |
on-run-start | Entire invocation | Once, before any models run |
on-run-end | Entire invocation | Once, after all models finish |
Model-Level Hooks: pre-hook and post-hook
The most common use of post-hook is granting access on a table right after dbt creates it โ necessary because the table often doesnโt exist yet when the run starts, so permissions canโt be granted ahead of time.
-- models/marts/sales_summary.sql{{ config( post_hook="grant select on {{ this }} to role analyst_role" )}}
select region, date_trunc('month', order_date) as month, sum(amount) as revenuefrom {{ ref('customer_orders') }}group by 1, 2{{ this }} inside the hook refers to the model currently being built โ in this case, sales_summary itself, resolved to its fully-qualified name automatically.
Multiple hooks on the same model are supported by passing a list:
{{ config( post_hook=[ "grant select on {{ this }} to role analyst_role", "grant select on {{ this }} to role bi_service_account" ] )}}pre-hook works identically but runs before the modelโs SQL executes โ a common use is truncating a staging table thatโs about to be fully rebuilt, or acquiring an application-level lock in warehouses that support it.
{{ config(pre_hook="delete from {{ this }} where batch_id = '{{ var(\"batch_id\") }}'") }}
select * from {{ source('raw', 'daily_batch') }}where batch_id = '{{ var("batch_id") }}'Invocation-Level Hooks: on-run-start and on-run-end
These live in dbt_project.yml rather than an individual model, because they apply to the whole dbt run (or dbt build), not to any single table.
on-run-start: - "insert into audit.dbt_run_log (run_id, started_at) values ('{{ invocation_id }}', current_timestamp)"
on-run-end: - "update audit.dbt_run_log set finished_at = current_timestamp, status = '{{ 'success' if results | selectattr('status', 'ne', 'success') | list | length == 0 else 'failure' }}' where run_id = '{{ invocation_id }}'"{{ invocation_id }} is a built-in dbt variable โ a unique identifier for the current run, useful for tying audit log entries back to a specific execution.
Applying Hooks Project-Wide Instead of Per-Model
Repeating the same post-hook in every modelโs config is exactly the kind of duplication macros and project-level config exist to eliminate. Grants, in particular, are almost always applied at the folder level instead:
models: my_project: marts: +post-hook: - "grant select on {{ this }} to role analyst_role"Every model under models/marts/ picks up this hook automatically โ a new mart model gets the correct grants without anyone remembering to add config to that specific file.
Hooks Calling Macros Instead of Raw SQL
Once hook logic gets non-trivial, itโs cleaner to call a macro than to inline a long SQL string in YAML or a config() block.
-- macros/grant_select.sql{% macro grant_select(role_name) %} grant select on {{ this }} to role {{ role_name }}{% endmacro %}models: my_project: marts: +post-hook: - "{{ grant_select('analyst_role') }}"This keeps the actual grant logic in one macro file, callable and version-controlled, rather than duplicated as raw SQL strings across config blocks.
Debugging a Failing Hook
A hook that fails is easy to misdiagnose because the error can look like it came from the model itself. dbtโs console output labels which phase failed:
Running 1 on-run-start hook1 of 1 START hook: my_project.on-run-start.0 ......... [RUN]1 of 1 ERROR hook: my_project.on-run-start.0 .......... [ERROR in 0.31s]
Database Error in hook (models/dbt_project.yml) relation "audit.dbt_run_log" does not existThe key detail is hook: in the log line, which tells you the failure happened in a hook, not in the modelโs own transformation SQL โ worth checking first before assuming the model logic itself is broken.
Common Mistakes
Using post-hook for logic that belongs in the model. If a post-hook is doing meaningful data transformation rather than a side effect like grants or logging, that logic usually belongs in the modelโs own SQL or in a downstream model instead โ hooks that quietly mutate data are hard to trace through the lineage graph.
Forgetting hooks run every single time. A post-hook runs on every execution of that model, including every incremental run. A grant statement is idempotent and safe to repeat; a hook that inserts an audit row on every run needs to account for that if youโre trying to count runs accurately.
Not testing hooks in CI. A hook referencing a table that doesnโt exist yet in a fresh CI database will fail the entire run. If hooks reference audit or permission tables, make sure those exist in every environment the pipeline runs in, including ephemeral CI databases.
Summary
| Hook | Scope | Common use |
|---|---|---|
pre-hook | Single model | Truncating a table, acquiring a lock before rebuild |
post-hook | Single model | Granting access, logging completion |
on-run-start | Whole invocation | Recording that a pipeline run started |
on-run-end | Whole invocation | Recording final run status, cleanup |
Hooks are the escape hatch for the operational side effects that donโt fit into โthis model produces this tableโ โ grants, audit trails, cleanup โ without forcing that logic into the transformation SQL itself.