dbt as the de-facto transformation layer

In 2018, the way Razorpay's analytics team built their daily settlement table was a 1,200-line stored procedure inside Postgres, edited by four people, version-controlled by copy-pasting into a Confluence page when someone "did something big". By 2022 the same team had 380 dbt models, a dependency graph, automatic tests on every join key, and a static documentation site that the new joiner could read on day one. The intermediate technology — Airflow Python operators wrapping SQL strings, then Stitch + Looker derived tables, then a homegrown templating engine — all died. dbt did not win because it was clever. It won because it picked four small, boring decisions and refused to add a fifth.

dbt is a SQL compiler with a dependency graph, a test runner, and a docs generator — nothing else. Its dominance came from refusing to be a query engine, an orchestrator, or a BI tool, and instead doing one job that every analytics team needed but nobody had productised. Understanding the four design choices that made it stick lets you debug why your dbt project hurts and predict where dbt will and won't extend.

What dbt actually is, in 70 words

You write a SQL SELECT in a file called models/marts/fct_orders.sql. dbt wraps it in CREATE OR REPLACE TABLE {{ this }} AS (...) based on the configured materialisation. It walks ref('stg_orders') calls to build a DAG. It runs the DAG in topological order against your warehouse. It runs declarative tests after each model. It renders Markdown + the DAG into a static site. That's it. There is no execution engine, no scheduler, no UI for query authoring — those are someone else's job.

dbt's actual surface areaA layered diagram. Top layer (the user): SQL files, YAML files, Jinja macros. Middle layer (dbt-core, the compiler): parser, ref-graph builder, materialisation wrapper, test runner, docs generator. Bottom layer (the warehouse, not dbt): Snowflake, BigQuery, Redshift, Postgres, Databricks. Arrows show that dbt only translates and orchestrates; the warehouse does all execution. dbt is a thin layer above SQL and below the warehouse What you write (the user surface) models/*.sql (with Jinja) schema.yml (tests, docs) macros/*.sql, dbt_project.yml What dbt-core does (the compiler) Jinja parser resolves ref(), source(), macros → raw SQL DAG builder topological sort, selectors, partial runs Materialiser view / table / incremental / ephemeral / snapshot Tests + docs unique, not_null, relationships, custom What dbt does NOT do (the warehouse does) query planning · join algorithms · storage · concurrency · authentication Snowflake / BigQuery / Redshift / Databricks / Postgres / DuckDB / ClickHouse
dbt sits between user-authored SQL+YAML and the warehouse. It compiles and orchestrates, never executes. The decision to push execution down to the warehouse is the single most important architectural choice — and the reason dbt outlived ten earlier transformation tools.

The narrowness is the product. Earlier transformation tools — Matillion, Talend, Informatica — tried to be the execution engine themselves, the scheduler, and the GUI editor. They have hundreds of features. They lost because each feature became coupling that an analytics team eventually wanted to break.

The four decisions that made it stick

Every analytics-engineering tool before dbt had at least one of these four backwards. dbt got all four right, and the combination is what produced the network effect.

Decision 1 — ref() is the only way to reference another model. You never write FROM analytics.fct_orders in a dbt model. You write FROM {{ ref('fct_orders') }}. dbt compiles this into the fully-qualified name based on the target schema (dev vs prod) and uses it to build the DAG. Why this single decision is load-bearing: it forces every model dependency to be discoverable by static analysis. Without it, the DAG is a guess; with it, the DAG is a deterministic property of your code. Lineage, partial runs, and impact analysis all flow from this one constraint.

Decision 2 — Materialisations are config, not code. Whether fct_orders is a view, a table, an incremental table, or a snapshot is a one-line config change. The SQL stays the same. Why this matters: separating "what the data should be" (the SELECT) from "how it lives in the warehouse" (the materialisation) lets you change physical storage without rewriting business logic. Most pre-dbt tools mixed these and made the rewrite cost prohibitive.

Decision 3 — Tests are declarative, attached to columns. You don't write a test as a Python script. You add tests: [unique, not_null] under a column in schema.yml. dbt compiles this into a SQL query that returns failing rows. The test passes if the query returns zero rows. Why this is the right shape: tests as code drift from the model they test (lives in a different repo, different deploy cadence). Tests as YAML next to the model are version-controlled together, code-reviewed together, and visible in the docs site as part of the model's contract.

Decision 4 — The compiled SQL is your output. dbt compile produces the exact SQL that will run, into target/compiled/. You can open it, copy it, run it directly in the warehouse for debugging. There is no runtime magic the user can't inspect. Why this is anti-magic: tools that hide the generated SQL (looking at you, every BI semantic layer that compiles to undebuggable joins) lose user trust the first time something breaks. The "you can always read the SQL" property kept dbt usable when projects grew past 500 models.

These four decisions, taken together, form a tight kernel. Each one alone would be a feature; together they shape how the project is structured, code-reviewed, and debugged. That is why teams that adopt dbt rarely return to bespoke transformation tooling — the four decisions become institutional muscle memory.

Worked example: building the daily revenue model

A real shape — Razorpay's daily payment-volume rollup, simplified. Six files, three layers (staging → intermediate → marts), one test config. This is the canonical dbt project shape.

# show_compile.py — render what dbt will execute, without a real warehouse.
# Demonstrates ref-resolution, materialisation wrapping, and DAG construction.

from pathlib import Path
import re, json
from collections import defaultdict

PROJECT = {
  "models/staging/stg_payments.sql": """
    SELECT id AS payment_id, merchant_id, amount_paise, status, created_at
    FROM {{ source('raw', 'payments') }}
    WHERE created_at >= '2026-01-01'
  """,
  "models/staging/stg_merchants.sql": """
    SELECT id AS merchant_id, name AS merchant_name, gst_state
    FROM {{ source('raw', 'merchants') }}
  """,
  "models/intermediate/int_captured_payments.sql": """
    SELECT payment_id, merchant_id, amount_paise, created_at
    FROM {{ ref('stg_payments') }}
    WHERE status = 'captured'
  """,
  "models/marts/fct_daily_revenue.sql": """
    SELECT DATE(p.created_at) AS day, m.gst_state,
           SUM(p.amount_paise) / 100.0 AS revenue_inr
    FROM {{ ref('int_captured_payments') }} p
    LEFT JOIN {{ ref('stg_merchants') }} m USING (merchant_id)
    GROUP BY 1, 2
  """,
}

CONFIG = {
  "stg_payments": "view", "stg_merchants": "view",
  "int_captured_payments": "view",
  "fct_daily_revenue": "table",
}

REF = re.compile(r"\{\{\s*ref\(['\"]([^'\"]+)['\"]\)\s*\}\}")
SOURCE = re.compile(r"\{\{\s*source\(['\"]([^'\"]+)['\"]\s*,\s*['\"]([^'\"]+)['\"]\)\s*\}\}")

def compile_model(name, sql):
    deps = REF.findall(sql)
    sql = REF.sub(lambda m: f"analytics_dev.{m.group(1)}", sql)
    sql = SOURCE.sub(lambda m: f"{m.group(1)}.{m.group(2)}", sql)
    mat = CONFIG[name]
    if mat == "view":
        wrapped = f"CREATE OR REPLACE VIEW analytics_dev.{name} AS (\n{sql.strip()}\n);"
    else:
        wrapped = f"CREATE OR REPLACE TABLE analytics_dev.{name} AS (\n{sql.strip()}\n);"
    return deps, wrapped

graph = defaultdict(list)
compiled = {}
for path, sql in PROJECT.items():
    name = Path(path).stem
    deps, wrapped = compile_model(name, sql)
    graph[name] = deps
    compiled[name] = wrapped

# Topological sort
def topo(graph):
    seen, order = set(), []
    def visit(n):
        if n in seen: return
        seen.add(n)
        for d in graph.get(n, []): visit(d)
        order.append(n)
    for n in graph: visit(n)
    return order

print("DAG (model -> dependencies):")
print(json.dumps(dict(graph), indent=2))
print("\nExecution order:")
for n in topo(graph):
    print(f"  -> {n}")
print("\nCompiled SQL for fct_daily_revenue:\n")
print(compiled["fct_daily_revenue"])
# Sample run:
DAG (model -> dependencies):
{
  "stg_payments": [],
  "stg_merchants": [],
  "int_captured_payments": ["stg_payments"],
  "fct_daily_revenue": ["int_captured_payments", "stg_merchants"]
}

Execution order:
  -> stg_payments
  -> stg_merchants
  -> int_captured_payments
  -> fct_daily_revenue

Compiled SQL for fct_daily_revenue:

CREATE OR REPLACE TABLE analytics_dev.fct_daily_revenue AS (
SELECT DATE(p.created_at) AS day, m.gst_state,
           SUM(p.amount_paise) / 100.0 AS revenue_inr
    FROM analytics_dev.int_captured_payments p
    LEFT JOIN analytics_dev.stg_merchants m USING (merchant_id)
    GROUP BY 1, 2
);

The load-bearing pieces:

The full schema.yml that goes alongside, with tests:

version: 2
models:
  - name: fct_daily_revenue
    description: "Daily payment revenue per GST state. Source of truth for finance dashboards."
    columns:
      - name: day
        tests: [not_null]
      - name: gst_state
        tests:
          - not_null
          - relationships:
              to: ref('stg_merchants')
              field: gst_state
      - name: revenue_inr
        tests: [not_null]

dbt test compiles each tests: block into a SQL query that returns offending rows. tests: [not_null] on day becomes SELECT * FROM analytics_dev.fct_daily_revenue WHERE day IS NULL. The test passes if zero rows return. The relationships test on gst_state becomes a left-anti-join checking that every state in fct_daily_revenue exists in stg_merchants. None of this is bespoke — every dbt project on earth uses these four built-in tests.

Where the lineage and docs come from

dbt's static documentation site is generated from three inputs: the YAML descriptions, the column-level metadata it can introspect from the warehouse (information_schema), and the DAG it built from ref() calls. The output is a single-page-app you can host on S3 or GitHub Pages — no server, no auth, no database. New joiner reads it on day one and understands the warehouse better than they would after three weeks of standups.

A small dbt project's lineage graphA lineage diagram showing the four-model DAG. Left: two source tables in raw schema (raw.payments, raw.merchants). Middle: two staging models, one intermediate model. Right: one mart model. Edges show ref() dependencies. The mart is highlighted because it's the leaf node consumed by BI tools. Lineage graph for the daily-revenue project sources staging (views) intermediate marts (table) raw.payments source raw.merchants source stg_payments view stg_merchants view int_captured_ payments fct_daily_revenue table · BI consumed Auto-generated from ref() calls — no manual lineage authoring
The lineage graph is a free side-effect of using ref(). Every dbt project gets this without any extra work. When fct_daily_revenue produces a wrong number, you trace edges leftward to find which staging or source table broke. Larger projects (Flipkart's analytics layer has 1,400+ models) use the same graph to do impact analysis: "this column changes; which downstream models will break?"

Common confusions

Going deeper

Why dbt's compile-then-execute model beats execute-then-trace

Earlier transformation tools (Matillion, Talend) execute SQL fragments and trace dependencies at runtime by parsing query history. dbt does the opposite: it parses ref() calls statically before any SQL runs, builds the DAG up front, and only then executes. This means dbt knows the entire dependency graph before touching the warehouse — so partial runs (dbt run --select fct_orders+) can correctly determine which downstream models to rebuild. Runtime-trace tools cannot do this; they have to rely on heuristics that miss cases. The cost of dbt's approach is that you must use ref() religiously; the benefit is that lineage is a property of the code, not of an execution log.

Incremental models, late-arriving data, and the merge strategy choice

The incremental materialisation is where most Indian-scale dbt projects spend their debugging time. The model has three knobs: unique_key, incremental_strategy (append, merge, delete+insert, insert_overwrite), and the filter inside is_incremental(). A common Razorpay-shape bug: an incremental model on fct_payments with unique_key='payment_id' and is_incremental() filter WHERE created_at > (SELECT max(created_at) FROM {{ this }}). If a payment is captured at 23:59:50 but the row lands in the warehouse at 00:00:30 of the next day, the next dbt run filters on max(created_at) of 23:59:50 — and never picks up the late arrival. The fix is to widen the filter (created_at > max - INTERVAL '1 hour') and rely on merge to dedupe by unique_key. This is the kind of correctness reasoning the materialisation choice forces on you, which is half the value of the abstraction.

What dbt deliberately does NOT include

dbt has no scheduler. The community-blessed pattern is "run dbt from Airflow / Dagster / Prefect / Cron". dbt has no orchestrator across heterogeneous systems — it cannot trigger a Flink job after a model finishes. dbt has no UI for query authoring during development; you write SQL in your editor. dbt has no in-warehouse caching layer — each dbt run re-executes the SELECTs from scratch. Each of these omissions has been argued for as a feature; in practice each was a deliberate "we are not going to be that" decision that kept dbt small and let it integrate cleanly with the ecosystem. dbt Cloud added some of these (a managed scheduler, an IDE) but the open-source core remains narrow.

The semantic layer extension and where it sits

In 2023 dbt acquired Transform (the company behind MetricFlow) and shipped MetricFlow as the dbt semantic layer. This is Build 13's main thread: the metrics-layer registry of wau, wau_paying, revenue_inr_state lives in metrics: blocks of YAML, alongside the models. MetricFlow compiles a metric+dimensions request into SQL at query time, using the dbt project's lineage as the source of joinable tables. This extension is real, but optional — most production dbt projects in Indian orgs as of 2026 do not yet use the semantic layer; they expose the marts directly to BI tools and rely on convention. The metrics-layer adoption curve is the next chapter of dbt's story.

Where this leads next

The metrics-layer view of dbt is the previous chapter (/wiki/the-what-is-an-active-user-problem) — dbt is the substrate that the metrics layer sits on top of, and the registry of versioned metric definitions lives in dbt's YAML files. The cross-tool comparison (/wiki/lookml-cube-metricflow-the-landscape) walks through how MetricFlow compares to Cube and LookML on the seven encoding fields.

For the orchestration layer that runs dbt, see /wiki/airflow-vs-dagster-vs-prefect-the-real-design-differences. For the data-contract pattern that makes dbt staging models reliable, see /wiki/data-contracts-the-producer-consumer-boundary. And for the testing landscape that complements dbt's built-in tests, see /wiki/dbt-tests-great-expectations-soda-the-landscape.

Build 13 is about the layer that sits above transformation and turns table columns into business meaning. dbt is the floor of that layer — every metrics-layer tool either integrates with dbt or competes with it. Understanding why dbt's four design decisions stuck is the prerequisite for evaluating any tool that builds on top of it.

References