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

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

The dbt-snowflake adapter has officially added support for the `SCHEDULER` attribute of Dynamic Tables, so I actually verified whether we could be freed from the workarounds we had been using up until now.
2026.07.24

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.

https://docs.snowflake.com/en/user-guide/dynamic-tables/dbt

https://docs.getdbt.com/reference/resource-configs/snowflake-configs?version=2.0&name=Fusion#dynamic-tables

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 = TRUE explicitly set. In particular, operations that CREATE OR REPLACE upstream tables may lose change tracking metadata, potentially causing incremental refresh failures for downstream Dynamic Tables
  • dbt Model Contracts are not supported
  • The copy_grants config is explicitly listed as unsupported in Snowflake's dbt documentation for Dynamic Tables (grants are reset on every CREATE OR REPLACE). Do not rely on copy_grants for permissions on Dynamic Tables; instead, plan to reapply necessary GRANTs after deployment
  • Changing the model SQL (SELECT statement) of a Dynamic Table causes dbt to execute CREATE OR REPLACE DYNAMIC TABLE, reinitializing the Dynamic Table. Users do not need to explicitly specify --full-refresh. Conversely, running dbt run --full-refresh will CREATE OR REPLACE and 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 scheduler config)
  • 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/RAW schemas in JAFFLE_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 = TRUE requires OWNERSHIP of the target table. In the jaffle-shop environment, RAW.RAW_ORDERS is a table created by dbt's seed under DBT_CICD_ROLE, so execute with that role (or a higher role in the hierarchy).

2026-07-24_21h44_43

Add the dynamic_table Model File

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

2026-07-24_21h54_01

Trying It Out

Run and Verify

Run dbt run with scheduler='DISABLE' in the config.

dbt run --select dt_orders_scheduler_demo

2026-07-24_21h59_18

2026-07-24_22h04_01

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

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;

2026-07-24_22h19_47

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;

2026-07-24_22h24_11

2026-07-24_22h24_34

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
);

2026-07-24_22h25_05

Note: What is being deleted here is the source data loaded by seed. After verification, it can be restored by re-running the seed command (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;

2026-07-24_22h25_40

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;

2026-07-24_22h30_15

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.

2026-07-24_22h49_27

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 ... REFRESH requires OPERATE (or OWNERSHIP) privilege on the target Dynamic Table. For roles dedicated to routine refreshes, consider a least-privilege design using OPERATE rather than granting OWNERSHIP beyond 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 with INCREMENTAL, it is a prerequisite that the Dynamic Table's query supports incremental refresh and that change tracking information from upstream CHANGE_TRACKING is 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 = DISABLE and TARGET_LAG cannot be used together. The hook-based implementation from the previous article (a configuration that switches to SCHEDULER = DISABLE while TARGET_LAG = DOWNSTREAM is set) was published as the verification result at the time, and adopting this configuration newly is not recommended. When using scheduler: DISABLE, remove target_lag from 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.

https://docs.snowflake.com/en/user-guide/dynamic-tables/dbt

I hope this article is helpful in some way!


Snowflakeの導入支援はクラスメソッドに!

クラスメソッドでは Snowflake の導入を支援しております。
製品の詳細や支援の内容についてお気軽にお問い合わせください。

Snowflakeの詳細を見る

Share this article