データベース

データマート構築の手順:スタースキーマ設計とdbtでの実装・更新戦略をコードで解説【dbt 1.12対応】

データマート構築の手順:スタースキーマ設計とdbtでの実装・更新戦略をコードで解説【dbt 1.12対応】

データマート構築で手戻りが起きる原因の多くは、SQLの書き方より前の設計にあります。1行が何を表すかを決めずに集計表を作り、部門ごとに似た表が増え、顧客の所在地が変わった月から数字が合わなくなる、という流れです。この記事では、データマートの定義やDWHとの違いを押さえた実装者向けに、受注マートを題材にして、粒度とディメンションの決め方、dbt Core 1.12とDuckDBによるスタースキーマの実装、顧客履歴の持ち方、増分更新と全件再構築の使い分けまでをコードで示します。

まとめ:データマート構築は粒度の宣言と共通ディメンションの設計が起点

最初に決めるのは表の数ではなく、ファクトの1行が何を表すかです。受注マートなら「受注明細1行」と宣言し、それより粗い集計は作りません。集計はBIやクエリ側で行えば済み、細かい粒度を持っていれば後から問いが変わっても作り直さずに答えられます。

次に、顧客・商品・日付のディメンションを部門をまたいで共通にします。売上を見る営業と、粗利を見る経理が別々の顧客表を持つと、同じ月の数字が一致しなくなるからです。顧客の所在地のように変わる属性は、dbtのsnapshotで履歴を残し、受注日に有効だった版と結合します。

更新方式は、まずtableで全件を作り直す形で始め、ビルド時間が実行枠を圧迫したときだけincrementalに切り替えます。incrementalにする場合は、遅れて届く更新を拾う遡り幅と、月1回程度の全件再構築をセットで決めてください。

設計手順:Kimballの4ステップによる受注マートの業務プロセスと粒度の決定

ディメンショナルモデリングを体系化したKimball Groupは、4ステップの設計プロセスとして、業務プロセスの選択、粒度の宣言、ディメンションの特定、ファクトの特定をこの順に挙げています。順番には意味があり、粒度より先にディメンションを決めると、どの列を持てるかが定まりません。

業務プロセスを受注に絞り粒度を受注明細1行と最初に宣言する理由

業務プロセスは「受注」「出荷」「請求」のように、システムに記録が残る単位で1つ選びます。部門名(営業部向けマート)で選ぶと、営業が見たい受注と出荷が1つの表に混ざり、粒度が揃わなくなります。

粒度は、そのプロセスで取れる最も細かい単位にします。受注なら受注ヘッダではなく明細です。ヘッダ粒度にすると、商品別の売上を出すときに明細を別途読みにいくことになり、マートを作った意味が薄れます。dbtのmartsの構成ガイドも、martはエンティティの粒度で持ち、日次集計のような時間軸の集約はmartではなく指標の領域だと整理しています。

バスマトリクスで部門をまたぐ共通ディメンションを先に洗い出す手順

2つ目以降のマートを作る前に、業務プロセスを行、ディメンションを列にした表を作ります。Kimball Groupがバスマトリクスと呼ぶもので、列が複数行で丸になっていれば、そのディメンションは共通化の対象です。

業務プロセス 日付 顧客 商品 倉庫
受注 ○ ○ ○
出荷 ○ ○ ○ ○
請求 ○ ○

この例では日付と顧客が3つのプロセスすべてにかかるので、最初の受注マートの段階から共通の表として作ります。Kimball Groupの共通ディメンションの解説は、別々のファクトの結果を同じ行に並べて比べられることを、企業内でデータを統合する本質という位置づけです。業務担当者と一緒に7つの問いで要件を引き出す進め方は、BEAMによるアジャイルなディメンショナルモデリングで解説しています。

検証環境の準備:dbt Core 1.12とDuckDBで受注マートを手元に作る構成

クラウドのDWHを契約しなくても、設計の妥当性は手元で確かめられます。ここではファイル1つで動くDuckDBを格納先にし、変換はdbt Coreで書きます。2026年11月13日時点のPyPIでは、dbt-coreは1.12系(1.12.7)、DuckDB用アダプタのdbt-duckdbは1.11系(1.11.1)が最新です。BigQueryやSnowflakeへ移すときは、profiles.ymlとSQLの方言を差し替えれば、モデルの構成はそのまま使えます。

dbt-duckdbのインストールとprofiles.ymlによる接続の初期設定

dbt-duckdbのリポジトリの案内どおり、pipで入れるとdbt-coreも依存として入ります。版を固定しておくと、手元とCIで挙動がずれません。

python -m venv .venv
# Windows は .venv\Scripts\activate、macOS と Linux は source .venv/bin/activate
pip install "dbt-core~=1.12.0" "dbt-duckdb~=1.11.0"
dbt --version

次に、プロジェクトのフォルダ sales_mart を作り、直下に2つの設定ファイルを置きます。staging層はview、marts層はtableを既定にしておきます。

# dbt_project.yml
name: sales_mart
version: "1.0.0"
profile: sales_mart

models:
  sales_mart:
    staging:
      +materialized: view
    marts:
      +materialized: table

# profiles.yml(プロジェクト直下に置き、コマンドに --profiles-dir . を付けて読ませる)
sales_mart:
  target: dev
  outputs:
    dev:
      type: duckdb
      path: sales_mart.duckdb
      threads: 4

path に指定した sales_mart.duckdb が、初回の実行時に作られるデータベースファイルです。dbtのインストールからCIへの組み込みまでを一通り知りたい場合は、dbt Coreの使い方を先に読むと設定ファイルの役割がつかめます。

seedsに置くサンプル受注データとstaging層での型の揃え方

抽出元の代わりに、seedsフォルダへ4つのCSVを置きます。dbtはseedをそのまま表として読み込むので、取り込み処理を書かずに設計を試せます。

seeds/raw_customers.csv
customer_id,customer_name,prefecture,updated_at
1,山田商事,東京都,2026-10-01 09:00:00
2,佐藤工業,大阪府,2026-10-01 09:00:00

seeds/raw_products.csv
product_id,product_name,category
101,業務用ノートPC,PC
102,27インチモニタ,周辺機器

seeds/raw_orders.csv
order_id,customer_id,order_date,status,updated_at
5001,1,2026-11-01,shipped,2026-11-02 10:00:00
5002,2,2026-11-03,placed,2026-11-03 15:00:00

seeds/raw_order_items.csv
order_id,line_no,product_id,quantity,unit_price
5001,1,101,2,158000
5001,2,102,2,32800
5002,1,101,1,158000

staging層では、受注ヘッダと明細を宣言した粒度(明細1行)に揃え、日付と金額の型をここで確定させます。型をマートに入ってから直すと、同じキャストが複数のモデルに散らばります。

-- models/staging/stg_order_lines.sql
select
    i.order_id,
    i.line_no,
    o.customer_id,
    i.product_id,
    cast(o.order_date as date)          as order_date,
    o.status,
    i.quantity,
    cast(i.unit_price as decimal(12,0)) as unit_price,
    cast(o.updated_at as timestamp)     as updated_at
from {{ ref('raw_order_items') }} as i
join {{ ref('raw_orders') }} as o using (order_id)

ディメンションの実装:顧客・商品・日付をdbtモデルとsnapshotで作る手順

スタースキーマでは、中央のファクトが各ディメンションのキーだけを持ち、名称や区分はディメンション側に置きます。ここで決めるのは、そのキーの振り方と、変わる属性の持ち方の2点です。

連番でなくハッシュでサロゲートキーを振る理由と日付ディメンションの例外

Kimball Groupのサロゲートキーの定義は、1から順に振る意味のない整数です。ただしdbtのtableモデルは実行のたびに作り直されるため、連番にすると並び順の変化で同じ商品に別の番号が振られ、ファクト側のキーと食い違います。dbtで組むなら、自然キーから決定的に計算できるハッシュを使うのが安全です。

-- models/marts/dim_products.sql
select
    md5(cast(product_id as varchar)) as product_key,
    product_id,
    product_name,
    category
from {{ ref('raw_products') }}

-- models/marts/dim_date.sql
select
    cast(d as date)        as date_key,
    year(d)                as cal_year,
    month(d)               as cal_month,
    dayofweek(d) in (0, 6) as is_weekend
from range(timestamp '2026-01-01', timestamp '2027-01-01', interval 1 day) as t(d)

日付ディメンションだけは日付そのものをキーにする設計です。Kimball Groupも、日付は値が予測でき安定しているためサロゲートキーの規則から外してよいとしています。DuckDBの dayofweek は日曜が0、土曜が6なので、週末判定に使うのは、この2値です。祝日や会計年度の列は、この表に足していけば全マートで同じ定義を使えます。

顧客の所在地変更をdbt snapshotでSCD Type2の履歴として残す設定

顧客が東京から神奈川へ移ったとき、顧客表を上書きすると、過去の売上まで神奈川の実績に変わります。これを防ぐのが、変更のたびに新しい行を足すSCD Type2です。Kimball Groupは、有効開始・有効終了・現在行の目印の3列を最低限足すよう示しています。

dbtではsnapshotがこの履歴を作ります。v1.9以降はYAMLで定義でき、timestamp戦略では更新日時の列が変わった行を検知して、dbt_valid_from と dbt_valid_to の列で有効期間を記録します。

# snapshots/customers_snapshot.yml
snapshots:
  - name: customers_snapshot
    relation: ref('raw_customers')
    config:
      unique_key: customer_id
      strategy: timestamp
      updated_at: updated_at
-- models/marts/dim_customers.sql
select
    dbt_scd_id as customer_key,
    customer_id,
    customer_name,
    prefecture,
    -- 最初の版は過去の受注も結合できるよう有効開始を1900年に広げる
    case
        when row_number() over (partition by customer_id order by dbt_valid_from) = 1
            then timestamp '1900-01-01'
        else dbt_valid_from
    end                                            as valid_from,
    coalesce(dbt_valid_to, timestamp '9999-12-31') as valid_to,
    dbt_valid_to is null                           as is_current
from {{ ref('customers_snapshot') }}

キーには snapshot が行ごとに振る dbt_scd_id をそのまま使います。現在の版は dbt_valid_to が空なので、9999年12月31日で埋めて範囲結合しやすくしました。check戦略や削除の扱いまで含めた snapshot の設定は、dbt snapshotの使い方にまとめています。

ファクトの実装:受注明細ファクトをincrementalモデルで増分更新する構成

ファクトは、staging の明細に各ディメンションのキーを付け、数量と金額を持たせた表です。件数が増える前提で、ここではincrementalモデルとして書きます。

unique_keyとdelete+insertで再実行しても明細が重複しない書き方

-- models/marts/fct_order_lines.sql
{{ config(
    materialized='incremental',
    unique_key=['order_id', 'line_no'],
    incremental_strategy='delete+insert',
    on_schema_change='fail'
) }}

select
    l.order_id,
    l.line_no,
    c.customer_key,
    p.product_key,
    l.order_date              as date_key,
    l.status,
    l.quantity,
    l.unit_price,
    l.quantity * l.unit_price as amount,
    l.updated_at
from {{ ref('stg_order_lines') }} as l
left join {{ ref('dim_customers') }} as c
    on  c.customer_id = l.customer_id
    and cast(l.order_date as timestamp) >= c.valid_from
    and cast(l.order_date as timestamp) <  c.valid_to
left join {{ ref('dim_products') }} as p
    on  p.product_id = l.product_id
{% if is_incremental() %}
where l.updated_at >= (
    select coalesce(max(updated_at), timestamp '1900-01-01') from {{ this }}
) - interval 3 day
{% endif %}

unique_key に粒度そのもの(受注番号と明細番号)を渡すのが要点です。dbtの公式ドキュメントは、unique_key を指定すると同じキーの行を新しい行で置き換え、指定しないと多くのアダプタで追記だけになると説明しています。追記だけの設定で再実行すると、明細が2倍に数えられます。

戦略は delete+insert を明示しました。dbtの増分戦略には append・merge・delete+insert・insert_overwrite・microbatch があり、dbt-duckdbは merge も扱えますが、DuckDB 1.4.0以上が条件です。キーの行を消して入れ直す delete+insert はPostgreSQLなどでも同じ書き方で動くので、移行先が決まっていない段階では扱いやすい選択として使えます。on_schema_change を fail にしているのは、上流で列が増減したときに黙って取り込まず、止めて気づくためです。

履歴付きディメンションを受注日で結合するときの欠落とテストでの検知

顧客ディメンションとの結合条件は、顧客IDの一致に加えて「受注日がその版の有効期間に入っていること」です。ここで起きやすい失敗が、snapshot を始めた日より前の受注が、どの版にも当てはまらず落ちる現象です。dim_customers で最初の版の有効開始を1900年に広げたのはこの対策で、さらに結合を left join にして、外れた行はキーが空のまま残るようにしています。

残った空のキーはテストで止めます。dbtのデータテストで not_null と relationships を掛け、粒度の一意性は単独のSQLテストで確かめます。

# models/marts/_marts.yml
models:
  - name: dim_customers
    columns:
      - name: customer_key
        data_tests: [unique, not_null]
  - name: fct_order_lines
    columns:
      - name: customer_key
        data_tests:
          - not_null
          - relationships:
              arguments:
                to: ref('dim_customers')
                field: customer_key
      - name: product_key
        data_tests: [not_null]
      - name: date_key
        data_tests:
          - relationships:
              arguments:
                to: ref('dim_date')
                field: date_key

-- tests/assert_fct_order_lines_grain.sql(1行でも返れば失敗)
select order_id, line_no, count(*) as n
from {{ ref('fct_order_lines') }}
group by order_id, line_no
having count(*) > 1

あとは dbt build を1回実行すれば、seed・snapshot・モデル・テストが依存関係の順に流れます。顧客1の所在地を神奈川県、updated_at を 2026-11-05 09:00:00 に書き換え、11月6日の受注5003を足して再実行すると、受注5001は東京都の版、受注5003は神奈川県の版に結び付く想定です。

dbt build --profiles-dir .
dbt show --profiles-dir . --inline "select f.order_id, c.prefecture, f.amount from {{ ref('fct_order_lines') }} f join {{ ref('dim_customers') }} c using (customer_key) order by f.order_id"

上流のテストが落ちると、dbt build は下流のモデルを実行しません。列の型まで固定したい場合はcontractを有効にする方法もありますが、incrementalで使う条件は on_schema_change が append_new_columns か fail のときに限られます。テストの種類と使い分けはdbt testの書き方で詳しく扱っています。

更新戦略:view・table・incrementalの切り替え基準と全件再構築の周期

マートの更新方式は、一度決めたら終わりではありません。データ量と鮮度の要求に合わせて段階的に重くしていきます。

martsを一律にincrementalにしない判断と切り替える目安の所要時間

dbtのmartsの構成ガイドは、まずviewで始め、遅くなったらtable、ビルドが遅く実行を圧迫したらincrementalという順を勧め、martを既定で一律incrementalにすると不要な難しさを持ち込むと書いています。この順番に賛成します。

実務の目安としては、ファクトが数百万行までで、全件の作り直しが数分で終わるならtableのままにします。incrementalにすると、遡り幅の設計、遅延データの検証、全件再構築の運用が増え、保守の手間はtableの数倍になるからです。切り替えるのは、1回のビルドが夜間の実行枠の半分を超えた、あるいは時間単位の鮮度が求められた時点で十分です。ディメンションは件数が少ないことが多く、tableのままで困る場面はほとんどありません。marts層の命名や、Semantic Layerを使う場合に非正規化を控える理由は、dbtのmarts層とMetricFlowの解説を参照してください。

遡り3日で拾えない過去修正を–full-refreshで月次に洗い直す運用

前章のファクトは、取り込み済みの最大更新日時から3日さかのぼって読み直します。2日遅れで届くキャンセル処理などは、この幅で吸収できます。一方、決算の締め後に3か月前の受注単価を直すといった修正は、更新日時が付け替えられない限り拾えません。

そこで、月に1回は全件から作り直します。dbtの公式ドキュメントによると、–full-refresh を付けると既存の表を削除して全期間から再構築し、選択の末尾に + を付けると下流のモデルも一緒に実行されます。

dbt build --profiles-dir . --full-refresh --select fct_order_lines+

遡り幅は抽出元の業務に合わせて決め、3日は目安にすぎません。月次の全件再構築の前後で金額の合計を比べ、差が出ていれば遡り幅が短すぎる兆候です。

データマートを作らない判断:部門別マートの乱立と外部支援を検討する境界

最後に、構築を止める側の判断を書きます。マートは作るより、増えすぎたものを片付けるほうが高く付きます。

finance_ordersとmarketing_ordersを別々に作る失敗パターン

dbtのガイドは、同じ概念を部門ごとに別の定義で作ることをアンチパターンとして挙げ、例に finance_orders と marketing_orders を出しています。経理はキャンセル後の確定額、マーケティングは受注時点の額で売上を数えると、会議のたびに数字の突き合わせから始まります。

意味が本当に違うなら、部門名ではなく概念で名前を分けます。税務申告用の売上と管理会計の売上は別の表にしてよい、というのがdbtの整理です。部門の要望が来たら、まず既存のファクトに列を足して答えられないかを確かめてください。新しいマートを作るのは、業務プロセスか粒度が違うときだけです。

BIから直接DWHを読めば足りる規模とマート構築を外部に任せる目安

分析する人が数人で、元の表が数十万行程度なら、マートを作らずBIツールからDWHの表を直接読むほうが速く始められます。集計の定義が1か所のBIに閉じている間は、スタースキーマを組む手間に見合う効果が出ません。マートが要るのは、複数の部門やBIツールが同じ数字を見る段階になってからです。

逆に、抽出元が複数の業務システムにまたがる、顧客や商品のコードが部門ごとに違う、過去の数字を履歴どおりに再現する必要がある、のいずれかに当たるなら、マート単体ではなく基盤全体の設計から見直す時期です。全体の層構成はデータ分析基盤の構築とBigQueryでの実装手順で解説しています。既存の集計表の棚卸しから共通ディメンションの設計、dbtへの移行までを相談したい場合は、当社のデータ分析基盤構築・MLOps構築支援で設計段階から支援しています。

よくある質問

データマート構築の実装段階でよく挙がる質問を5つ取り上げます。

データマート構築にはどれくらいの期間がかかりますか?

抽出元が1つで、受注のような業務プロセス1つ分のマートなら、設計から最初のBI表示まで数週間が目安です。時間がかかるのはSQLを書く工程ではなく、粒度と指標の定義を業務側と合意する工程です。抽出元が複数あり、顧客コードの統合が必要な場合は、その名寄せだけで数か月かかることもあります。

スタースキーマとスノーフレークスキーマはどちらを選ぶべきですか?

BIツールから直接読むマートなら、ディメンションを1段に平たくしたスタースキーマを選びます。結合の回数が減り、利用者が表の関係を理解しやすいためです。商品分類のような階層を別表に分けるスノーフレーク型は、ディメンションが非常に大きく、重複を減らす効果が結合の手間を上回る場合に限って検討します。

データマートの構築にdbtは必須ですか?

必須ではありません。SQLのビューやストアドプロシージャ、ETLツールでもマートは作れます。dbtを使う利点は、モデル間の依存順の実行、テスト、snapshotによる履歴、増分更新が1つの仕組みにまとまることです。マートが数本で更新も月1回程度なら、スケジュールされたSQLで足ります。

データマートの更新頻度はどう決めればよいですか?

利用者がその数字を見て判断する頻度に合わせます。月次の会議資料に使うなら日次更新で十分で、当日の在庫引当に使うなら時間単位が要ります。頻度を上げるほどincrementalの設計と監視の手間が増えるので、要望の段階で「何時点の数字で何を決めるか」を確かめてから決めてください。

データマートとDWHを同じデータベースに置いてもよいですか?

問題ありません。クラウドDWHでは、同じデータベースの中でスキーマを分けてマートを置く構成が一般的です。dbtでもモデルのフォルダごとに出力先のスキーマを変えられます。分けるべきなのは物理的な置き場所より、参照する権限と更新の責任者です。

関連記事

お気に入りに入れた記事の一覧

一創の公式 SNS

X では毎朝の新着記事と IT ニュースの速報を、YouTube・TikTok・Instagram・LinkedIn・Facebook では記事の解説動画を配信しています。

この記事は以下の記事からリンクされています

資料請求

RELATED POSTS 関連記事

目次