
I tried out the new feature where the SCHEDULER attribute of Dynamic Table can now be natively controlled with the scheduler config in dbt-snowflake
This page has been translated by machine translation. View original
This is Kawabata.
Until now, controlling the SCHEDULER attribute of Dynamic Tables in dbt required a workaround using pre_hook/post_hook, but since the dbt-snowflake adapter has added native support for the scheduler config key, I went ahead and verified it in practice.
Here are the results.
Background & Problem
Previously, I wrote an article titled Trying Out the SCHEDULER Attribute of Dynamic Tables. Setting SCHEDULER = DISABLE stops the automatic TARGET_LAG-based refresh by Snowflake's automatic scheduler, so the table is not refreshed until an external orchestrator like dbt or Airflow explicitly calls ALTER DYNAMIC TABLE ... REFRESH.
-- Raw SQL example with SCHEDULER = DISABLE
CREATE OR REPLACE DYNAMIC TABLE KAWABATA_MART_DB.PUBLIC.DT_ORDERS_SCHEDULER_DISABLED
WAREHOUSE = COMPUTE_WH
REFRESH_MODE = FULL
INITIALIZE = ON_CREATE
SCHEDULER = DISABLE
AS
SELECT o.ID AS ORDER_ID
FROM KAWABATA_MART_DB.KAWABATA_RAW.RAW_ORDERS o;
However, as of the article written on March 31, 2026, the dbt-snowflake adapter (v1.9.2) did not natively support the SCHEDULER attribute, and target_lag remained mandatory, causing ALTER statements to conflict when running dbt run directly. At the time, the workaround was to temporarily revert to SCHEDULER = ENABLE via pre_hook, manually refresh via post_hook, and then switch back to SCHEDULER = DISABLE, as shown below.
-- models/intermediate/int_order_items_dynamic_table.sql (workaround from previous article)
{{
config(
materialized='dynamic_table',
snowflake_warehouse='COMPUTE_WH',
target_lag='DOWNSTREAM',
pre_hook=[
"ALTER DYNAMIC TABLE IF EXISTS {{ this }} SET SCHEDULER = ENABLE TARGET_LAG = DOWNSTREAM"
],
post_hook=[
"ALTER DYNAMIC TABLE {{ this }} REFRESH",
"ALTER DYNAMIC TABLE {{ this }} SET SCHEDULER = DISABLE"
]
)
}}
SELECT
i.ITEM_ID,
i.ORDER_ID
FROM {{ ref('stg_items') }} i
JOIN {{ ref('stg_orders') }} o
ON i.ORDER_ID = o.ORDER_ID;
Since this configuration forcibly rewrote the SCHEDULER attribute via hooks, it had awkward aspects such as 2–3 extra ALTER statements running on every dbt run, and unintended refreshes occurring if the hook execution order was wrong. The previous article also concluded that "waiting for official dbt adapter support is the right approach," so I was curious whether support had actually been added.
Technical Approach
After checking the official Snowflake documentation, I confirmed that the scheduler key has been formally added to the materialized='dynamic_table' config, making it possible to specify ENABLE/DISABLE directly without hooks.
-- models/marts/dt_orders_scheduler_demo.sql (new approach)
{{ config(
materialized='dynamic_table',
snowflake_warehouse='COMPUTE_WH',
refresh_mode='FULL',
scheduler='DISABLE'
) }}
SELECT
o.ID AS ORDER_ID,
o.CUSTOMER AS CUSTOMER_ID
FROM {{ source('ecom', 'raw_orders') }} o
In dbt Dynamic Table models, omitting target_lag or specifying scheduler: DISABLE switches to a dbt/external orchestrator-managed refresh mode that does not use Snowflake's automatic scheduling. Since TARGET_LAG cannot be defined when SCHEDULER = DISABLE, remove target_lag from the config.
When dbt executes a Dynamic Table model, it creates or alters the Dynamic Table and also performs an explicit refresh via ALTER DYNAMIC TABLE ... REFRESH. Note that while the official documentation states that subsequent dbt run executions skip the model if the SQL is unchanged, in my verification environment, refreshes were executed even on dbt run without SQL changes (confirmed in the delete propagation verification later). The attribute switching that was previously managed manually via pre_hook/post_hook is now replaced by a single line in the config.
Limitations
Limitations
- dbt-snowflake adapter v1.11.5 or higher is required
- All base tables that directly or indirectly supply data to the Dynamic Table must have
CHANGE_TRACKING = TRUEexplicitly set. In particular, operations thatCREATE OR REPLACEupstream tables may lose change tracking metadata, potentially causing incremental refresh failures for downstream Dynamic Tables - dbt Model Contracts are not supported
- The
copy_grantsconfig is explicitly listed as unsupported in Snowflake's dbt documentation for Dynamic Tables (grants are reset on everyCREATE OR REPLACE). Do not rely oncopy_grantsfor permissions on Dynamic Tables; instead, plan to reapply necessary GRANTs after deployment - Changing the model SQL (
SELECTstatement) of a Dynamic Table causes dbt to executeCREATE OR REPLACE DYNAMIC TABLE, reinitializing the Dynamic Table. Users do not need to explicitly specify--full-refresh. Conversely, runningdbt run --full-refreshwillCREATE OR REPLACEand reinitialize even unchanged models within the selection scope (--select/--exclude), so be careful not to run it without narrowing the target - dbt tests run against the "current state" of the Dynamic Table and do not test against specific refresh results. If a test runs during a refresh, it reads the state available at that point
- Materialized Views, External Tables, Directory Tables, and Streams cannot be referenced as upstream sources for Dynamic Tables
Prerequisites
- dbt-snowflake adapter: v1.11.5 or higher (minimum requirement for using the
schedulerconfig) - Verification environment: dbt Core 1.11.11, as of July 24, 2026. Execution command:
dbt run --select dt_orders_scheduler_demo - Snowflake: Continuing to use the separately built jaffle-shop environment (
DEV/PROD/RAWschemas inJAFFLE_SHOP_DB,JAFFLE_SHOP_WH,DBT_CICD_ROLE) as-is
Preparation
Set CHANGE_TRACKING on the Base Table
-- RAW_ORDERS is created by dbt (seed), so execute with the role that holds OWNERSHIP
ALTER TABLE JAFFLE_SHOP_DB.RAW.RAW_ORDERS SET CHANGE_TRACKING = TRUE;
Note:
ALTER TABLE ... SET CHANGE_TRACKING = TRUErequiresOWNERSHIPof the target table. In the jaffle-shop environment,RAW.RAW_ORDERSis a table created by dbt's seed underDBT_CICD_ROLE, so execute with that role (or a higher role in the hierarchy).

Add the dynamic_table Model File
Create models/marts/dt_orders_scheduler_demo.sql as a new file and place the model there.

Trying It Out
Run and Verify
Run dbt run with scheduler='DISABLE' in the config.
dbt run --select dt_orders_scheduler_demo


During dbt run, in addition to the SQL for Dynamic Table configuration changes, ALTER DYNAMIC TABLE ... REFRESH is also issued.

Also verify using SHOW DYNAMIC TABLES that the scheduler column shows DISABLE and target_lag is NULL.
SHOW DYNAMIC TABLES LIKE 'DT_ORDERS_SCHEDULER_DEMO' IN DATABASE JAFFLE_SHOP_DB;

Comparing the behavior of ENABLE and DISABLE gives the following:
| Setting | Refresh Trigger | Behavior When Refresh Is Not Executed |
|---|---|---|
scheduler: ENABLE (default) |
Snowflake executes automatically based on target_lag |
Continues to refresh automatically |
scheduler: DISABLE |
When dbt creates/alters the model, or via an explicit ALTER DYNAMIC TABLE ... REFRESH from an external orchestrator |
No automatic refresh by Snowflake; data remains as of the last refresh |
It was confirmed that switching between Snowflake-managed automatic operation via target_lag and dbt/external orchestrator-managed operation can be done with just a single-line config change.
Verifying Whether Deletions on the Source Side Are Reflected
A concern with scheduler: DISABLE operation is how data changes on the base table side—especially row deletions—are reflected in the Dynamic Table. With dbt incremental models, workarounds like delete+insert are needed to propagate deletions, but since Dynamic Tables declaratively maintain the result of the query SQL, source-side deletions should naturally be reflected in the query results once a refresh completes.
Another thing to verify is whether a dbt run without SQL changes executes a refresh. The official documentation's description of dbt-managed refresh states that each dbt run creates/alters the Dynamic Table and then issues ALTER DYNAMIC TABLE ... REFRESH, while the model example section states "Subsequent runs detect if the SQL is unchanged and skip the model entirely." The observable behavior differs within the same page. Note that "SQL is unchanged" here refers to the dbt model's SELECT statement and config being unchanged, and has nothing to do with data changes in the source table. Let's verify which behavior actually occurs.
First, check the row count and target rows before deletion.
-- Check state before deletion
SELECT COUNT(*) AS CNT FROM JAFFLE_SHOP_DB.RAW.RAW_ORDERS;
SELECT COUNT(*) AS CNT FROM JAFFLE_SHOP_DB.DEV.DT_ORDERS_SCHEDULER_DEMO;


DELETE rows from the base table.
-- Delete rows on the source side
DELETE FROM JAFFLE_SHOP_DB.RAW.RAW_ORDERS
WHERE ID IN (
SELECT ID FROM JAFFLE_SHOP_DB.RAW.RAW_ORDERS LIMIT 10
);

Note: What is being deleted here is the source data loaded by seed. After verification, it can be restored by re-running the
seedcommand (with--vars '{load_source_data: true}').
At this point, checking the Dynamic Table side shows that because of scheduler: DISABLE, no automatic refresh by Snowflake has run, and the row count remains as it was before deletion.
-- Immediately after DELETE: not yet reflected in Dynamic Table
SELECT COUNT(*) AS CNT FROM JAFFLE_SHOP_DB.DEV.DT_ORDERS_SCHEDULER_DEMO;

Next, run dbt run without changing the model SQL.
dbt run --select dt_orders_scheduler_demo
-- After dbt run without SQL changes: deletion is reflected
SELECT COUNT(*) AS CNT FROM JAFFLE_SHOP_DB.DEV.DT_ORDERS_SCHEDULER_DEMO;

As a result of verification, a dbt run with no SQL changes still executed a refresh, and the source-side deletion was reflected in the Dynamic Table (observed with dbt Core 1.11.11, as of July 24, 2026). Contrary to the model example section's statement that "the model is skipped if the SQL is unchanged," the behavior followed the dbt-managed refresh description (issuing REFRESH on each dbt run).
Checking the SQL issued in Query History, this dbt run issued alter dynamic table ... refresh against the existing Dynamic Table and did not perform a CREATE OR REPLACE recreation. It was confirmed that for models with unchanged SQL, dbt executes only the refresh.

If you want to reflect changes without going through dbt, you can explicitly run a refresh from an orchestrator like Airflow or Snowflake Tasks.
-- Explicit refresh (when running directly from an orchestrator)
ALTER DYNAMIC TABLE JAFFLE_SHOP_DB.DEV.DT_ORDERS_SCHEDULER_DEMO REFRESH;
Note on permissions: Executing
ALTER DYNAMIC TABLE ... REFRESHrequiresOPERATE(orOWNERSHIP) privilege on the target Dynamic Table. For roles dedicated to routine refreshes, consider a least-privilege design usingOPERATErather than grantingOWNERSHIPbeyond what is necessary.
The behavior before and after deletion is summarized as follows:
| Timing | Base Table | Dynamic Table (scheduler: DISABLE) |
|---|---|---|
| Immediately after DELETE | Deleted | Not reflected (remains as of last refresh) |
After dbt run with no SQL changes |
Deleted | Deletion reflected, matches base table |
Dynamic Tables naturally reflect source-side deletions in results once a refresh is executed. There is no need to write deletion propagation logic yourself as with incremental models. With scheduler: DISABLE, since Snowflake's automatic refresh does not occur, only the execution of dbt run or an explicit ALTER DYNAMIC TABLE ... REFRESH acts as the refresh trigger—a level of controllability that was confirmed.
Note: Since the official documentation still contains a statement that models are skipped when SQL is unchanged, behavior may vary depending on the dbt or adapter version. When incorporating this into production operations, it is recommended to verify REFRESH issuance in your own environment's execution logs.
Note: This verification was performed with
refresh_mode='FULL'. For deletion propagation withINCREMENTAL, it is a prerequisite that the Dynamic Table's query supports incremental refresh and that change tracking information from upstreamCHANGE_TRACKINGis available.
Comparison with the Old Workaround
Placing the old article's hook-based implementation side by side with the new scheduler config specification makes the difference in simplicity easy to see.
Before (pre_hook/post_hook workaround)
pre_hook=[
"ALTER DYNAMIC TABLE IF EXISTS {{ this }} SET SCHEDULER = ENABLE TARGET_LAG = DOWNSTREAM"
],
post_hook=[
"ALTER DYNAMIC TABLE {{ this }} REFRESH",
"ALTER DYNAMIC TABLE {{ this }} SET SCHEDULER = DISABLE"
]
After (scheduler config)
scheduler='DISABLE'
The implementation that was toggling the SCHEDULER attribute back and forth via hooks has been replaced by a single config line: scheduler='DISABLE'. By eliminating the unnecessary state transitions and execution order dependencies caused by hooks, the maintainability of the definition improves.
Note: In the current version of Snowflake,
SCHEDULER = DISABLEandTARGET_LAGcannot be used together. The hook-based implementation from the previous article (a configuration that switches toSCHEDULER = DISABLEwhileTARGET_LAG = DOWNSTREAMis set) was published as the verification result at the time, and adopting this configuration newly is not recommended. When usingscheduler: DISABLE, removetarget_lagfrom the config.
Closing
I verified the SCHEDULER attribute of Dynamic Tables using dbt's scheduler config. Summarizing the issues at the time of the previous article and the current state:
| Point in Time | SCHEDULER Control in dbt |
|---|---|
| March 31, 2026 (verification at time of previous article, dbt-snowflake v1.9.2) | Not supported. Workaround using pre_hook/post_hook was required |
| July 24, 2026 (current verification, dbt Core 1.11.11) | Controllable with a single line of scheduler: ENABLE / DISABLE in the config (dbt-snowflake v1.11.5 or higher required) |
As I wrote in the previous article that "waiting for official support is the right approach," it was worth the wait. Since there is no longer a need to forcibly rewrite attributes via hooks, using the scheduler config directly is recommended when you want to manage Dynamic Tables under dbt.
Note that when manually refreshing a SCHEDULER = DISABLE Dynamic Table on its own, only that Dynamic Table is refreshed and it does not cascade to upstream or downstream Dynamic Tables (with SCHEDULER = ENABLE, manual refresh cascades to upstream Dynamic Tables, but stops at any upstream table with SCHEDULER = DISABLE). Therefore, when operating a group of Dynamic Tables with dependencies under scheduler: DISABLE, the execution order, re-execution on failure, and monitoring need to be designed on the orchestrator side using dbt, Airflow, or similar tools. Please use them according to your requirements.
I hope this article is helpful in some way!