もっと早く知っておけばよかったdbtを使うメリット
現在取り組んでいるデータ基盤構築プロジェクトで、初めてdbtを導入しました。
本記事では、その経験を踏まえ、プロジェクト目線で感じたdbtの利点を簡単にご紹介します。
本記事の目次は以下のとおりになります。
- dbtとは何か?なぜ必要か?
- dbtの構造と役割
-
なぜこの構造なのか?
3.1. 責任の分離
3.2. 依存関係の自動管理
3.3. 自動テスト実行 - Pythonとの比較
- 具体例:staging層の価値
- 効率性の比較
- まとめ:なぜdbtなのか
dbtとは何か?なぜ必要か
dbtの定義としては、データウェアハウスにあるデータに対し、SQLを用いてデータ変換を行うためのツールです。
下記(1)と(2)のコードを比較してみまよう。
# Pythonでやろうとすると...
import pandas as pd
from google.cloud import bigquery
# データ取得
client = bigquery.Client()
query = """
SELECT symbol, ts, open, high, low, close, volume, ingested_at
FROM `project.raw_fin.raw_prices`
WHERE symbol IS NOT NULL AND close > 0
"""
df = client.query(query).to_dataframe()
# データクリーニング
df['data_quality_flag'] = 'valid'
df.loc[df['close'] <= 0, 'data_quality_flag'] = 'invalid_price'
# ... 100行以上のコード
# BigQueryに書き戻し
df.to_gbq('project.dwh_fin.stg_prices', if_exists='replace')
-- stg_prices.sql(たった30行)
{{ config(materialized='view') }}
SELECT symbol, ts, open, high, low, close, volume,
CASE WHEN close <= 0 THEN 'invalid_price' ELSE 'valid' END as data_quality_flag
FROM {{ source('raw_fin','raw_prices') }}
WHERE symbol IS NOT NULL
上記の二つのコードからわかるように、SQLを用いるだけで、余分なコードを使わず、データの変換を簡潔かつきれいに行うことができます。
dbtの構造と役割
1. dbtにおいてSQLファイル (.sql) の役割
SQLの役割は、データ変換ロジックを定義することです。SELECT分やWITH句、CASEなどを使って、どの列を残すか、どのように変換・計算などをするか宣言的に記述します。つまり、データを「どう変換するか」直接書くのがSQLです。
{{ config(materialized='view') }}
WITH cleaned_data AS (
SELECT
symbol,
ts,
close,
-- ⭐ ビジネスロジックを宣言的に記述
CASE
WHEN close <= 0 THEN 'invalid_price'
WHEN high < low THEN 'invalid_ohlc'
ELSE 'valid'
END AS data_quality_flag
FROM {{ source('raw_fin','raw_prices') }}
)
SELECT * FROM cleaned_data WHERE data_quality_flag = 'valid'
dbtにおいてYML (.yml) の役割
YMLは、データ変換ロジックの設計・管理するためのメタデータを定義する役割を持っています。具体的には以下のようなことを担います。
モデルの定義:どのSQLファイルがどんな意味を持つのか?
カラムの説明:ドキュメンテーション
テストの設定:not nullや範囲チェックなど
ソース定義:出どころ
つまり、変換の仕様や品質保証を記述するのがYMLです。
version: 2
models:
- name: stg_prices
description: "クリーンな価格データ"
columns:
- name: close
description: "終値"
tests:
- not_null # ⭐ 自動テスト
- dbt_utils.accepted_range:
min_value: 0
なぜこの構造なのか
なぜこの構造になっているか、以下の三つに分けて見ていきます。
1)責任の分離
2)依存関係の自動管理
3)自動テスト実行
責任の分離
ファイル | 責任 | 例 |
---|---|---|
.sql | 何を作るか(ロジック) | データ変換、集計、結合など |
.yml | 品質をどう保つか(メタデータ) | テスト、ドキュメント、型定義 |
依存関係の自動管理
dbtでは、source()やraw()を使うと、テーブルの依存関係が自動的に決定されます。
例えば、以下のコードを見ると、stg_prices.sqlは「raw_pricesに依存している」と宣言しており、daily_calculate.sqlは、「stg_pricesに依存している」と宣言しています。
- models/staging/stg_prices.sql
FROM {{ source('raw_fin','raw_prices') }} -- ⬅️ RAWデータに依存
-- models/marts/daily_calculate.sql
FROM {{ ref('stg_prices') }} -- ⬅️ stagingに依存
自動テスト実行
YMLファイルでカラムごとに品質ルールを宣言できます。
# schema.yml
tests:
- not_null
- unique
- accepted_values: ['valid', 'invalid']
以上のコードのように設定しておけば、dbt testを実行するだけで、
・NULLがあるか?
・重複があるか?
・値が指定したリストに収まっているか?
などの内容を自動的に検証してくれます。
Pythonとの比較
次に、Pythonと比較してみてみます。
以下のコードを見てわかるように、Pythonで実装すると、依存関係管理・データ品質チェック・保存処理・実行順序をすべて自分で制御しないといけません。したがって、ミスや抜け漏れが起こりやすくて、運用コストも高くなります。
# 手動でやらないといけないこと
def clean_prices():
# 1. 依存関係を手動管理
raw_df = get_raw_prices() # この順序を間違えるとエラー
# 2. データ品質チェックを手動実装
if raw_df['close'].isnull().any():
raise ValueError("NULL値があります")
# 3. 変換ロジック
cleaned_df = raw_df[raw_df['close'] > 0]
# 4. 手動でBigQueryに保存
cleaned_df.to_gbq('project.dwh_fin.stg_prices')
# 5. 次の処理も手動で呼び出し
create_daily_calculate(cleaned_df)
def create_daily_calculate(input_df):
# また同じことを繰り返し...
一方、dbtでは処理を「宣言的」に記述できるため、依存関係の解決・品質チェック・保存・実行順序を自動で最適化してくれます。その結果、Pythonで毎回ゴリゴリと手続き的なコードを書く必要がなくなり、再現性と保守性の高いデータ基盤を構築できます。
SELECT symbol, close,
CASE WHEN close > 0 THEN 'valid' ELSE 'invalid' END as flag
FROM {{ ref('stg_prices') }}
具体例:staging層の価値
staging層を例に、具体的に見ていきます。
例えば、金、銀、原油など複数の商品データを分析したいとします。
Pythonで直接 raw_prices を読み込むと、以下のコードのようになります。
# analyze_gold.py
df = pd.read_gbq("SELECT * FROM raw_prices WHERE symbol='GC=F'")
df = df[df['close'] > 0] # クリーニング処理(重複)
df['daily_return'] = df['close'].pct_change()
# analyze_silver.py
df = pd.read_gbq("SELECT * FROM raw_prices WHERE symbol='SI=F'")
df = df[df['close'] > 0] # また同じ処理をコピペ
df['daily_return'] = df['close'].pct_change()
以上のやり方の問題点として以下あげることができます。
・同じクリーニングロジックを何度もコピペ
・分析対象が増えるたびに「また同じ処理」を書く必要がある
・修正が入ったら、すべてのスクリプトに反映しなければならない
一方、dbtのstaging層を使う場合、staging層に「共通のクリーニング処理」を一度だけ定義します。
-- stg_prices.sql
SELECT symbol, ts, close,
CASE WHEN close > 0 THEN 'valid' ELSE 'invalid' END AS flag
FROM {{ source('raw_fin','raw_prices') }}
WHERE flag = 'valid'
すると、後続の分析モデルでは「きれいにしたデータ」だけを呼び出せばOKです:
-- gold_analysis.sql
SELECT ts, close,
LAG(close) OVER(...) - 1 AS daily_return
FROM {{ ref('stg_prices') }}
WHERE symbol = 'GC=F';
dbtを使うと以下のメリットをあげることができます。
・クリーニング処理を一度だけ書けばいい
・金でも銀でも石油でも、同じstg_pricesを参照するだけ
・修正があればstg_prices.sqlを直すだけで全体に反映される
効率の比較
以下の表に、効率性の比較をまとめました。
観点 | Python | dbt |
---|---|---|
コード量 | 多い(100行+) | 少ない(20−30行) |
再利用性 | 低い | 高い(staging層で共通化) |
テスト | 手動実装 | 自動生成 |
依存関係 | 手動管理 | 自動管理 |
ドキュメント | 別途作成 | 自動生成 |
バージョン管理 | 複雑 | シンプル |
まとめ:なぜdbtなのか
1)モジュール化:staging層で共通のクリーニングロジック
2)自動化:依存関係とテストの自動実行
3)宣言的:SQLで「何を作るか」だけ記述
4)品質保証:YMLでテストとドキュメントを定義
5)チーム開発:標準化された構造
Pythonでもできるけれども、dbtはSQL中心の「データパイプライン専用フレームワーク」として遥かに効率的であることを実感させられました。