Hooks and Operations
What a dbt hook actually is, the four hook types and their exact config syntax, the classic post-hook grant pattern for keeping BI tools from silently losing access after every table rebuild, run-operation for standalone maintenance macros, and how hooks differ from on-demand macro invocation.
A Hook Is SQL That Runs at a Specific Point Around a Model's Execution
Every dbt model, when it runs, executes as a specific sequence of SQL statements against your warehouse — a CREATE OR REPLACE of some kind, wrapping the SELECT you wrote. A hook is additional SQL you configure to run automatically at a specific point in that sequence, without touching the model's own SELECT statement at all. dbt supports hooks that fire immediately before a model builds, immediately after a model builds, once at the very start of an entire invocation, and once at the very end — four distinct attachment points, each solving a different class of problem.
The core idea is worth internalizing precisely: a hook is not a separate step you run manually. It is configuration attached to a model (or to the whole project) that tells dbt "also run this SQL, at this exact moment, every time." You write the hook once, and it fires automatically on every subsequent dbt run or dbt build, with zero action required from whoever triggers that run.
Why hooks exist at all: a model's own SELECT statement can only ever describe a query — it has no way to express "and also grant a role SELECT on this table afterward" or "and also log that this run started." Those are not transformations of data; they are side effects tied to the act of building. Hooks give you a place to put exactly that kind of SQL, without polluting the model file with statements that have nothing to do with the actual transformation logic.
Hooks are ordinary SQL, but they are also Jinja-aware, exactly like model files — you can use {{ this }}, ref(), variables, and macros inside a hook's SQL string. This is what makes hooks genuinely useful rather than just a place to paste static SQL: a hook can reference the specific model it is attached to, or call a reusable macro, rather than hardcoding a table name that would break the moment that table is renamed.
| Hook type | When it fires | Scope |
|---|---|---|
| pre-hook | Immediately before a model's build statement executes. | One model (or applied to several via project-level config). |
| post-hook | Immediately after a model's build statement executes successfully. | One model (or applied to several via project-level config). |
| on-run-start | Once, before any model in the invocation starts building. | The entire dbt run / dbt build invocation, project-wide. |
| on-run-end | Once, after every model in the invocation has finished (success or failure). | The entire dbt run / dbt build invocation, project-wide. |
Part 02 covers the exact configuration syntax for each of these four. Part 03 works through the single most common real-world hook use case in complete depth. Part 05 covers the alternative to a hook entirely — invoking a macro standalone, on demand, outside of any model's build.
pre-hook, post-hook, on-run-start, on-run-end — Exact Syntax
pre-hook — runs before a model builds
A pre-hook runs immediately before dbt executes the DDL that builds a model. A realistic use: temporarily disabling a constraint or dropping an index before a table rebuild that would otherwise conflict with it, then relying on the model's own build to recreate the table cleanly. It is configured either inline via config() at the top of a model file, or project-wide in dbt_project.yml.
{{
config(
pre_hook="alter table {{ this }} disable trigger orders_audit_trigger"
)
}}
select
order_id,
customer_id,
order_status,
order_total_cents
from {{ ref('stg_orders') }}Note that {{ this }} inside the hook string resolves to the current model's own fully qualified, compiled table name — the same name the model itself builds. This is what lets a hook reference "whatever table this model produces" without hardcoding a schema and table name that would silently go stale the moment the model is renamed or its target schema changes.
post-hook — runs after a model builds, the most common of the four
A post-hook runs immediately after a model's build statement completes successfully. This is, in practice, the hook type used constantly in real projects, because the single most common real-world need — re-granting SELECT permissions to a reporting role immediately after a table rebuild — has to happen after the new table exists, not before. Part 03 works through exactly why this specific use case is so common, in full depth.
{{
config(
post_hook="grant select on {{ this }} to role bi_reader"
)
}}
select
order_id,
customer_id,
order_status,
order_total_cents
from {{ ref('stg_orders') }}Both pre_hook and post_hook also accept a list, not just a single string, if more than one statement needs to run at that point — dbt executes them in the order given.
{{
config(
post_hook=[
"grant select on {{ this }} to role bi_reader",
"grant select on {{ this }} to role finance_analyst"
]
)
}}
select order_id, customer_id, order_status, order_total_cents
from {{ ref('stg_orders') }}on-run-start — runs once, project-wide, at the very beginning of an invocation
on-run-start is configured in dbt_project.yml, not in an individual model file, because it is not tied to any one model — it fires exactly once, before dbt starts building the first model of a dbt run or dbt build invocation, no matter how many models that invocation ultimately touches.
on-run-start:
- "insert into analytics.dbt_run_log (event, occurred_at) values ('run_started', current_timestamp())"on-run-end — runs once, project-wide, at the very end of an invocation
on-run-end is the mirror image, also configured in dbt_project.yml: it fires exactly once, after every model in the invocation has finished, whether that invocation succeeded or failed. A very common real use is audit logging — recording that a run completed and when — or maintaining a "last successful run" timestamp table that other systems can check to know how fresh the warehouse's dbt-built tables currently are.
on-run-end:
- "insert into analytics.dbt_last_run_status (run_completed_at, invocation_id) values (current_timestamp(), '{{ invocation_id }}')"on-run-end hooks also have access to a special schemas and results Jinja variable describing every model that was part of the run and whether each one succeeded — enough to build a hook that only logs a completion row when every single model actually succeeded, rather than unconditionally.
on-run-end:
- "{% for schema in schemas %}grant usage on schema {{ schema }} to role bi_reader;{% endfor %}"| Hook | Configured where | Fires how many times per invocation |
|---|---|---|
| pre-hook | config() block in a model file, or per-directory in dbt_project.yml | Once per model it is attached to, before that model builds. |
| post-hook | config() block in a model file, or per-directory in dbt_project.yml | Once per model it is attached to, after that model builds. |
| on-run-start | dbt_project.yml, top level | Exactly once, before the first model in the whole invocation builds. |
| on-run-end | dbt_project.yml, top level | Exactly once, after every model in the whole invocation has finished. |
materialized, pre_hook and post_hook can be set under the models: block in dbt_project.yml, applying to every model under a given directory path rather than requiring the same config() block to be copy-pasted into every individual model file. This is exactly how the grant pattern in Part 03 is usually applied in a real project — once, at the marts/ directory level, rather than repeated per model.Why CREATE OR REPLACE TABLE Silently Revokes Permissions — and the Fix
This is the single most common real-world reason a team reaches for a hook at all, so it is worth understanding the underlying mechanism precisely rather than just copying the pattern.
Every table materialization dbt builds runs some form of CREATE OR REPLACE TABLE against the warehouse. On many warehouses, including Snowflake, CREATE OR REPLACE does not modify the existing table in place — it drops the old object entirely and creates a brand-new object with the same name. A grant you issued against the old object — say, grant select on analytics.fct_orders to role bi_reader — was a permission attached to that specific, now-deleted object. The new object created by the next CREATE OR REPLACE is a different object as far as the warehouse's permission system is concerned, even though it has the identical name, and it starts with none of the grants the old one had.
-- Day 1: table is built, then manually granted
create or replace table analytics.fct_orders as (select ...);
grant select on analytics.fct_orders to role bi_reader;
-- BI tool queries fct_orders successfully all day
-- Day 2: dbt run rebuilds the same model
create or replace table analytics.fct_orders as (select ...);
-- ^ this DROPPED the Day 1 object and created a new one
-- the grant from Day 1 was attached to the OLD object -- it is gone
-- BI tool's next query fails with a permission error
-- nobody touched permissions on purpose -- the daily dbt run did this silentlyThis produces a genuinely confusing incident the first time a team hits it: nobody explicitly revoked anything, no one changed a role's permissions, and yet a BI tool that was working perfectly yesterday suddenly cannot read a table today. The actual cause is entirely mechanical — every single scheduled dbt run rebuilding that table is quietly wiping out the grant, and it will keep happening on every future run until something re-grants access after every rebuild, automatically, forever.
CREATE OR REPLACE in specific circumstances — but the safe, portable assumption for any table materialization is that a fresh rebuild may not carry forward previously granted privileges. Relying on grants surviving a rebuild is relying on undocumented, engine-specific behavior. A post-hook that re-grants unconditionally on every run is correct regardless of which warehouse you are on.The fix: a post-hook that re-grants on every single build
The fix is not to grant permissions once, manually, after the first build. It is to attach a post-hook to the model that re-issues the grant every time the model builds, so the grant is restored automatically within the same run that just dropped it — a BI tool never actually experiences a gap in access, because by the time dbt run finishes, the grant is already back in place.
{{
config(
materialized='table',
post_hook="grant select on {{ this }} to role bi_reader"
)
}}
select
order_id,
customer_id,
order_status,
order_total_cents,
order_placed_at
from {{ ref('stg_orders') }}{{ this }} is what makes this hook durable rather than fragile — it always resolves to whatever fully qualified name this specific model actually builds as, in whichever environment the run is happening in (dev, staging, prod). Hardcoding analytics.fct_orders directly into the hook string instead would silently grant permissions on the wrong table the moment this model is run in a different target schema, or would need to be manually updated if the model's name ever changes.
-- dbt compiles and runs, in order, for this one model:
create or replace table dbt_asil.fct_orders as (
select order_id, customer_id, order_status, order_total_cents, order_placed_at
from dbt_asil.stg_orders
);
-- ^ the model's own build statement -- this is what dropped and recreated the table
grant select on dbt_asil.fct_orders to role bi_reader;
-- ^ the post-hook, firing immediately after, in the same dbt invocation
-- by the time this run finishes, the grant is already restoredApplied once, at the directory level in dbt_project.yml, this same pattern protects every mart-level model in a project with a single block of configuration rather than a config() line copy-pasted into every mart file — and it means a brand-new mart model added six months from now automatically inherits the same protection with zero extra effort from whoever writes it.
models:
freshcart_analytics:
marts:
+post_hook: "grant select on {{ this }} to role bi_reader"stg_orders directly. Applying the grant hook at the marts/ directory level, rather than project-wide, means every model that a BI tool actually depends on gets automatically re-granted, without also granting a reporting role access to internal staging and intermediate models it was never meant to see.Multiple Roles, Conditional Hooks, and Why Incremental Models Change the Calculus
Real projects rarely have exactly one reporting role. A finance mart might need to grant SELECT to both a BI tool's service account and a finance analyst's role; a marketing mart to a completely different pair of roles. The list form of post_hook covered in Part 02 handles this directly.
{{
config(
materialized='table',
post_hook=[
"grant select on {{ this }} to role bi_reader",
"grant select on {{ this }} to role finance_analyst_reporting"
]
)
}}
select
order_id,
customer_id,
order_status,
order_total_cents
from {{ ref('stg_orders') }}It is worth being explicit about why incremental models change this calculus. An incremental model does not run CREATE OR REPLACE TABLE on every run — after its first build, it runs an insert or merge into the existing table, which does not drop and recreate the object at all. This means an incremental model's grants, once issued, generally survive every subsequent incremental run untouched — the permission problem described in Part 03 is specifically a full-rebuild problem.
| Materialization | Does a normal run drop and recreate the object? | Does the grant survive that run? |
|---|---|---|
| table | Yes — every run is a fresh CREATE OR REPLACE TABLE. | No, on most warehouses — needs a post-hook to re-grant every run. |
| view | Yes — every run is a fresh CREATE OR REPLACE VIEW. | No, for the same reason as table — a post-hook is still needed if the view is queried by a role other than the one that owns it. |
| incremental (steady-state runs, after the first build) | No — subsequent runs insert or merge into the existing object. | Yes — the object itself was never dropped, so its grants are untouched. |
| incremental, full-refresh run (--full-refresh) | Yes — a full-refresh explicitly rebuilds the table from scratch. | No — this specific run behaves exactly like a table rebuild, and needs the post-hook to fire. |
dbt run --full-refresh — used deliberately to rebuild an incremental model from scratch, say after a schema change — behaves exactly like a table rebuild and will still wipe grants on warehouses where CREATE OR REPLACE does. Leaving the post-hook attached to an incremental model costs nothing on the steady-state runs where it is a no-op-equivalent re-grant, and protects the one occasion a full refresh is triggered.A conditional variant is also common in real projects: only issuing certain grants in specific environments, using a Jinja {% if %} inside the hook string itself, so a development target does not attempt to grant a production-only role that may not even exist in that environment.
{{
config(
materialized='table',
post_hook="{% if target.name == 'prod' %}grant select on {{ this }} to role bi_reader{% else %}select 1{% endif %}"
)
}}
select order_id, customer_id, order_status, order_total_cents
from {{ ref('stg_orders') }}The select 1 fallback matters mechanically — a hook string must always compile to a valid, executable SQL statement, so an empty string or a comment-only branch would fail; a harmless no-op statement is the idiomatic way to make a conditional hook do genuinely nothing in branches where no action is wanted.
run-operation — Invoking a Macro Directly, Outside Any Model's Build
Everything in Parts 01 through 04 is a hook — SQL tied to a model's lifecycle or to a whole invocation, firing automatically every time that model or invocation runs. Sometimes you need the opposite: a one-off maintenance action, invoked manually from the command line, that is not tied to building any model at all. dbt run-operation is exactly this — it invokes a standalone macro directly, on demand, with no model build involved.
{% macro grant_select(role) %}
{% set relations = ['analytics.fct_orders', 'analytics.dim_customers'] %}
{% for relation in relations %}
{% set sql %}
grant select on {{ relation }} to role {{ role }}
{% endset %}
{% do run_query(sql) %}
{{ log("Granted select on " ~ relation ~ " to " ~ role, info=true) }}
{% endfor %}
{% endmacro %}Invoking it from the CLI, passing role as an argument, looks like this — no model is built, no dbt run or dbt build is involved, just the macro executing directly:
$ dbt run-operation grant_select --args '{role: bi_reader}'Running with dbt=1.8.3
Granted select on analytics.fct_orders to bi_reader
Granted select on analytics.dim_customers to bi_reader
Done. PASS=1 WARN=0 ERROR=0 SKIP=0 TOTAL=1Real use cases for run-operation tend to be one-off maintenance tasks rather than anything that needs to happen automatically on a schedule: manually re-granting permissions after a new role is created and needs to be caught up on existing tables, clearing out a specific table's contents without rebuilding the whole model, or running a cleanup macro that drops old, orphaned tables left behind by renamed models (exactly the kind of orphaned object described in the models-basics module's naming discussion).
{% macro clear_table(table_name) %}
{% set sql %}
truncate table {{ table_name }}
{% endset %}
{% do run_query(sql) %}
{{ log("Truncated " ~ table_name, info=true) }}
{% endmacro %}$ dbt run-operation clear_table --args '{table_name: analytics.fct_orders}'| Hook (pre/post/on-run-start/on-run-end) | run-operation | |
|---|---|---|
| Tied to | A model's build lifecycle, or a full run/build invocation. | Nothing — invoked directly and independently of any model or run. |
| Triggered | Automatically, every time the attached model or invocation runs. | Manually, only when someone runs the run-operation command. |
| Good for | Recurring side effects that must happen every single time a model builds — grants, audit logging. | One-off maintenance actions — manual grant catch-up, clearing a table, cleanup scripts. |
| Where it lives | config() in a model file, or dbt_project.yml. | A standalone macro in macros/, called from the CLI. |
The Real Decision: Does This Need to Happen Every Time, or Just Once, On Demand?
The distinction from Part 05's table compresses into one practical question worth asking about any SQL side effect you're trying to automate: does this genuinely need to happen automatically, every single time a model (or the whole project) builds — or is this a one-off action a human decides to trigger occasionally?
The grant pattern from Part 03 is the clearest possible example of the first case. A BI tool's access must never depend on someone remembering to run a manual command after today's scheduled dbt job — if it did, the very first day someone forgot, the BI tool would silently lose access again. That is exactly why it belongs as a post_hook, tied permanently to the model's own build, rather than as a run-operation a human has to remember to invoke.
Catching a newly created role up on permissions for tables that already exist is the clearer example of the second case. This needs to happen exactly once, at the moment the new role is created — running it on every future dbt run forever, as a hook, would be needless overhead for something that is genuinely a one-time backfill.
Does this action need to happen automatically, every single time
this model (or the whole project) builds?
YES -> it's a hook (pre-hook, post-hook, on-run-start, on-run-end)
-- tie it to the model's config() or dbt_project.yml,
so nobody has to remember to trigger it manually
NO -> it's a run-operation
-- write it as a standalone macro, invoke it manually
from the CLI exactly when the one-off need arisesrun-operation — do not leave it wired in as a permanent hook out of convenience.It is also worth noting these two mechanisms are not mutually exclusive within one project — a real dbt project commonly has both a permanent post_hook grant pattern on its mart models and a small library of maintenance macros in macros/, invoked occasionally via run-operation for the one-off tasks that come up as the project evolves.
Building an on-run-end Hook That Downstream BI Tools Can Query for Freshness
One of the most useful real applications of on-run-end, beyond a simple log line, is a small audit table a BI tool or internal dashboard can query directly to answer a question end users ask constantly: "how fresh is this data right now?" Instead of a BI tool guessing, or a stakeholder asking the data team in Slack, the BI tool queries a single row this hook maintains automatically, on every invocation, without any model needing to know it exists.
The key design decision is that this table should record a completed run only when the run actually succeeded — a naive version that writes a row unconditionally on every invocation would happily report "last successful run: 2 minutes ago" immediately after a run that failed halfway through, which is worse than not having the table at all, because it actively asserts freshness that isn't real. on-run-end hooks have access to a results Jinja variable listing every node in the invocation and its outcome, which is exactly what makes this check possible.
on-run-end:
- "{{ record_last_successful_run(results) }}"{% macro record_last_successful_run(results) %}
{% set any_failures = false %}
{% for result in results %}
{% if result.status in ('error', 'fail') %}
{% set any_failures = true %}
{% endif %}
{% endfor %}
{% if not any_failures %}
{% set sql %}
insert into analytics.dbt_last_successful_run
(invocation_id, completed_at, models_built)
values
('{{ invocation_id }}', current_timestamp(), {{ results | length }})
{% endset %}
{% do run_query(sql) %}
{{ log("Recorded successful run " ~ invocation_id, info=true) }}
{% else %}
{{ log("Run had failures -- last_successful_run NOT updated", info=true) }}
{% endif %}
{% endmacro %}A BI tool, or a lightweight internal dashboard, then needs only a single, trivial query against this table to answer the freshness question for an end user — no dbt internals, no access to CI logs, just a plain SELECT against a table that already has the answer.
select
completed_at,
datediff('minute', completed_at, current_timestamp()) as minutes_since_last_success
from analytics.dbt_last_successful_run
order by completed_at desc
limit 1COMPLETED_AT MINUTES_SINCE_LAST_SUCCESS
2026-09-11 06:04:12 47
-- the dashboard renders: "Data current as of 47 minutes ago"
-- instead of end users guessing, or asking the data team directlyThis pattern composes directly with source freshness checks from the testing-strategy module: a dbt source freshness failure tells the platform team a source is stale before a build even starts, while this on-run-end table tells an end user, after the fact, whether the transformation layer they actually query kept up. Together they cover both ends of the same question — is the raw data recent, and did the pipeline that turns it into a reportable table actually finish successfully on top of it.
What Actually Happens When a pre-hook or post-hook Itself Fails
Every example so far assumes a hook's SQL succeeds. It is worth being precise about what dbt actually does when a hook itself errors, because the answer is different depending on which of the four hook types fails, and getting this wrong is how a "harmless" logging hook ends up silently taking down an entire production run.
| Hook | If it fails, what happens to the model it is attached to | What happens to the rest of the invocation |
|---|---|---|
| pre-hook | The model's own build statement never runs at all — dbt treats the model itself as errored. | Everything downstream of that model is skipped, exactly as if the model's own SELECT had failed. |
| post-hook | The model has already built successfully by the time its post-hook runs — the table or view exists with correct data — but dbt still marks the model as errored for this invocation because a configured post-hook failing counts as part of that model's result. | Everything downstream of that model is skipped, even though the model's own data is actually fine — only the post-hook step failed. |
| on-run-start | Not tied to any single model. | No model in the invocation attempts to build at all — dbt fails the entire invocation immediately, before the first model. |
| on-run-end | Not tied to any single model; by the time it runs, every model has already finished, successfully or not. | The invocation as a whole is marked as failed, but every model's own individual result (already recorded before on-run-end ran) is unaffected — the models themselves already succeeded or failed on their own merits. |
The post-hook row is the one that surprises engineers most often the first time they hit it: a model can build a perfectly correct table, with entirely correct data, and still show up as ERROR in the run's summary because its post-hook — say, a grant statement referencing a role that does not exist in this environment — failed after the real work was already done. The data is fine. The run output says otherwise. This is exactly why Part 04's environment-conditional hook pattern (only granting when target.name == 'prod') matters beyond tidiness — an unconditional grant to a prod-only role, run in a dev environment where that role was never created, fails the model in dev for a reason that has nothing to do with the model's actual correctness.
06:04:11 1 of 1 START sql table model marts.fct_orders ................ [RUN]
06:04:13 1 of 1 OK created sql table model marts.fct_orders ............ [SELECT 48213 in 2.1s]
06:04:13 Running hook: post-hook.fct_orders.0
06:04:13 Database Error in hook post-hook.fct_orders.0
002003 (42S02): SQL compilation error: role 'BI_READER_STAGING' does not exist
06:04:13 1 of 1 ERROR creating sql table model marts.fct_orders ........ [ERROR in 0.15s]
-- the table itself built and has correct data (SELECT 48213 succeeded)
-- but the model is reported as ERROR because its post-hook failed
-- anything downstream of fct_orders will be SKIPPED, even though
-- fct_orders' own data is completely fineThe practical consequence worth internalizing: a downstream model getting skipped does not always mean upstream data is bad. It is worth checking, specifically, whether the failure that caused the skip was in the model's own SELECT or in one of its hooks, before assuming a data problem and starting to debug the transformation logic — the two failure modes look identical in a quick glance at a red run summary, but call for completely different fixes.
Four Mechanisms for "Run This SQL Automatically" — and When Each One Actually Wins
By this point in the module there are, in effect, four different ways to get SQL to execute without a person typing it manually each time: a dbt hook, a dbt macro called from a model, a run-operation, and — stepping outside dbt entirely — a warehouse-native scheduling mechanism like a Snowflake task or stream. Real projects, especially ones already running on Snowflake, end up choosing between these constantly, and the wrong choice tends to produce either something that should have been automatic staying manual, or something automated in dbt that the warehouse was always going to do more natively and more cheaply.
| Mechanism | Tied to a dbt run? | Best for | Weak point |
|---|---|---|---|
| pre-hook / post-hook | Yes — fires as part of one specific model's build, every time that model runs. | A side effect that must happen every time this exact model builds — the grant pattern from Part 03 is the canonical case. | Only fires when dbt actually runs this model; cannot run on any independent schedule of its own. |
| on-run-start / on-run-end | Yes — fires once per whole dbt invocation, regardless of which models it touches. | Invocation-level bookkeeping — an audit log row, the last-successful-run table from Part 07. | Same limitation, one level up: it only fires when someone runs dbt at all, never independently. |
| A macro invoked via run-operation | No — invoked manually and independently of any model build. | A one-off, human-triggered maintenance action — Part 05 and Part 06's backfill and cleanup cases. | Needs a human (or an external scheduler calling the CLI) to actually trigger it; nothing fires on its own. |
| A Snowflake-native task + stream | No — runs entirely inside the warehouse, on the warehouse's own schedule, independent of dbt. | A genuinely warehouse-internal maintenance job with no dbt-modeled logic involved — purging old rows on a timer, monitoring a raw ingestion table for new rows to react to. | Lives outside the dbt project entirely — invisible to dbt's DAG, its docs, and anyone reading the project to understand what runs and when. |
The decision that trips people up most, coming from a Snowflake background specifically, is the last row: Snowflake tasks and streams can absolutely run scheduled SQL on their own, with no dbt involvement at all, so it is tempting to treat every recurring warehouse job as a task rather than a dbt hook. The distinction that actually matters is whether the job is about a dbt model or entirely independent of dbt's own DAG. The grant post-hook from Part 03 is fundamentally about a specific dbt model's own lifecycle — it needs to know precisely when that model rebuilds, which is exactly what a dbt hook is positioned to know and a Snowflake task is not, short of duplicating dbt's own run logic outside of dbt.
-- this belongs as a Snowflake task, NOT a dbt hook: it runs on its
-- own fixed schedule, has nothing to do with any specific dbt model
-- rebuilding, and dbt would add nothing by wrapping it
create or replace task purge_stale_raw_events
warehouse = transform_wh
schedule = 'USING CRON 0 3 * * * UTC'
as
delete from raw.events where _loaded_at < dateadd(day, -90, current_timestamp());Conversely, a common mistake in the other direction is building an elaborate Snowflake task-and-stream pipeline to react to a dbt model's own output — say, a stream watching fct_orders for new rows to trigger a downstream notification — when a straightforward post-hook on that exact model would do the same job with far less moving infrastructure, and would show up directly in the dbt project where anyone reading the model's config can see it, rather than being invisible outside dbt entirely.
{{
config(
materialized='table',
post_hook="call analytics.notify_new_orders_batch({{ this }})"
)
}}
select order_id, customer_id, order_status, order_total_cents
from {{ ref('stg_orders') }}
-- one line, visible in the model's own config, versus a separate
-- stream + task pair that has to be discovered by reading Snowflake's
-- own object list rather than the dbt projectNone of this is a strict either/or across an entire project — a healthy Snowflake-plus-dbt setup commonly runs all four mechanisms side by side: grant post-hooks on every mart model, an on-run-end audit table, a small library of run-operation macros for occasional maintenance, and a handful of genuinely warehouse-native tasks for jobs that were never dbt's concern to begin with. The skill is recognizing, for any given new automation need, which of the four it actually is.
Reading Hook Output When Something in the Chain Goes Wrong
The failure-behavior table in Part 08 describes what dbt does structurally when a hook fails. In practice, the harder part is not knowing that a post-hook failure blocks downstream models — it is quickly telling, from a wall of run output, whether a given failure came from a model's own SELECT or from one of its hooks, especially when several models in the same invocation are failing at once for unrelated reasons.
dbt's run output is deliberately explicit about this distinction if you know what to look for: a failure inside a model's own build statement is reported directly against that step, while a hook failure is reported against a separate, clearly labeled hook step that runs immediately after (for a post-hook) or before (for a pre-hook) the model's own compile step. The practical habit worth building: before assuming a model's transformation logic is broken, check whether the failing line in the log actually says hook or names the model's own materialization statement.
-- Failure #1: the model's own SELECT is broken
06:10:02 1 of 2 START sql table model marts.fct_orders ................ [RUN]
06:10:02 Database Error in model fct_orders (models/marts/fct_orders.sql)
002003 (42S02): SQL compilation error: invalid identifier 'ORDR_ID'
06:10:02 1 of 2 ERROR creating sql table model marts.fct_orders ........ [ERROR in 0.09s]
-- fix: there is a real typo/bug in the model's own SELECT statement
-- Failure #2: the model built fine, its post-hook did not
06:11:40 2 of 2 START sql table model marts.dim_customers .............. [RUN]
06:11:42 2 of 2 OK created sql table model marts.dim_customers ......... [SELECT 91002 in 1.9s]
06:11:42 Running hook: post-hook.dim_customers.0
06:11:42 Database Error in hook post-hook.dim_customers.0
002003 (42S02): SQL compilation error: role 'FINANCE_ANALYST_REPORTING' does not exist
06:11:42 2 of 2 ERROR creating sql table model marts.dim_customers ..... [ERROR in 0.11s]
-- fix: the grant target doesn't exist in this environment -- the
-- model itself, and its data, are completely fineA second habit worth building specifically for project-wide hooks configured in dbt_project.yml rather than an individual model's config(): since the same hook string applies to every model under a given directory, a single bad hook (a typo in the grant SQL, a role name that only exists in one environment) can appear to fail many unrelated models at once, all with the identical error message. That repetition across otherwise-unrelated models is itself a strong signal the problem is the shared hook, not something wrong with each model individually.
06:12:01 ERROR creating sql table model marts.fct_orders ....... [ERROR in 0.11s]
role 'FINANCE_ANALYST_REPORTING' does not exist
06:12:03 ERROR creating sql table model marts.dim_customers ..... [ERROR in 0.10s]
role 'FINANCE_ANALYST_REPORTING' does not exist
06:12:05 ERROR creating sql table model marts.dim_products ...... [ERROR in 0.09s]
role 'FINANCE_ANALYST_REPORTING' does not exist
-- three completely different models, the exact same error --
-- this is the +post_hook config at the marts/ directory level in
-- dbt_project.yml, not three independent bugs in three models+post_hook (or +pre_hook) config for that directory in dbt_project.yml, rather than opening each model file individually looking for a bug that almost certainly is not there. This single habit turns what looks like three or four separate incidents into one five-minute fix.dbt run --select fct_orders re-running just the one affected model, after fixing the shared hook config, is the fastest way to confirm the fix worked without re-running the entire project — the model itself never needed rebuilding in the first place, since its own data was fine the whole time; only the hook needed to succeed.
Five Misconceptions About dbt Hooks and Operations
What This Looks Like on Day One
At Sonos: the analytics team gets a recurring complaint every few weeks — a product dashboard in the BI tool intermittently fails with a permission error, always right after the overnight dbt job runs, and always on a table that was working fine the day before. Nobody had touched permissions manually. Per Part 03, the root cause turns out to be exactly the CREATE OR REPLACE TABLE mechanism — the nightly rebuild was silently dropping the BI service account's grant every single run. Adding a project-wide post_hook re-granting SELECT on every mart model, per Part 02's directory-level config pattern, ends the recurring incident permanently rather than requiring someone to manually re-grant it each time it happens.
At Angi: a new data analyst role is created for a contractor team that needs read access to two years of historical marketing mart tables that already exist and are not rebuilt often. Rather than adding a permanent hook that would re-grant this role on every future run of models that mostly don't change, the platform engineer writes a small grant_select macro per Part 05 and runs it once via dbt run-operation grant_select --args '{role: contractor_reporting}' to catch the new role up immediately — a one-time backfill action, not a recurring hook.
At Root Insurance: during a project audit, an engineer notices an on-run-end hook in dbt_project.yml that inserts a row into an audit log table on every single invocation, including ones triggered by individual developers testing a single model locally with dbt run --select my_model. Per Part 06's decision framework, the team decides this genuinely does need to fire on every invocation — the audit log is meant to capture every build event, local or scheduled — so the hook stays as-is; it is deliberately not a run-operation, because relying on a human to manually log every local test run would defeat the purpose of an audit trail.
5 Interview Questions — With Complete Answers
The Mistakes That Make Hooks Unreliable
Errors You Will Hit — And Exactly Why They Happen
🎯 Key Takeaways
- ✓A hook is SQL configured to run automatically at a specific point around a model's build (pre-hook, post-hook) or around a whole invocation (on-run-start, on-run-end) — it fires every time, with no manual step required.
- ✓The single most common real hook use case is a post-hook re-granting SELECT to a BI role after every table rebuild, because CREATE OR REPLACE TABLE drops and recreates the object on many warehouses, taking previously issued grants with it.
- ✓Using {{ this }} inside a hook, rather than a hardcoded schema and table name, is what makes the hook resolve correctly across environments and survive model renames.
- ✓run-operation invokes a standalone macro directly from the CLI, independent of any model build — the right tool for a one-off maintenance action, not something that needs to happen automatically every run.
- ✓The decision between a hook and a run-operation comes down to one question: does this need to happen automatically every single time, or only once, on demand — the former is a hook, the latter is a run-operation.
- ✓Incremental models mostly avoid the grant-loss problem on steady-state runs (they insert/merge rather than rebuild), but a --full-refresh run behaves like a full table rebuild and still needs the post-hook to fire.
Discussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.