dbt (Data Build Tool) — Data Engineering

dbt (Data Build Tool) dbt adalah tool yang mentransformasi data di warehouse menggunakan SQL. dbt menerapkan software engineering best practices (version…

dbt (data build tool) adalah alat untuk mengerjakan huruf T di ELT: transformasi di dalam warehouse. Tiap tabel yang mau kamu bangun ditulis sebagai satu file berisi satu SELECT, lalu dbt yang menentukan urutannya, membuat tabel atau view-nya, menjalankan tes, dan menulis dokumentasinya. Bayangin dapur restoran. Tiap resep ditulis di kartu sendiri, dan kartu sambal bilang "pakai bawang goreng dari kartu 3". Kepala dapur membaca semua kartu, tahu bawang goreng harus dibuat dulu, memasak sesuai urutan, lalu mencicipi tiap masakan sebelum keluar. Kalau sambalnya keasinan, nasi goreng yang memakai sambal itu gak disajikan. Kartu resep itu model, "pakai dari kartu 3" itu ref(), mencicipi itu tes, dan kepala dapurnya dbt build.

🧪 Tebakan cepat: Baris dobel item 9 lolos ke stg_order_items karena dedup-nya terhapus. Malam itu dbt build jalan. Besok pagi pemilik membuka laris_mingguan: dia melihat Bumi Manusia 4 di Jakarta, angka kemarin, atau error? Tebak dulu, jawabannya ada di bagian "Tes: cek yang ikut dibangun".

Model: satu file, satu SELECT

Proyek dbt Toko Buku jalan sungguhan di laptop dengan dbt Core 1.12.5 (rilis 15 September 2026), adapter dbt-duckdb 1.11.0, dan DuckDB 1.5.6. Data aplikasi (5 tabel MySQL) sudah dimuat ke skema toko_buku oleh tahap EL; dbt cuma mengerjakan transformasinya.

toko_buku/
├── dbt_project.yml
├── models/
│   ├── staging/   _sources.yml, _staging.yml, stg_orders.sql, stg_order_items.sql, stg_customers.sql
│   └── marts/     _marts.yml, dim_buku.sql, dim_pelanggan.sql, fact_penjualan.sql, laris_mingguan.sql
├── seeds/         dim_tanggal.csv
├── snapshots/     snap_buku.yml
└── tests/         assert_harga_bayar_sesuai_katalog.sql

Satu model = satu file .sql berisi satu SELECT = satu tabel atau view di warehouse, dengan nama file sebagai namanya. Model staging merapikan satu tabel sumber (ganti nama kolom, ubah tipe), model mart menyusun tabel siap pakai. Ini models/staging/stg_orders.sql:

select
    id as order_id,
    customer_id,
    created_at,
    cast(replace(substr(cast(created_at as varchar), 1, 10), '-', '') as integer) as tanggal_key
from {{ source('toko_buku', 'orders') }}

ref() dan DAG

Model yang memakai model lain menyebutnya dengan ref(), bukan nama tabel. Potongan models/marts/fact_penjualan.sql:

from {{ ref('stg_order_items') }} as i
join {{ ref('stg_orders') }} as o on o.order_id = i.order_id
join {{ ref('dim_pelanggan') }} as p on p.customer_id = o.customer_id
join {{ ref('dim_buku') }} as b
    on b.book_id = i.book_id
    and o.created_at >= b.valid_from
    and o.created_at < b.valid_to

Dari tiap ref() dbt mencatat panah: stg_order_items harus jadi sebelum fact_penjualan. Semua panah membentuk DAG (directed acyclic graph, sama seperti di pelajaran Airflow): sumber, staging, mart, sampai laris_mingguan, tabel juara mingguan yang dibaca pemilik. DAG juga dipakai untuk memilih: tanda + di belakang nama artinya "model ini dan semua yang membacanya". Hasil dbt ls --select stg_orders+ --resource-type model:

toko_buku.marts.fact_penjualan
toko_buku.marts.laris_mingguan
toko_buku.staging.stg_orders

ref() juga yang membuat proyek bisa pindah tempat: di laptop ref('stg_orders') jadi "tokobuku"."main"."stg_orders", di warehouse produksi jadi skema produksi, tanpa mengubah satu file pun.

Materialization: view, table, incremental

Materialization adalah cara dbt menyimpan hasil sebuah model. Di dbt_project.yml, semua staging jadi view dan semua mart jadi table:

models:
  toko_buku:
    staging:
      +materialized: view
    marts:
      +materialized: table
Materialization Yang disimpan Dibaca Cocok untuk
view query-nya saja dihitung ulang tiap kali dipanggil staging yang ringan
table hasil query sebagai data fisik langsung, cepat mart yang sering dibaca
incremental data fisik, ditambah baris baru tiap build langsung, cepat fact besar yang terus bertambah

Jadi bedanya: view disimpan sebagai query yang dieksekusi saat dipanggil, table disimpan sebagai data fisik. Kalau Sabtu pagi pesanan 15 masuk MySQL dan dbt belum jalan, stg_orders (view) langsung berisi 15 pesanan, fact_penjualan (table) tetap 18 baris sampai build berikutnya.

fact_penjualan dibuat incremental. Bagian atas dan bawah file-nya:

{{ config(materialized='incremental', unique_key=['order_id', 'buku_key']) }}

select ...
{% if is_incremental() %}
where o.tanggal_key >= (select max(tanggal_key) from {{ this }})
{% endif %}

Sources dan freshness

_sources.yml mendaftarkan tabel sumber, dan bisa memberi aturan freshness: seberapa baru data terbarunya harus.

sources:
  - name: toko_buku
    schema: toko_buku
    tables:
      - name: orders
        config:
          loaded_at_field: created_at
          freshness:
            warn_after: {count: 24, period: hour}
            error_after: {count: 48, period: hour}

dbt source freshness yang dijalankan Minggu 11 Oktober malam:

1 of 1 START freshness of toko_buku.orders ..................................... [RUN]
1 of 1 WARN freshness of toko_buku.orders ...................................... [WARN in 0.01s]

Pesanan terakhir (10 Oktober 10:15:30) sudah sekitar 28,6 jam: lewat 24 jam, belum 48, jadi WARN. Ini cek timeliness dari pelajaran kualitas data, ditulis sekali di YAML.

Tes: cek yang ikut dibangun

Tes ditulis di YAML di samping model, di bawah data_tests: (nama barunya sejak dbt 1.8; tests: masih diterima). dbt Core punya empat tes bawaan (generic test): unique, not_null, accepted_values, relationships. Sebagian models/staging/_staging.yml dan models/marts/_marts.yml:

models:
  - name: stg_order_items
    data_tests:
      - unique:
          column_name: "order_id || '-' || book_id"
  - name: fact_penjualan
    columns:
      - name: buku_key
        data_tests:
          - relationships:
              arguments:
                to: ref('dim_buku')
                field: buku_key
  - name: dim_pelanggan
    columns:
      - name: kota
        data_tests:
          - accepted_values:
              arguments:
                values: ['Jakarta', 'Bandung', 'Surabaya', 'Tidak diketahui']

Argumen tes (to, field, values) ditaruh di bawah arguments:. Tutorial lama menaruhnya langsung di bawah nama tes, dan dbt 1.12 menjawabnya dengan peringatan MissingArgumentsPropertyInGenericTestDeprecation. Tiap tes dikompilasi jadi SELECT yang mencari baris pelanggar, persis seperti di pelajaran kualitas data: lolos kalau 0 baris. Tes juga bisa ditulis sendiri sebagai file SQL di tests/ (singular test): assert_harga_bayar_sesuai_katalog.sql mencari item yang harga bayarnya beda dengan harga katalog, dan menemukan item 16. dbt build menjalankan model dan tesnya berselang-seling sesuai DAG. Akhir log build pertama:

19 of 19 OK created sql table model main.laris_mingguan ........................ [OK in 0.02s]
Finished running 1 incremental model, 1 seed, 1 snapshot, 3 table models, 10 data tests, 3 view models in 0 hours 0 minutes and 1.12 seconds (1.12s).
Done. PASS=17 WARN=2 ERROR=0 SKIP=0 NO-OP=0 REUSED=0 TOTAL=19

Dua WARN itu not_null pada kota Maya (diberi severity: warn) dan harga item 16. Lalu dedup di stg_order_items dihapus dan dbt build dijalankan lagi:

9 of 19 FAIL 1 unique_stg_order_items_order_id_book_id ......................... [FAIL 1 in 0.01s]
16 of 19 SKIP relation main.fact_penjualan ..................................... [SKIP]
19 of 19 SKIP relation main.laris_mingguan ..................................... [SKIP]
Done. PASS=12 WARN=2 ERROR=1 SKIP=4 NO-OP=0 REUSED=0 TOTAL=19

Tes unique gagal karena pasangan 6-2 muncul dua kali, dan semua model di hilirnya di-SKIP. laris_mingguan gak dibangun ulang, jadi pemilik tetap membaca angka build kemarin, bukan Bumi Manusia 4, dan yang jaga melihat ERROR. Itu jawaban tebakannya. dbt test menjalankan tesnya saja, dbt run membangun model saja tanpa tes; dbt build menjalankan keduanya berurutan sesuai DAG.

Snapshot: riwayat harga

Sabtu 10 Oktober pukul 08:00 harga Bumi Manusia turun jadi 119000. Snapshot mencatat tiap versi baris sumber, persis SCD tipe 2 di pelajaran star schema:

snapshots:
  - name: snap_buku
    relation: source('toko_buku', 'books')
    config:
      unique_key: id
      strategy: timestamp
      updated_at: updated_at

Dengan strategi timestamp, dbt membandingkan updated_at tiap buku dengan versi yang tercatat. Setelah build Sabtu, dim_buku (dibangun dari snap_buku) berisi:

buku_key book_id judul harga valid_from valid_to is_current
2 2 Bumi Manusia 125000 2026-09-01 00:00:00 2026-10-10 08:00:00 false
5 2 Bumi Manusia 119000 2026-10-10 08:00:00 9999-12-31 00:00:00 true

Pesanan sebelum Sabtu tetap memakai buku_key 2 (125000), pesanan 15 memakai buku_key 5 (119000).

💡 Seed, docs, dan lineage

⚠️ Jebakan yang sering ketemu

TL;DR: dbt mengerjakan transformasi (T di ELT) di dalam warehouse: tiap model satu file SELECT, dbt yang membuat tabel atau view-nya. source() menunjuk tabel sumber, ref() menunjuk model lain, dan dari ref() dbt membangun DAG serta urutan build. Materialization: view menyimpan query dan dieksekusi saat dipanggil, table menyimpan data fisik, incremental cuma memproses baris baru (Sabtu: 2 baris, delete+insert per unique_key, fact_penjualan jadi 19). Freshness di _sources.yml memberi WARN kalau data sumber terlalu lama. Tes unique, not_null, accepted_values, relationships ditulis di bawah data_tests:, plus tes SQL sendiri di tests/; dbt build men-SKIP model hilir kalau tesnya gagal, jadi laporan kemarin gak ditimpa angka salah. Snapshot mencatat riwayat (SCD tipe 2: Bumi Manusia buku_key 2 dan 5), seed memuat CSV, dan dbt docs menggambar lineage. Proyek ini jalan dengan dbt Core 1.12.5 dan dbt-duckdb 1.11.0.

Yang akan kamu pelajari