Skip to content

Commit c2cf3e5

Browse files
feat(snowflake): add transient support for dynamic tables (#1603)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 71d33fb commit c2cf3e5

12 files changed

Lines changed: 698 additions & 11 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
kind: Breaking Changes
2+
body: Add transient support for Snowflake dynamic tables via `transient` config option and `snowflake_default_transient_dynamic_tables` behavior flag.
3+
time: 2026-02-09T11:38:22.629597-08:00
4+
custom:
5+
Author: igorbelianski-cyber
6+
Issue: "716"

dbt-snowflake/src/dbt/adapters/snowflake/impl.py

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from copy import deepcopy
22
from dataclasses import dataclass
3-
from typing import Mapping, Any, Optional, List, Union, Dict, FrozenSet, Tuple, TYPE_CHECKING
3+
from typing import Mapping, Any, Optional, List, Union, Dict, FrozenSet, Tuple
44

55
from dbt.adapters.base.impl import AdapterConfig, ConstraintSupport
66
from dbt.adapters.base.meta import available
@@ -22,6 +22,7 @@
2222
CatalogTable,
2323
ColumnMetadata,
2424
)
25+
from dbt_common.behavior_flags import BehaviorFlag
2526
from dbt_common.events.functions import fire_event
2627
from dbt_common.exceptions import CompilationError, DbtDatabaseError, DbtRuntimeError
2728
from dbt_common.utils import filter_null_values
@@ -38,11 +39,19 @@
3839
from dbt.adapters.snowflake import SnowflakeConnectionManager
3940
from dbt.adapters.snowflake import SnowflakeRelation
4041

41-
if TYPE_CHECKING:
42-
import agate
42+
import agate
4343

4444
SHOW_OBJECT_METADATA_MACRO_NAME = "snowflake__show_object_metadata"
4545

46+
SNOWFLAKE_DEFAULT_TRANSIENT_DYNAMIC_TABLES = BehaviorFlag(
47+
name="snowflake_default_transient_dynamic_tables",
48+
default=False,
49+
description=(
50+
"When enabled, dynamic tables default to transient (matching regular table behavior). "
51+
"This is a breaking change from previous behavior where dynamic tables were non-transient."
52+
),
53+
)
54+
4655

4756
@dataclass
4857
class SnowflakeConfig(AdapterConfig):
@@ -99,6 +108,10 @@ class SnowflakeAdapter(SQLAdapter):
99108
}
100109
)
101110

111+
@property
112+
def _behavior_flags(self) -> list[BehaviorFlag]:
113+
return [SNOWFLAKE_DEFAULT_TRANSIENT_DYNAMIC_TABLES]
114+
102115
def __init__(self, config, mp_context) -> None:
103116
super().__init__(config, mp_context)
104117
self.add_catalog_integration(constants.DEFAULT_INFO_SCHEMA_CATALOG)
@@ -512,7 +525,9 @@ def build_catalog_relation(self, model: RelationConfig) -> Optional[CatalogRelat
512525
return None
513526

514527
@available
515-
def describe_dynamic_table(self, relation: SnowflakeRelation) -> Dict[str, Any]:
528+
def describe_dynamic_table(
529+
self, relation: SnowflakeRelation, include_transient: bool = False
530+
) -> Dict[str, Any]:
516531
"""
517532
Get all relevant metadata about a dynamic table to return as a dict to Agate Table row
518533
@@ -548,7 +563,35 @@ def describe_dynamic_table(self, relation: SnowflakeRelation) -> Dict[str, Any]:
548563
if "initialization_warehouse" in available_columns:
549564
base_columns.insert(base_columns.index("warehouse") + 1, "initialization_warehouse")
550565

551-
return {"dynamic_table": dt_table.select(base_columns)}
566+
selected = dt_table.select(base_columns)
567+
568+
if include_transient:
569+
is_transient = self._query_dynamic_table_transient_status(relation)
570+
# choosing a future proof column name
571+
selected = selected.compute(
572+
[("transient", agate.Formula(agate.Boolean(), lambda row: is_transient))]
573+
)
574+
575+
return {"dynamic_table": selected}
576+
577+
def _query_dynamic_table_transient_status(self, relation: SnowflakeRelation) -> bool:
578+
"""
579+
Query SHOW TABLES to determine if a dynamic table is transient.
580+
581+
SHOW DYNAMIC TABLES does not expose transient status, so we fall back to
582+
SHOW TABLES where the "kind" column contains "TRANSIENT" for transient tables.
583+
"""
584+
quoting = relation.quote_policy
585+
schema = f'"{relation.schema}"' if quoting.schema else relation.schema
586+
database = f'"{relation.database}"' if quoting.database else relation.database
587+
show_tables_sql = f"show tables like '{relation.identifier}' in schema {database}.{schema}"
588+
_, tables_table = self.execute(show_tables_sql, fetch=True)
589+
if len(tables_table.rows) > 0:
590+
tables_table = tables_table.rename(
591+
column_names=[name.lower() for name in tables_table.column_names]
592+
)
593+
return tables_table.rows[0].get("kind") == "TRANSIENT"
594+
return False
552595

553596
def expand_column_types(self, goal, current):
554597
reference_columns = {c.name: c for c in self.get_columns_in_relation(goal)}

dbt-snowflake/src/dbt/adapters/snowflake/relation.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
SnowflakeDynamicTableWarehouseConfigChange,
2727
SnowflakeDynamicTableImmutableWhereConfigChange,
2828
SnowflakeDynamicTableClusterByConfigChange,
29+
SnowflakeDynamicTableTransientConfigChange,
2930
SnowflakeQuotePolicy,
3031
SnowflakeRelationType,
3132
)
@@ -93,7 +94,9 @@ def from_config(cls, config: RelationConfig) -> RelationConfigBase:
9394

9495
@classmethod
9596
def dynamic_table_config_changeset(
96-
cls, relation_results: RelationResults, relation_config: RelationConfig
97+
cls,
98+
relation_results: RelationResults,
99+
relation_config: RelationConfig,
97100
) -> Optional[SnowflakeDynamicTableConfigChangeset]:
98101
existing_dynamic_table = SnowflakeDynamicTableConfig.from_relation_results(
99102
relation_results
@@ -150,6 +153,19 @@ def dynamic_table_config_changeset(
150153
context=new_dynamic_table.cluster_by,
151154
)
152155

156+
# Transient is only compared when both sides are explicitly known:
157+
# - new is None when the user omitted transient from their config ("don't care")
158+
# - existing is None when describe_dynamic_table was called without include_transient
159+
if (
160+
new_dynamic_table.transient is not None
161+
and existing_dynamic_table.transient is not None
162+
and new_dynamic_table.transient != existing_dynamic_table.transient
163+
):
164+
config_change_collection.transient = SnowflakeDynamicTableTransientConfigChange(
165+
action=RelationConfigChangeAction.create, # type: ignore
166+
context=new_dynamic_table.transient,
167+
)
168+
153169
if config_change_collection.has_changes:
154170
return config_change_collection
155171
return None

dbt-snowflake/src/dbt/adapters/snowflake/relation_configs/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
SnowflakeDynamicTableTargetLagConfigChange,
99
SnowflakeDynamicTableImmutableWhereConfigChange,
1010
SnowflakeDynamicTableClusterByConfigChange,
11+
SnowflakeDynamicTableTransientConfigChange,
1112
)
1213
from dbt.adapters.snowflake.relation_configs.policies import (
1314
SnowflakeIncludePolicy,

dbt-snowflake/src/dbt/adapters/snowflake/relation_configs/dynamic_table.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ class SnowflakeDynamicTableConfig(SnowflakeRelationConfigBase):
4949
- initialize: specifies the behavior of the initial refresh of the dynamic table
5050
- cluster_by: specifies the columns to cluster on
5151
- immutable_where: specifies an immutability constraint expression
52+
- transient: specifies whether the dynamic table is transient (no fail-safe). snowflake_default_transient_dynamic_tables determines the default value
5253
5354
There are currently no non-configurable parameters.
5455
"""
@@ -66,6 +67,7 @@ class SnowflakeDynamicTableConfig(SnowflakeRelationConfigBase):
6667
table_tag: Optional[str] = None
6768
cluster_by: Optional[Union[str, list[str]]] = None
6869
immutable_where: Optional[str] = None
70+
transient: Optional[bool] = None
6971

7072
@classmethod
7173
def from_dict(cls, config_dict: Dict[str, Any]) -> Self:
@@ -91,6 +93,7 @@ def from_dict(cls, config_dict: Dict[str, Any]) -> Self:
9193
"table_tag": config_dict.get("table_tag"),
9294
"cluster_by": config_dict.get("cluster_by"),
9395
"immutable_where": config_dict.get("immutable_where"),
96+
"transient": config_dict.get("transient"),
9497
}
9598

9699
return super().from_dict(kwargs_dict) # type:ignore
@@ -117,6 +120,7 @@ def parse_relation_config(cls, relation_config: RelationConfig) -> Dict[str, Any
117120
"immutable_where": relation_config.config.extra.get( # type:ignore
118121
"immutable_where"
119122
),
123+
"transient": relation_config.config.extra.get("transient"), # type:ignore
120124
}
121125

122126
if refresh_mode := relation_config.config.extra.get("refresh_mode"): # type:ignore
@@ -172,6 +176,9 @@ def parse_relation_results(cls, relation_results: RelationResults) -> Dict[str,
172176
"table_tag": dynamic_table.get("table_tag"),
173177
"cluster_by": cluster_by,
174178
"immutable_where": immutable_where,
179+
# agate.Row.get() returns None when the column is absent, which is the
180+
# correct default -- it means "not queried" and skips transient comparison.
181+
"transient": dynamic_table.get("transient"),
175182
# we don't get initialize since that's a one-time scheduler attribute, not a DT attribute
176183
}
177184

@@ -232,6 +239,16 @@ def requires_full_refresh(self) -> bool:
232239
return False
233240

234241

242+
@dataclass(frozen=True, eq=True, unsafe_hash=True)
243+
class SnowflakeDynamicTableTransientConfigChange(RelationConfigChange):
244+
context: Optional[bool] = None
245+
246+
@property
247+
def requires_full_refresh(self) -> bool:
248+
# Transient cannot be changed via ALTER, requires full table recreation
249+
return True
250+
251+
235252
@dataclass
236253
class SnowflakeDynamicTableConfigChangeset:
237254
target_lag: Optional[SnowflakeDynamicTableTargetLagConfigChange] = None
@@ -242,6 +259,7 @@ class SnowflakeDynamicTableConfigChangeset:
242259
refresh_mode: Optional[SnowflakeDynamicTableRefreshModeConfigChange] = None
243260
immutable_where: Optional[SnowflakeDynamicTableImmutableWhereConfigChange] = None
244261
cluster_by: Optional[SnowflakeDynamicTableClusterByConfigChange] = None
262+
transient: Optional[SnowflakeDynamicTableTransientConfigChange] = None
245263

246264
@property
247265
def requires_full_refresh(self) -> bool:
@@ -261,6 +279,7 @@ def requires_full_refresh(self) -> bool:
261279
self.refresh_mode.requires_full_refresh if self.refresh_mode else False,
262280
self.immutable_where.requires_full_refresh if self.immutable_where else False,
263281
self.cluster_by.requires_full_refresh if self.cluster_by else False,
282+
self.transient.requires_full_refresh if self.transient else False,
264283
]
265284
)
266285

@@ -274,5 +293,6 @@ def has_changes(self) -> bool:
274293
self.refresh_mode,
275294
self.immutable_where,
276295
self.cluster_by,
296+
self.transient,
277297
]
278298
)

dbt-snowflake/src/dbt/include/snowflake/macros/materializations/dynamic_table.sql

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@
9191

9292

9393
{% macro snowflake__get_dynamic_table_configuration_changes(existing_relation, new_config) -%}
94-
{% set _existing_dynamic_table = snowflake__describe_dynamic_table(existing_relation) %}
94+
{% set _include_transient = new_config.get("transient") is not none %}
95+
{% set _existing_dynamic_table = adapter.describe_dynamic_table(existing_relation, _include_transient) %}
9596
{% set _configuration_changes = existing_relation.dynamic_table_config_changeset(_existing_dynamic_table, new_config.model) %}
9697
{% do return(_configuration_changes) %}
9798
{%- endmacro %}

dbt-snowflake/src/dbt/include/snowflake/macros/relations/dynamic_table/create.sql

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,16 @@
3131
A valid DDL statement which will result in a new dynamic info schema table.
3232
-#}
3333

34-
create dynamic table {{ relation }}
34+
{#- Determine transient: explicit config takes precedence, otherwise use behavior flag default -#}
35+
{%- if dynamic_table.transient is not none -%}
36+
{%- set is_transient = dynamic_table.transient -%}
37+
{%- elif adapter.behavior.snowflake_default_transient_dynamic_tables.no_warn -%}
38+
{%- set is_transient = true -%}
39+
{%- else -%}
40+
{%- set is_transient = false -%}
41+
{%- endif -%}
42+
{%- set transient_keyword = 'transient ' if is_transient else '' -%}
43+
create {{ transient_keyword }}dynamic table {{ relation }}
3544
target_lag = '{{ dynamic_table.target_lag }}'
3645
warehouse = {{ dynamic_table.snowflake_warehouse }}
3746
{{ optional('initialization_warehouse', dynamic_table.snowflake_initialization_warehouse) }}

dbt-snowflake/src/dbt/include/snowflake/macros/relations/dynamic_table/replace.sql

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,16 @@
4444
A valid DDL statement which will result in a new dynamic info schema table.
4545
-#}
4646

47-
create or replace dynamic table {{ relation }}
47+
{#- Determine transient: explicit config takes precedence, otherwise use behavior flag default -#}
48+
{%- if dynamic_table.transient is not none -%}
49+
{%- set is_transient = dynamic_table.transient -%}
50+
{%- elif adapter.behavior.snowflake_default_transient_dynamic_tables.no_warn -%}
51+
{%- set is_transient = true -%}
52+
{%- else -%}
53+
{%- set is_transient = false -%}
54+
{%- endif -%}
55+
{%- set transient_keyword = 'transient ' if is_transient else '' -%}
56+
create or replace {{ transient_keyword }}dynamic table {{ relation }}
4857
target_lag = '{{ dynamic_table.target_lag }}'
4958
warehouse = {{ dynamic_table.snowflake_warehouse }}
5059
{{ optional('initialization_warehouse', dynamic_table.snowflake_initialization_warehouse) }}

dbt-snowflake/tests/functional/relation_tests/dynamic_table_tests/models.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,3 +308,40 @@
308308
) }}
309309
select id, "NONE" from {{ ref('my_seed_none') }}
310310
"""
311+
312+
313+
# Transient dynamic table fixtures
314+
DYNAMIC_TABLE_TRANSIENT = """
315+
{{ config(
316+
materialized='dynamic_table',
317+
snowflake_warehouse='DBT_TESTING',
318+
target_lag='2 minutes',
319+
refresh_mode='INCREMENTAL',
320+
transient=True,
321+
) }}
322+
select * from {{ ref('my_seed') }}
323+
"""
324+
325+
326+
DYNAMIC_TABLE_NON_TRANSIENT = """
327+
{{ config(
328+
materialized='dynamic_table',
329+
snowflake_warehouse='DBT_TESTING',
330+
target_lag='2 minutes',
331+
refresh_mode='INCREMENTAL',
332+
transient=False,
333+
) }}
334+
select * from {{ ref('my_seed') }}
335+
"""
336+
337+
338+
# For testing default behavior (no explicit transient config)
339+
DYNAMIC_TABLE_DEFAULT_TRANSIENT = """
340+
{{ config(
341+
materialized='dynamic_table',
342+
snowflake_warehouse='DBT_TESTING',
343+
target_lag='2 minutes',
344+
refresh_mode='INCREMENTAL',
345+
) }}
346+
select * from {{ ref('my_seed') }}
347+
"""

0 commit comments

Comments
 (0)