Skip to content

Commit 2ca2a96

Browse files
authored
feat(snowflake): add transient tmp relation type for incremental models (#1894)
1 parent 3070dea commit 2ca2a96

4 files changed

Lines changed: 181 additions & 23 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
kind: Features
2+
body: Add `tmp_relation_type=transient` support for incremental models, enabling Snowflake lineage tracking; add `snowflake__resolve_incremental_tmp_relation` dispatch macro for controlling tmp relation destination
3+
time: 2026-04-24T00:00:00.000000-00:00
4+
custom:
5+
Author: b-per
6+
Issue: "1893"

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

Lines changed: 67 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,48 +12,64 @@
1212
1313
Low-level specifics:
1414
If an invalid option is specified, then we will raise an
15-
excpetion with corresponding message.
15+
exception with a corresponding message.
1616
1717
Languages other than SQL (like Python) will use a temporary table.
18-
With the default strategy of merge, the user may choose between a temporary
19-
table and view (defaulting to view).
20-
21-
The append strategy can use a view because it will run a single INSERT statement.
22-
23-
When unique_key is none, the delete+insert and microbatch strategies can use a view beacuse a
24-
single INSERT statement is run with no DELETES as part of the statement.
25-
Otherwise, play it safe by using a temporary table.
26-
27-
Catalog-linked databases (Iceberg tables) does not support using temporary relations.
18+
With the default strategy of merge, the user may choose between a
19+
temporary table and view (defaulting to view).
20+
21+
The append strategy can use a view because it will run a single INSERT
22+
statement.
23+
24+
When unique_key is none, the delete+insert and microbatch strategies
25+
can use a view because a single INSERT statement is run with no DELETES
26+
as part of the statement. Otherwise, play it safe by using a table.
27+
28+
Catalog-linked databases (Iceberg tables) do not support temporary
29+
relations or transient tables — only Iceberg tables are allowed. A
30+
permanent table is used as the tmp relation for CLD models.
31+
32+
'transient' is also available as a user-facing tmp_relation_type for
33+
non-Iceberg models. Unlike session-scoped temporary tables, transient
34+
tables are visible to Snowflake's lineage tracking. Note that transient
35+
tables share the regular schema namespace; use the
36+
snowflake__resolve_incremental_tmp_relation dispatch macro to redirect
37+
tmp relations to a dedicated schema to avoid name collisions when
38+
multiple runs share the same target schema.
2839
#} */
2940

30-
{#-- Always use table for catalog-linked databases (Iceberg) --#}
31-
{% if snowflake__is_catalog_linked_database(relation=config.model) %}
32-
{{ return("table") }}
33-
{% endif %}
34-
3541
{% if language == "python" and tmp_relation_type is not none %}
3642
{% do exceptions.raise_compiler_error(
3743
"Python models currently only support 'table' for tmp_relation_type but "
3844
~ tmp_relation_type ~ " was specified."
3945
) %}
4046
{% endif %}
4147

42-
{% if strategy in ["delete+insert", "microbatch"] and tmp_relation_type is not none and tmp_relation_type != "table" and unique_key is not none %}
48+
{#-- Python always uses a temporary table, regardless of other conditions --#}
49+
{% if language != "sql" %}
50+
{{ return("table") }}
51+
{% endif %}
52+
53+
{#-- CLD schemas only support Iceberg tables; use table (not transient) --#}
54+
{% if snowflake__is_catalog_linked_database(relation=config.model) %}
55+
{{ return("table") }}
56+
{% endif %}
57+
58+
{% if strategy in ["delete+insert", "microbatch"] and tmp_relation_type is not none and tmp_relation_type not in ("table", "transient") and unique_key is not none %}
4359
{% do exceptions.raise_compiler_error(
4460
"In order to maintain consistent results when `unique_key` is not none,
45-
the `" ~ strategy ~ "` strategy only supports `table` for `tmp_relation_type` but "
61+
the `" ~ strategy ~ "` strategy only supports `table` or `transient` for `tmp_relation_type` but "
4662
~ tmp_relation_type ~ " was specified."
4763
)
4864
%}
4965
{% endif %}
5066

51-
{% if language != "sql" %}
52-
{{ return("table") }}
53-
{% elif tmp_relation_type == "table" %}
67+
{% if tmp_relation_type == "table" %}
5468
{{ return("table") }}
5569
{% elif tmp_relation_type == "view" %}
5670
{{ return("view") }}
71+
{% elif tmp_relation_type == "transient" %}
72+
{{ return("transient") }}
5773
{% elif strategy in ("default", "merge", "append", "insert_overwrite") %}
5874
{{ return("view") }}
5975
{% elif strategy in ["delete+insert", "microbatch"] and unique_key is none %}
@@ -63,6 +79,27 @@
6379
{% endif %}
6480
{% endmacro %}
6581

82+
83+
{% macro resolve_incremental_tmp_relation(tmp_relation) %}
84+
{{ return(adapter.dispatch('resolve_incremental_tmp_relation', 'dbt')(tmp_relation)) }}
85+
{% endmacro %}
86+
87+
88+
{% macro snowflake__resolve_incremental_tmp_relation(tmp_relation) %}
89+
{#--
90+
Override this macro in your project to control where the incremental
91+
tmp relation is created. Useful for redirecting to a dedicated scratch
92+
schema to avoid name collisions when multiple runs share the same
93+
target schema.
94+
95+
Example:
96+
{% macro snowflake__resolve_incremental_tmp_relation(tmp_relation) %}
97+
{{ return(tmp_relation.incorporate(schema='scratch')) }}
98+
{% endmacro %}
99+
--#}
100+
{{ return(tmp_relation) }}
101+
{% endmacro %}
102+
66103
{% materialization incremental, adapter='snowflake', supported_languages=['sql', 'python'] -%}
67104

68105
{% set original_query_tag = set_query_tag() %}
@@ -94,8 +131,11 @@
94131
{% if is_catalog_linked_db %}
95132
{% set tmp_relation = make_temp_relation(this).incorporate(type=tmp_relation_type, catalog=catalog_relation.catalog_name, is_table=true) %}
96133
{% else %}
97-
{% set tmp_relation = make_temp_relation(this).incorporate(type=tmp_relation_type) %}
134+
{#-- Transient tables are dropped with DROP TABLE, so the relation type must be 'table' --#}
135+
{% set tmp_relation_object_type = 'table' if tmp_relation_type == 'transient' else tmp_relation_type %}
136+
{% set tmp_relation = make_temp_relation(this).incorporate(type=tmp_relation_object_type) %}
98137
{% endif %}
138+
{% set tmp_relation = resolve_incremental_tmp_relation(tmp_relation) %}
99139

100140
{% set grant_config = config.get('grants') %}
101141

@@ -131,7 +171,7 @@
131171
%}
132172

133173
{% else %}
134-
{#-- Create the temp relation, either as a view or as a temp table --#}
174+
{#-- Create the temp relation as a view, temp table, or transient table --#}
135175
{% if is_catalog_linked_db %}
136176
{%- call statement('create_tmp_relation', language=language) -%}
137177
{{ create_table_as(False, tmp_relation, compiled_code, language) }}
@@ -140,6 +180,10 @@
140180
{%- call statement('create_tmp_relation') -%}
141181
{{ snowflake__create_view_as_with_temp_flag(tmp_relation, compiled_code, True) }}
142182
{%- endcall -%}
183+
{% elif tmp_relation_type == 'transient' %}
184+
{%- call statement('create_tmp_relation', language=language) -%}
185+
{{ snowflake__create_table_transient_sql(tmp_relation, compiled_code) }}
186+
{%- endcall -%}
143187
{% else %}
144188
{%- call statement('create_tmp_relation', language=language) -%}
145189
{{ create_table_as(True, tmp_relation, compiled_code, language) }}

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,36 @@
3535
{% endmacro %}
3636

3737

38+
{% macro snowflake__create_table_transient_sql(relation, compiled_code) -%}
39+
{#-
40+
Implements CREATE TRANSIENT TABLE ... AS SELECT for use as an incremental
41+
tmp relation. Unlike session-scoped temporary tables, transient tables
42+
persist in the catalog (enabling Snowflake lineage tracking) but have no
43+
fail-safe period, avoiding the storage costs of permanent tables.
44+
https://docs.snowflake.com/en/sql-reference/sql/create-table
45+
-#}
46+
47+
{%- set contract_config = config.get('contract') -%}
48+
{%- if contract_config.enforced -%}
49+
{{- get_assert_columns_equivalent(compiled_code) -}}
50+
{%- set compiled_code = get_select_subquery(compiled_code) -%}
51+
{%- endif -%}
52+
53+
{%- set sql_header = config.get('sql_header', none) -%}
54+
{{ sql_header if sql_header is not none }}
55+
56+
create or replace transient table {{ relation }}
57+
{%- if contract_config.enforced %}
58+
{{ get_table_columns_and_constraints() }}
59+
{%- endif %}
60+
as (
61+
{{ compiled_code }}
62+
)
63+
;
64+
65+
{%- endmacro %}
66+
67+
3868
{% macro snowflake__create_table_temporary_sql(relation, compiled_code) -%}
3969
{#-
4070
Implements CREATE TEMPORARY TABLE and CREATE TEMPORARY TABLE ... AS SELECT:
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import pytest
2+
from dbt.tests.util import run_dbt, run_dbt_and_capture
3+
4+
5+
_MODEL_TRANSIENT_TMP = """
6+
{{ config(
7+
materialized='incremental',
8+
unique_key='id',
9+
tmp_relation_type='transient',
10+
) }}
11+
12+
select 1 as id, 'alice' as name
13+
{% if is_incremental() %}
14+
union all
15+
select 2 as id, 'bob' as name
16+
{% endif %}
17+
"""
18+
19+
_MODEL_TRANSIENT_TMP_DELETE_INSERT = """
20+
{{ config(
21+
materialized='incremental',
22+
incremental_strategy='delete+insert',
23+
unique_key='id',
24+
tmp_relation_type='transient',
25+
) }}
26+
27+
select 1 as id, 'alice' as name
28+
{% if is_incremental() %}
29+
union all
30+
select 2 as id, 'bob' as name
31+
{% endif %}
32+
"""
33+
34+
35+
class TestIncrementalTransientTmpRelation:
36+
"""tmp_relation_type='transient' creates a transient (not session-scoped) staging
37+
table, enabling Snowflake lineage tracking while avoiding permanent-table
38+
fail-safe storage costs."""
39+
40+
@pytest.fixture(scope="class")
41+
def models(self):
42+
return {"transient_incremental.sql": _MODEL_TRANSIENT_TMP}
43+
44+
def test_incremental_transient(self, project):
45+
run_dbt(["run"])
46+
result = project.run_sql(
47+
"select count(*) as cnt from {database}.{schema}.transient_incremental",
48+
fetch="one",
49+
)
50+
assert result[0] == 1
51+
52+
_, logs = run_dbt_and_capture(["--debug", "run"])
53+
assert "create or replace transient table" in logs.lower()
54+
55+
result = project.run_sql(
56+
"select count(*) as cnt from {database}.{schema}.transient_incremental",
57+
fetch="one",
58+
)
59+
assert result[0] == 2
60+
61+
run_dbt(["test"])
62+
63+
64+
class TestIncrementalTransientTmpRelationDeleteInsert:
65+
"""transient tmp_relation_type is allowed for delete+insert strategy since
66+
transient tables are stable across multiple statements, unlike views."""
67+
68+
@pytest.fixture(scope="class")
69+
def models(self):
70+
return {"transient_delete_insert.sql": _MODEL_TRANSIENT_TMP_DELETE_INSERT}
71+
72+
def test_incremental_transient_delete_insert_runs(self, project):
73+
run_dbt(["run"])
74+
75+
_, logs = run_dbt_and_capture(["--debug", "run"])
76+
assert "create or replace transient table" in logs.lower()
77+
78+
run_dbt(["test"])

0 commit comments

Comments
 (0)