Data Engineering  /  dbt

๐Ÿ”„ dbt โ€” Data Build Tool 60 guides ยท updated 2026

Analytics engineering with SQL โ€” models, tests, sources, and Jinja macros that turn raw warehouse tables into trustworthy, documented data products.

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:

HookScopeRuns
pre-hookSingle modelImmediately before that model builds
post-hookSingle modelImmediately after that model builds
on-run-startEntire invocationOnce, before any models run
on-run-endEntire invocationOnce, 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 revenue
from {{ 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.

dbt_project.yml
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:

dbt_project.yml
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 hook
1 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 exist

The 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

HookScopeCommon use
pre-hookSingle modelTruncating a table, acquiring a lock before rebuild
post-hookSingle modelGranting access, logging completion
on-run-startWhole invocationRecording that a pipeline run started
on-run-endWhole invocationRecording 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.