⚔️

dbtとDataformを緩く比較してみた

に公開

こんにちは!ゲンシュンです。
datatech-jpアドベントカレンダー2025の1日目の記事です!トップバッター頑張ります!

はじめに

データ基盤だとdbtとDataformがよく使われるツールですが、ぶっちゃけdbtがデファクトスタンダードになっており、基盤を今から作るならdbtを選ばれる方が多いと思ってます。前職ではdbtを、現職ではdataformを使いまして、両方の開発体験されてる方の記事ってあんまり見かけないので、今回は実体験ベースで比較記事を書いてみました。
自分がDataformを使い倒しきれていないのもあり、どっちが優れているのか?という比較ではなく、こういう感じで実装して使うんだよ〜ぐらいの緩めな内容となってます。網羅的に機能を比較したりもっと技術的な詳細記事はいつか書きます!

この記事の前提

dbtは dbt Core を使ったローカル開発を、dataformは dataform-cli を使ったローカル開発とGoogleCloudのコンソール上の開発を紹介します。どちらもBigQueryのデータを使ってBigQueryに出力してます。記事で使った実装コードは公開リポジトリにて用意しています。 各々のベストプラクティスにのっとっておらず、一旦手っ取り早く見たり手を動かしたりする雑コードとなってます 。また今回サンプルデータとしてBigQueryの公開データセットである bigquery-public-data.thelook_ecommerce を使いましたが、モデリングめっちゃ適当です。雑にdimensionとfactテーブル作って各機能を紹介してます。

  • この記事のターゲット
    • dbtを触ったことある方が、dataformのことを雰囲気知りたい
    • dataformを触ったことある方が、dbtのことを雰囲気知りたい
  • この記事に書いてあること
    • dbtとdataformの書き方
    • それぞれの機能の使い方
    • 所感
  • この記事で書かないこと
    • 各々のベストプラクティス
    • サンプルデータのデータモデリング周り

モデルの書き方

どちらも「SQL + 設定ファイル + お作法」で作れちゃうので学習コスト低いと思います。雛形があれば、一旦動くもの自体はサクッと作れちゃいます。

dbtでのモデルの実装

SQLファイルとYAMLファイルで実装します。SQLのファイル名=モデル名=テーブル名という雑なイメージを持つとわかりやすいかも!

YAMLファイルにドキュメントとテストを記載します。テーブル定義書とテスト項目、両方兼ねられるので結構YAMLを書くことになると思います。(markdownの段落記法と若干違うので、今でも不要な - を書いちゃいます。。。)今回は網羅的にYAMLファイル書きましたが、sourcdeについてはデータセットとテーブル名が、modelについてはモデル名が必須で、descriptionやcolumns、testは書かなくても問題ないです。ですがここ網羅的に書くと良いことあるので、それは後述します。

models/source.yml
version: 2
# sourceはデータソースの定義
sources:
  - name: thelook_ecommerce # dbt上での宣言名。わかりやすいようにschemaと同じにしてます
    database: bigquery-public-data # プロジェクトID相当
    schema: thelook_ecommerce # データソースのスキーマ名(BigQueryのデータセット名)
    description: BigQuery公開データセット
    tables: # 以下テーブル名とカラムの定義やら実行したいテストを記載
      - name: products
        description: 商品マスタ
        columns:
          - name: id
            description: 商品ID
            tests:
              - unique
              - not_null
          - name: name
            description: 商品名              
      - name: orders
        description: 注文ヘッダ
        columns:
          - name: order_id
            description: 注文ID
          - name: user_id
            description: ユーザーID
models/schema.yml
# dbtで作るデータモデルの定義
models:
  - name: stg_users #モデル名=SQLのファイル名
    description: ユーザーマスタのステージングテーブル
    columns:
      - name: user_id
        description: ユーザーID
        tests:
          - unique
          - not_null
  - name: stg_products
    description: 商品マスタのステージングテーブル
    columns:
      - name: product_id
        description: 商品ID
      - name: product_name
        description: 商品名

SQLファイル内の上部にconfigブロックで細かい仕様を記載出来ます。 materialized='view' はviewテーブルを生成するという意味です。tagを付けたり、incremental指定したり、色々な細かい設定がここで出来ます。今回はデータソースからのクエリなので source() で取ってきます。sourceの名前はYAMLで定義した名前を指定する感じです。

models/stg_users.sql
/*configでテーブルの細かい設定*/
{{ config(
    materialized='view'
) }}

/*最終的なselectの内容を出力する*/
SELECT
    id AS user_id,
    first_name,
    last_name,
    email,
    created_at
FROM {{ source('thelook_ecommerce', 'users') }}

dbtが生成したモデルを参照する際は ref(モデル名) で取ってきます。

models/dim_users.sql
{{
  config(
    materialized='table',
    tags=['mart', 'dimension']
  )
}}

SELECT
  user_id,
  age,
  gender,
  country
FROM {{ ref('stg_users') }}

Dataformでのモデルの実装

SQLXファイル(SQLとJavascriptが記載可能)で実装します。データソースの指定はjavascriptで指定することが出来ます。これはdbtのYAMLとおんなじですね。

declarations.js
declare({
  database: "bigquery-public-data", /*プロジェクトID相当*/
  schema: "thelook_ecommerce", /*データセット相当*/
  name: "products" /*テーブル名相当*/
});
declare({
  database: "bigquery-public-data",
  schema: "thelook_ecommerce",
  name: "orders"
});

SQLXファイルですがdbt同様で上部のconfigブロック内で細かい指定を行い、最終的なSELECT結果を出力します。dbtでは source()ref() を使い分けてましたが、dataformは全て $ref() で参照します。そしてdbtではYAMLファイルでカラムやテストを記載しましたが、dataformではSQLXファイルのconfigで指定します。columnsもassetionsも記載なくても実行上問題はないです。

definitions/stg_order.sqlx
config {
  type: "view",
  schema: "dataform_ecommerce_staging", /*出力されるデータセット名*/
  description: "ユーザーマスタのステージングテーブル",
  tags: ["staging"],
  /*assertionsがtest実行内容*/
  assertions: {
    uniqueKey: ["user_id"],
    nonNull: ["user_id", "email"]
  },
  columns: {
    'order_id': '注文ID',
    'user_id': 'ユーザーID'
  }  
}

SELECT
    id AS user_id,
    first_name,
    last_name,
    email,
    created_at
FROM ${ref("bigquery-public-data", "thelook_ecommerce", "users")}

お作法さえわかれば、dbtもdataformも実装自体はサクッと出来るかなと!

コンパイルと実行について

通常開発時の実行を中心に記載します。CI/CD周りは特に書いてません。まぁ何かしらのスケジューラ機能のある所から、PROD等の環境変数等渡して実行するだけっちゃだけなので・・!

dbtでのコンパイルと実行

dbt compile でコンパイル出来ます。dbtで開発したSQLファイルをコピーしてBigQueryのコンソールに貼り付けても ref() とかの変換しないいけないですが、コンパイルした生SQLは target 配下に吐き出されます。 {{ ref('stg_users') }}xxxxx.dbt_ecommerce_stg.stg_users に変換されてますね!

target/compiled/xxxx/dim_users.sql
SELECT
  user_id,
  age,
  gender,
  country
FROM `xxxxx`.`dbt_ecommerce_stg`.`stg_users`

dbt run が全モデルを実行します。実行については色んなパターンがありまして

  • ファイル指定系
    • dbt run --select ファイルパス(もしくはモデル名) で、そのモデルだけの実行
    • dbt run --select ファイルパス/ で、そのディレクトリ配下のモデル全て
    • dbt run --select tag:タグ名 で、そのタグのモデル全て実行
  • 特定のモデルの上流下流含めた実行系
    • dbt run --select +stg_users だと stg_users 含めた上流(参照元)を全て実行
    • dbt run --select stg_users+ だと stg_users 含めた下流(参照先)を全て実行
  • タグ実行系
    • OR条件は --select tag:A tag:B で、AまたはBを実行
    • AND条件は --select tag:A,tag:B で、AとBのタグが付いているモデルだけ実行
    • 除外条件は --select --exclude tag:A で、タグA以外を実行

みたいな感じです。実行対象の制御が色々出来るので便利ですが、タグのAND,OR条件は紛らわしいので、そんなに使いたくないお気持ちです笑

incrementalについては materialized=incremental で実装可能で、WHERE句だけ工夫が必要です。またリポジトリの方にはコード記載しているのですが type=ephemeral というテーブルを実体化させずdbt runの実行完了後にテーブルを消す機能があります。権限周りの管理が面倒なので誰にも触ってほしくないが中間テーブルを一旦生成したい、みたいなケースで重宝します。

dataformでのコンパイルと実行

dataform compile でローカルでのコンパイルが出来ます。dbtはローカルにコンパイルSQLを発行してくれるんですがdataformは作ってくれません。一方で、Dataformのコンソール上でSQLXを書いていると常時コンパイルしてくれるので、右上に 「✅️コンパイル完了」と表記されると、コンパイル成功となります。「コンパイル済みクエリ」のタブを開くと、生のSQLが出力されるのでこれをコピってBigQueryのコンソールに貼れば即実行出来ます。

dataform run についてはdbt runとほぼ同じですが「特定のディレクトリ配下全てのモデルを実行」は無さそうです。

  • dataform run --actions モデル名 で特定のモデルのみ実行可能
  • dataform run --tags タグ名 でタグ指定で実行可能
  • dataform run --include-deps --actions stg_orders でstg_ordersの上流(参照元)含めて実行
  • dataform run --include-dependents --actions stg_orders でstg_ordersの下流(参照先)含めて実行
    大きさ差分といえば --dry-run コマンドがあるので、実行するとどのファイルが作られる予定か予告してくれます!

またコンソール上だと実行時に「Selection of tags」「依存関係を含める」「依存者を含める」「完全に更新して実行する」「実行するアクションを選択する」などなど、コマンドと同等のことを画面上でぽちぽちしながら実行出来ます。ターミナル使えない方でも一通りの操作が出来ますね(dbt Cloudもきっと同じようなこと出来ますが)

dataformも勿論incrementalがあります。 type=incremental で実装可能です。dataformにはephemeralはないですが、後述するoperationがあります。

ここまでは大きな機能差は無さそうで、雑に見てみると「dbtはexcludeで実行除外出来て、ephemeral機能がある」「dataformはdry-runがある」ぐらいですかね?笑

テストについて

テストの実行方法はちょっと差分ありそうです。

dbt test

単体テストとカスタムテストの2種類あります。
単体テストは、モデルの書き方で記載の通りYAMLでテストを定義すると実行され、カスタムテストは tests配下にテストクエリを実装すれば実行されます。whereで条件指定が可能なので、 例えばnot nullを担保したいけど過去のシステムゴミデータのせいでエラーが出ちゃう〜〜ってケースも直近数年に絞ってテスト実施 とか出来ます。

models/mart/schema.yml
models:
  - name: dim_users
    description: ユーザーディメンション
    columns:
      # user_idがユニークでNOT NULLであるテスト
      - name: user_id
        description: ユーザーID
        tests:
          - unique
          - not_null
      - name: email
        tests:
          # 2025-01-01以降のレコードについて、emailがNOT NULLであるテスト
          - not_null:
              config:
                where: "DATE(created_at) >= '2025-01-01'"
      - name: gender
        description: 性別
        tests:
      # genderがFとMの値しか持たないことをチェックするテスト
          - accepted_values:
              arguments:
                values: ['F', 'M']

カスタムテストは tests/ ディレクトリ配下に「こういうデータが1レコードでもHITしたらエラーだよ」というSQLを書くだけで済みます。ログのレコード数とfact_logテーブルのレコード数が一致していないか検知等、自由度高い検出が可能です。

tests/test_order_amount_positive.sql
select
    *
from {{ ref('stg_orders') }}
where order_amount < 0

テストの実行は dbt test コマンドで行います。dbt runと同じように、ファイル選択、タグ選択、上流下流など実行対象を絞ることができます。dbtでは dbt run を一通り完了させたあとに dbt test でテストをするケースが多いかなと思います。

Dataform assertions

Dataformでは assertions を使います。同様に単体テストとカスタムテストの2種類あります(ちゃんとした正式名所がわからず)。現在dataformのtestの導入中でまだまだ模索中なので、雑目な紹介になりますmm

単体テストについては、ユニークキー、NOT NULL、値のチェック( rowConditions )などdbt testと同じようなことが出来ます。単体テストで出来ないことは、dbtでいうwhereによる条件を絞れないことです、過去のゴミデータをfilterしたい場合は、カスタムテストするしかなさそう。

definitions/stg_order.sqlx
config {
  type: "view",
  schema: "dataform_ecommerce_staging",
  description: "ユーザーマスタのステージングテーブル",
  tags: ["staging"],
  /*assertionsがtest実行内容*/
  assertions: {
    uniqueKey: ["user_id"],
    nonNull: ["user_id", "email"],
    /*created_atが2025-04-01以降であるテスト*/
    rowConditions: [
      'DATE(created_at) > "2025-04-01"'
    ]
  }
}

SELECT
    id AS user_id,
    first_name,
    last_name,
    email,
    created_at
FROM ${ref("bigquery-public-data", "thelook_ecommerce", "users")}

カスタムテストは assetions 配下に、1件でも検出したらおかしいSQLを実装するだけです。typeは assertion を指定。

definitions/assertions/assert_test_users.sqlx
config {
  type: "assertion",
  description: "2025年以降user_idが必ず存在するテスト"
}

SELECT
    *
FROM ${ref("stg_users")}
WHERE user_id IS NOT NULL AND DATE(created_at) >= "2025-01-01"

テストの実行はdbt testと違い dataform run で実行されます。テーブルの通常実行に加えて、単体テスト、カスタムテスト全てを実行してます。まだ自分は手探り状態なんですが、--dry-run でみると、assertionの実行名がわかるので、ローカルで特定の単体テストを実行する場合は dataform run --actions staging_stg_orders_assertions_uniqueKey_0 を、コンソール上で実行する際は、選択肢にテストっぽい表記があるので、ポチポチで指定しています。dataform-cliとまだ全然お友達になりきれてないので、ローカルからテスト実行するよりも、コンソール上で指定して実行するほうが開発しやすいな〜という体感です。

また、特定のassertionが成功しないとSQLを実行させたくない、みたいなケースは dependencies に依存テーブルだけでなく、テストケース名も指定出来ます。test_usersのカスタムテストが失敗すると実行されません。以下はカスタムテストのファイル名を指定すればよいですが、上記のような単体テストは未だに名前の法則がわからないので --dry-run で依存関係に含めたい名前を見つけて書くのが無難かなと!

definitions/fact_order.sqlx
config {
  dependencies: ["stg_orders", "assert_test_users"]
}

各種機能の紹介

dbt seed

dbtでは、csvファイルをテーブルとして読み込む seed 機能があります。何かしらのマスターデータをコードとしても管理したい場合に便利です。

seeds/promotion.csv
promo_date,promo_name,discount_rate
2025-01-01,New Year Sale,0.20
2025-02-14,Valentine's Day,0.15

dbt snapshot

dbtではSCD Type2の履歴化がめちゃめちゃ楽に出来ます。dbtで一番好きな機能です笑。

  • strategy='timestamp' にすることで、時刻系のカラムを見て指定されたカラムが更新された際に履歴的に管理してくれます(updated_atやmodified_atを指定すると楽)
  • stragegy='check' の場合、特定のカラムの値変更を検知してくれます
  • invalidate_hard_deletes=True でデータソース側の物理削除も対応可

この辺はシステム側の作りにかなり依存しまして、レコード自体は更新されていないが親テーブルの更新=ドメインイベントの更新が走ると modified_at が更新されちゃうみたいなケースや、そもそもcreated_atしかないパターンについては check で検知したいカラムを列挙しなきゃいけないです。

snapshots/users_snapshot.sql
{% snapshot users_snapshot %}

{{
    config(
      target_schema='snapshots',
      unique_key='user_id',
      strategy='timestamp',
      updated_at='updated_at',
    )
}}

select * from {{ ref('stg_users') }}

{% endsnapshot %}

macro

dbtはJinjaテンプレートで便利な汎用関数を作れます。結構重宝します。 generate_alias_name という色んなところで紹介されているmacroをコピペしておきます笑

macros/generate_alias_name.sql
{% macro generate_schema_name(custom_schema_name, node) -%}
    {%- set default_schema = target.schema -%}
    {%- if custom_schema_name is none -%}
        {{ default_schema }}
    {%- else -%}
        {{ custom_schema_name | trim }}
    {%- endif -%}
{%- endmacro %}

DataformのJavaScript関数

Dataformではjavascriptが使えます。SQLXファイル内で関数を書くことも可能です。データ系職種でGASとかゴリゴリ書いたことある人は書けるかもですが、スキルセットがSQL持ってる方が多いので、あまりjavascriptでてくいことさせたくないなーってお気持ちです。 includes ディレクトリにリポジトリ全体で再利用可能なjavascriptを定義することができるので、dbtでいうmacroぽい使い方が出来ると思います。

dataform operations

dataformはオペレーション系の操作が出来ます。GCSにあるcsvファイルをロードしてぶっこむみたいなケースは以下のような感じで雑に実装できるので便利なユースケースはあるって感じです。

gcs2bq.sqlx
config {
    type: 'operations',
    hasOutput: true, /*このoperationの結果、具体的なテーブル出力をするという設定。操作だけしておしまいのケースはfalseに*/
}
CREATE OR REPLACE TABLE ${self()}
LOAD DATA INTO ${self()}
FROM files(
    format = 'CSV',
    uris = ['gs://your-bucket/*.tsv']
)

開発環境周り

stgやprodへの出し分けとか、データセットを分けたりするとかそういう話です。

dbtのprofile周り

まずdbtには profile.yml で開発環境ごとにPROJECT_IDとかデータセットを分ける手段があります。

profiles.yml
sample_dbt_ecommerce:
  target: dev # デフォルトの実行環境
  outputs:
    dev:
      type: bigquery
      method: oauth
      project: DEVのプロジェクトID
      dataset: DEVのデータセットID
    stg:
      type: bigquery
      method: oauth
      project: STGのプロジェクトID
      dataset: STGのデータセットID
    prod:
      type: bigquery
      method: oauth
      project: PRODのプロジェクトID
      dataset: PRODのデータセットID

そしてローカルにある .dbt/profiles.yml を作ると独自のprofileも設定できます。

  • dbt runはまず .dbt/profiles.yml を参照しにいき、ファイルがあればそこに記載されているprojectIDとデータセットを見に行きます
  • dbt run --profiles-dir . でプロジェクトのprofiles.ymlを指定します。上記だと target:dev になってるのでデフォルトdevプロファイルを見に行きます
  • dbt run --profiles-dir . --target prod でprod環境を対象にdbt runが出来ます

devがあれば十分かもですが、データ分析の開発環境ってstagingとprodしかないケース結構多いので、defaultをstagingにし、ローカルのprofileでデータセットを個々人のデータセットを指定すれば、お互い干渉しないみたいな開発が出来ます。

また、ディレクトリ単位でタグを付ける、データセット名にsuffixをつける等の対応を全体設定用のYAMLに実装すれば実現出来ます。以下のファイルを使って、staging層は全部viewテーブルで、xxx_stgデータセットは以下に stagingタグを付けてdbt runする、みたいなことが出来ます。

dbt_project.yml
# Configuring models
models:
  sample_dbt_ecommerce:
    staging:
      +materialized: view
      +tags: ["staging"]
      +schema: stg
    intermediate:
      +materialized: ephemeral
    mart:
      +materialized: table
      +tags: ["mart"]
      +schema: mart

dataformの

dataform.json で設定します。jsonで色々設定が出来るのでSTG環境ではデータセット名にsuffixをつけるとかで環境だし分けします。自分はDataformのコンソール上のスケジュール実行時に「コンパイルのオーバーロード」という部分でいわゆる環境変数を渡して、スキーマのsuffixをprod環境は prod つけるみたいな設定をしています。これを全部コード管理するには --vars= で環境変数渡す等の実装が必要ですが、まだ自分がそこに至れておらず。。。まぁ、もしくはDataformコンソールでワークスペースを分ける方法もありますね。

dataform.json
{
    "defaultSchema": "dataform_ecommerce", /*データセット*/
    "assertionSchema": "dataform_ecommerce_assertions", /*assetionの結果格納用のデータセット*/
    "warehouse": "bigquery",
    "defaultDatabase": "PROJECT_ID",
    "datasetSuffix": "stg" /*この環境ではデータセット名のsuffixに_stgをつける*/
}

dbt_project.yml のような層ごとにデータセットを分けることが出来ないので、SQLXファイルの schema に都度指定するようにするしか無さそうです。

ドキュメントやリネージ

dbt docs

dbtの良いところは、テーブルのスキーマ定義やテストをYAMLで頑張って実装すると、以下のようなリネージ含めてドキュメント化してくれます。このモデルを参照先、参照元のテーブル達、タグ付いているもの含めてリネージから色んな情報得られます。

各モデルの、テーブル定義書やどんなテストをしているのか?も細かく見ることができます。dbtで2番目に好きな機能です笑

これを例えばmain merge時にdbt docsで吐き出したHTMLファイルをGAEにhostingすれば、社内向けテーブル定義書が作れるので一石二鳥です。

dataformのリネージ

GCPコンソール上で自動的にリネージが表示されます。Compiled graphを見るとリネージが一覧表示されますが、ちょっとまだまだ弱いな〜と思ってますし、機能追加が期待されます!!

おわりに

個人的にはまだまだdbt推しです笑。
Dataformは(自分の理解度がまだ浅い前提ですが)、痒いところに手が届かない所が色んなところであって辛いな〜〜〜って思いつつも、dbt Claudのように課金しなくても、非エンジニアの方が画面をポチポチSQL書きながら、GoogleCloud環境だけで完結できるという点では非常に協力です。Dataformで開発体験向上や便利なことをやるためにテクい実装やJavascriptのマクロ等を用意し続けて、それやるぐらいならもうdbtで良くね?とツッコまれる未来はありそうですが。

今回はゆるーくひろーく紹介したんですが、もっと詳細な比較とか、テクい実装集とかはまた別途書こうと思います!
それでは!

Discussion