dbtをもう一歩深掘りしてみた ~マテリアライゼーション・インクリメンタルモデル・Snapshotsまで~
はじめに
以前、「dbtとは?SQLでデータを加工するだけじゃない?仕組みを調べてみた」という記事を書きました。
前回の記事では、
- dbtはSQLによるデータ変換処理を、ソフトウェア開発のように管理・実行するためのツールであること
- SQLファイルを
Modelという単位で管理すること -
ref()でモデル同士の依存関係を表現できること -
source()で外部のデータを参照できること -
dbt runでモデルを実行できること - テストやドキュメント化、Gitによるバージョン管理にも対応していること
について、Fivetran・Snowflakeとの役割分担を中心にざっくり整理しました。
ただ、前回はあくまで「dbtとは何か」という全体像の紹介が中心で、
-
dbt runのたびに毎回テーブルを全部作り直しているの?大量データだと重くならない? - モデルには
tableやview以外にどんな種類があるの? - 「過去の状態」を記録したい場合はどうすればいいの?
- テストって具体的にどうやって書くの?
- SQLの中で使っていた
{{ }}って結局何なの?
といった、実際にdbtプロジェクトを触るときに気になる疑問には踏み込めていませんでした。そこで今回は、前回の記事からもう一歩踏み込んで、dbtの「マテリアライゼーション」「インクリメンタルモデル」「Snapshots」「テスト」「Jinjaとマクロ」について調べながら整理してみました。
この記事はこんな方におすすめ
- 前回の記事でdbtの全体像はつかめたので、実際の使い方をもう少し知りたい方
-
dbt runのたびにテーブル全体を作り直すのが非効率そうで気になっている方 - dbtで過去のデータの状態を記録する方法を知りたい方
- dbtのテスト機能を具体的にどう書くのか知りたい方
- SQLファイルの中の
{{ }}や{% %}が何なのか気になっている方
まず前回のおさらい
前回の記事のポイントを簡単に振り返ります。
SQLファイル(Model)
↓
dbt
↓
SnowflakeへSQLを実行
↓
Table / Viewなどを作成
- dbt自身がデータを加工しているわけではなく、Snowflakeなどのデータウェアハウスの計算能力を利用してSQLを実行している
-
ref()でモデル同士の依存関係を、source()で外部データとの関係を表現できる -
dbt run/dbt test/dbt buildといったコマンドでモデルを実行・検証できる - Fivetran(運ぶ)→ Snowflake(貯める・処理する)→ dbt(加工する)という役割分担がある
前回は、この「dbtが何をしてくれるか」という全体像の紹介にとどまっていました。今回はまず、dbtが実際にどうやってテーブルを作っているのか、という「マテリアライゼーション」の中身から深掘りしていきます。
マテリアライゼーションを深掘りする
前回、「dbtを実行するとSnowflake上にTableやViewが作られる」と紹介しましたが、実はこの「作り方」にはtable・view・incremental・ephemeralという4種類のマテリアライゼーションがあることが分かりました。(dbt Developer Hub)
| 種類 | 動作 |
|---|---|
| view |
SELECT文をそのままViewとして保存する。実行のたびにViewが再定義される |
| table | 実行のたびにテーブルを丸ごと作り直す(CREATE TABLE AS SELECT相当) |
| incremental | 初回はテーブルを丸ごと作るが、2回目以降は新しい・変更された行だけを追記・更新する |
| ephemeral | データベース上にオブジェクトを作らず、SQLがCTE(WITH句)として下流のモデルに埋め込まれる |
マテリアライゼーションはモデルの先頭で以下のように指定します。
{{
config(
materialized='table'
)
}}
select
customer_id,
sum(amount) as total_amount
from {{ ref('stg_orders') }}
group by 1
前回紹介したcustomer_sales.sqlのようなモデルも、実は何も指定しなければデフォルトでviewとして作られます。集計対象のデータ量が多く、参照のたびに毎回集計し直すと重くなるような場合はtableに、逆に中間的な変換ステップで実体を持たせる必要がない場合はephemeralにする、といった使い分けが考えられます。
インクリメンタルモデルの仕組みを深掘りする
冒頭で挙げた「dbt runのたびに全部作り直しているの?」という疑問に、まさに答えてくれるのがincrementalマテリアライゼーションです。
インクリメンタルモデルは、初回実行時はテーブルを全件分作りますが、2回目以降の実行では新しい・変更された行だけを対象にテーブルを更新します。(dbt Developer Hub)
1回目のdbt run
↓
全件を変換してテーブルを作成
2回目以降のdbt run
↓
前回の実行以降に増えた/変わった行だけを抽出
↓
既存テーブルへ追記・更新
これを実現しているのがis_incremental()というマクロです。
{{
config(
materialized='incremental',
unique_key='order_id'
)
}}
select
order_id,
customer_id,
amount,
updated_at
from {{ ref('stg_orders') }}
{% if is_incremental() %}
-- この条件は2回目以降の実行時のみ適用される
where updated_at >= (select coalesce(max(updated_at), '1900-01-01') from {{ this }})
{% endif %}
is_incremental()は、以下の条件をすべて満たすときにTrueを返します。(dbt Developer Hub)
- 対象のテーブルがすでにデータベース上に存在する
-
--full-refreshフラグが指定されていない - モデルが
materialized='incremental'に設定されている
ポイントは、{% if is_incremental() %}のブロックの中に書いたSQLは2回目以降の実行時にしか使われないという点です。初回実行時はこのWHERE句を無視して全件が対象になります。
unique_keyで「更新」を表現する
上のコード例ではunique_key='order_id'を指定しています。これを指定すると、新しく取得した行のorder_idが既存テーブルにすでに存在する場合は、行を追加するのではなくその行を置き換えるという挙動になります。(dbt Developer Hub)
逆にunique_keyを指定しない場合、多くのアダプタではdbtは単純に行を追記するだけのappendという動作になり、同じ注文が2回登録されるようなケースでは重複行が発生してしまいます。(dbt Developer Hub)
unique_keyあり
↓
新しい行のキーが既存テーブルにある → 既存行を更新
新しい行のキーが既存テーブルにない → 新規行として挿入
unique_keyなし(append)
↓
新しい行を無条件に追記(重複の可能性あり)
スキーマが変わったらどうなる?
前回の元記事を書いたあとに実際に触っていて気になったのが、「モデルのSELECT文にカラムを追加したらどうなるのか」という点でした。調べてみると、これはon_schema_changeという設定で制御できることが分かりました。(dbt Developer Hub)
| 設定値 | 動作 |
|---|---|
| ignore(デフォルト) | カラムを追加しても反映されない。カラムを削除するとdbt runが失敗する |
| fail | スキーマの差分を検知した時点でエラーにする |
| append_new_columns | 新しいカラムだけ既存テーブルに追加する(削除されたカラムは残る) |
| sync_all_columns | 新しいカラムの追加・削除・型変更をすべて反映する |
なお、on_schema_changeのどの設定を使っても、新しく追加されたカラムの過去分の値は自動的には埋まらないという点は注意が必要だと分かりました。過去の行に値を入れたい場合は、手動でUPDATEするか、--full-refreshでテーブルを作り直す必要があります。(dbt Developer Hub)
インクリメンタルモデルのロジック自体を変更した場合も、過去に作られたデータと新しいロジックで食い違いが起きる可能性があるため、dbt run --full-refresh --select モデル名+のようにテーブルを一度丸ごと作り直すのが推奨されています。末尾の+をつけると、そのモデルより下流(依存している)モデルも一緒にフルリフレッシュされます。(dbt Developer Hub)
Snapshotsで「過去の状態」を記録する
前回の記事では触れていませんでしたが、調べてみるとdbtにはSnapshotsという、テーブルの状態の変化を時系列で記録する仕組みがあることが分かりました。
例えば、「顧客のプランが先月はFreeだったが今月Proに変わった」というような変化を追跡したい場合、通常のテーブルは「今の状態」しか持っていないため、いつプランが変わったのかが分からなくなってしまいます。
時点1: customer_id=1, plan=Free
↓(プラン変更)
時点2: customer_id=1, plan=Pro
普通のテーブル
↓
上書きされて "plan=Pro" しか残らない
Snapshot
↓
plan=Free(有効期間: 時点1~時点2)
plan=Pro(有効期間: 時点2~現在)
の両方が残る
これは一般に**SCD Type2(Slowly Changing Dimension Type2)**と呼ばれる設計で、dbtのSnapshotsはこれを自動化してくれる機能です。変化を検知する方法として、主にtimestamp戦略とcheck戦略の2種類があります。
-
timestamp戦略:updated_atのような信頼できる更新日時カラムがある場合に使う。前回より新しいタイムスタンプの行があれば変更とみなす -
check戦略: 更新日時カラムがない場合に使う。指定したカラムの値そのものを比較し、変わっていれば変更とみなす
{% snapshot customers_snapshot %}
{{
config(
target_schema='snapshots',
unique_key='customer_id',
strategy='timestamp',
updated_at='updated_at',
)
}}
select * from {{ source('app', 'customers') }}
{% endsnapshot %}
dbt snapshotコマンドを実行するたびに、変更があった行については「古い行の有効期間を閉じて、新しい行を追加する」という処理が行われ、dbt_valid_from / dbt_valid_toのようなカラムに有効期間が記録されます。プランの変更履歴や、注文ステータスの遷移履歴を追いたい場合などに使える機能だと分かりました。
dbt testを深掘りする
前回の記事では「NULLではないか」「値が一意になっているか」といったテストがあることを紹介しましたが、今回はもう少し具体的な書き方を調べてみました。dbtのテストには大きく分けてGeneric TestとSingular Testの2種類があります。
Generic Test(汎用テスト)
YAMLファイルの中でカラムに対して宣言的に指定するテストです。dbtには標準で以下の4つが組み込まれています。
models:
- name: customer_sales
columns:
- name: customer_id
tests:
- unique
- not_null
- name: plan
tests:
- accepted_values:
values: ['Free', 'Pro', 'Enterprise']
- name: customer_id
tests:
- relationships:
to: ref('stg_customers')
field: customer_id
-
unique: カラムの値が重複していないか -
not_null: NULLが含まれていないか -
accepted_values: 指定したリスト以外の値が入っていないか -
relationships: 参照先のテーブルに対応する値が存在するか(外部キーのような整合性チェック)
Singular Test(個別テスト)
もう少し複雑な条件をチェックしたい場合は、testsフォルダにSQLファイルを置くSingular Testを使います。「クエリの実行結果が0行であればテスト成功」という考え方で書きます。
-- tests/assert_total_amount_is_positive.sql
select *
from {{ ref('customer_sales') }}
where total_amount < 0
このSQLが1行でも返してしまう場合、そのテストは失敗扱いになります。「合計金額がマイナスになっている行が存在しないこと」のような、ビジネスルールに近いチェックをSQLで自由に書けるのがSingular Testの特徴です。
dbt_utilsのようなパッケージを追加すると、dbt_utils.equal_rowcount(2つのモデルの行数が一致するか)のような、より高度なGeneric Testも使えるようになります。
Jinjaとマクロ、パッケージ管理
前回の記事で{{ ref('stg_orders') }}という書き方が出てきましたが、この{{ }}はJinjaというPython製のテンプレートエンジンの記法です。dbtはSQLファイルをそのまま実行しているのではなく、Jinjaで一度「コンパイル」してから実際のSQLとしてデータウェアハウスに送っている、という仕組みだと分かりました。
SQLファイル(Jinja入り)
↓
dbtがJinjaを解決してコンパイル
↓
純粋なSQL
↓
Snowflakeなどへ送信・実行
ref()やsource()、is_incremental()はdbtがあらかじめ用意している関数(マクロ)ですが、自分でマクロを定義することもできます。
-- macros/cents_to_dollars.sql
{% macro cents_to_dollars(column_name) %}
({{ column_name }} / 100)
{% endmacro %}
-- モデルの中での利用例
select
order_id,
{{ cents_to_dollars('amount_cents') }} as amount_dollars
from {{ ref('stg_orders') }}
同じような変換処理を複数のモデルで使い回したい場合、マクロとして切り出しておくとSQLの重複を減らせます。さらに、こうした便利なマクロをまとめたdbt_utilsのようなパッケージを、packages.ymlに追記してdbt depsコマンドでインストールし、社内・社外を問わず再利用することもできます。
# packages.yml
packages:
- package: dbt-labs/dbt_utils
version: [">=1.0.0", "<2.0.0"]
2026年9月の大きな変化:Fivetranとdbt Labsが統合
今回調べていて驚いたのが、2026年にFivetranとdbt Labsが合併し、1つの会社になっていたことです。両社は「分析を信頼できるものにするデータ基盤は、AIを信頼できるものにするデータ基盤と同じである」という考え方のもと、データ連携(Fivetran)とデータ変換(dbt)を一体で提供する方向へ進んでいるようです。(dbt Labs)
あわせて、2026年9月にはdbt v2.0がGA(正式リリース)となったことも分かりました。以前からRustで書き直された高速な実行エンジンであるdbt Fusion engineの開発が進められていましたが、v2.0ではこのFusionエンジンが正式な標準エンジンとなり、従来のPython製エンジンに比べてパース処理が最大30倍高速化されているとのことです。これまで使われてきたdbt Coreは「dbt v1」という位置づけになり、Apache 2.0ライセンスのオープンソースとして提供が続けられています。(dbt Labs)
また、前回のdbt runごとに差分を検知して不要な再実行をスキップするdbt Stateという機能もGAになっており、これを使うことでウェアハウスのコンピュート利用量を平均15〜30%以上削減できるケースがある、と紹介されていました。(dbt Labs)
これまで
↓
Fivetran(データ連携)と dbt(データ変換)は別々の会社・別々のエンジン
2026年9月以降
↓
Fivetran + dbt Labsが統合
↓
dbt v2.0(Fusion engine)に一本化
↓
データ連携からデータ変換までを一貫した基盤として提供
前回の記事で「Fivetran = 運ぶ、Snowflake = 貯める・処理する、dbt = 加工する」という役割分担を紹介しましたが、Fivetranとdbtが同じ会社の製品になったことで、今後はこの2つの連携がより深くなっていきそうだと感じました。
個人的に調べてみて思ったこと
前回は、dbtについて「SQLによるデータ変換をソフトウェア開発のように管理するツール」という理解にとどまっていました。今回さらに調べてみると、
-
table/view/incremental/ephemeralという4種類のマテリアライゼーションがあり、データ量や用途によって使い分けること - インクリメンタルモデルは
is_incremental()とunique_keyの組み合わせで「新規行の追加」と「既存行の更新」を制御していること -
on_schema_changeの設定次第で、カラムの追加・削除にどう対応するかが変わること - Snapshotsを使うと、テーブルの「今の状態」だけでなく「過去の状態の変化」まで記録できること
- テストにはYAMLで宣言的に書くGeneric Testと、SQLで自由に書けるSingular Testがあること
-
{{ }}の正体はJinjaというテンプレートエンジンで、マクロやパッケージによってSQLの再利用ができること
が分かりました。特に、単に「SQLをファイルで管理できる」というだけでなく、インクリメンタル処理やSnapshotsのように、データウェアハウスの計算量やストレージを意識した設計がされている点は、実際に大規模なデータを扱う場面を想定しているのだと実感しました。
まだ実際に自分の手を動かしてインクリメンタルモデルやSnapshotsを試したり、dbt v2.0のFusion engineの速度を体感したりはできていないので、今後は手元の環境で検証していきたいと思います。
まとめ
今回は、dbtについて前回の記事よりもさらに一段深掘りしてみました。重要なポイントをまとめると、
- マテリアライゼーションには
table/view/incremental/ephemeralの4種類があり、用途に応じて使い分ける - インクリメンタルモデルは
is_incremental()とunique_keyで「差分だけ処理する」仕組みを実現している -
on_schema_changeでスキーマ変更時の挙動(無視・エラー・カラム追加のみ・完全同期)を制御できる - Snapshotsを使うと、SCD Type2形式でテーブルの過去の状態を記録できる
- テストにはYAMLで書くGeneric Testと、SQLで書くSingular Testがある
- Jinjaによってマクロやパッケージでの再利用が可能になっている
- 2026年にFivetranとdbt Labsが統合し、dbt v2.0(Fusion engine)としてデータ連携からデータ変換までの一体化が進んでいる
前回は「dbtとは何か」という全体像の紹介でしたが、今回はそれを実際にプロジェクトで使ううえで欠かせない、マテリアライゼーションの使い分けやインクリメンタル処理、テストの書き方まで踏み込むことができました。
同じようにdbtを勉強中の方の参考になれば幸いです。
最後まで読んでいただき、ありがとうございました!