Paths Subjects Questions Quizzes Pricing Search

dbt & Analytics Engineering

The DAG, materializations, incremental models, and tests that make SQL transformations behave like software

dbt & Analytics Engineering

Interviewers ask about dbt not to check whether you've memorized a CLI, but to see whether you understand what changed when transformation logic moved from ad hoc SQL scripts and stored procedures into a version-controlled, testable, documented project. Before dbt (and tools like it), "the transform layer" usually meant a pile of CREATE TABLE AS SELECT scripts run by cron, a tangle of nested views nobody fully understood, or business logic duplicated across five different BI dashboards because nobody had a shared, trusted place to put it once. dbt's actual contribution isn't the SQL — it's applying ordinary software-engineering discipline (version control, dependency resolution, testing, documentation, environments) to something that used to be treated as disposable scratch work.

That's also exactly where interviews probe. A weak answer describes dbt as "a tool that runs SQL files in order." A strong answer explains why the order matters (the DAG dbt builds from ref() and source() calls, and what breaks if you bypass it by hardcoding table names), why materialization is a deliberate cost/freshness trade-off rather than a default setting, why incremental models exist and what specifically can go wrong with them (late-arriving data, schema drift, non-deterministic unique_key collisions), and why a layered project structure (staging → intermediate → marts) isn't a style preference but the thing that makes a 500-model project navigable instead of a maze. This subject covers all of that, with real dbt SQL and YAML throughout, because the difference between a candidate who has read about dbt and one who has operated it in production shows up entirely in the details.


Analytics Engineering as a Discipline

Analytics engineering is the role — and the set of practices — that sits between data engineering and data analysis. The boundary is worth being precise about, because interviewers use it to check whether you understand where dbt fits in a larger data platform, not just what dbt does in isolation.

  • Data engineers build and operate the infrastructure that gets raw data into the warehouse: ingestion pipelines, streaming systems, orchestration, the extract-and-load half of ELT. They care about uptime, throughput, schema evolution at the source, and infrastructure cost. This layer is covered in etl-vs-elt-and-pipeline-design and airflow-and-workflow-orchestration.
  • Data analysts consume modeled data to answer business questions — dashboards, ad hoc queries, reports. They care about correctness and speed of iteration on questions, not on infrastructure.
  • Analytics engineers own the layer in between: turning raw, loaded-but-unmodeled tables into clean, tested, documented, reusable datasets that analysts (and downstream tools, and other engineers) can trust without re-deriving business logic from scratch every time. The job is SQL-first — the primary artifact is a SELECT statement, not a Python DAG or a Java service — but it borrows its practices from software engineering rather than from traditional BI: everything lives in git, changes go through code review, a CI pipeline runs tests before merge, and models are documented well enough that someone who didn't write them can trust and extend them.

This is the discipline dbt (data build tool) operationalizes. Before it, "the transform layer" typically meant scattered stored procedures, nested views with no dependency tracking, or business logic re-implemented independently in every BI tool that touched the warehouse — the classic failure mode where three dashboards report three different numbers for "monthly active users" because each one encodes its own slightly different definition. dbt's core insight is structural: transformation logic is just SELECT statements, and if you put those statements under version control, make their dependencies explicit and machine-readable, and hold them to the same bar as application code (tests, docs, review, CI), a warehouse full of ad hoc scripts becomes a maintainable software project. Everything in the rest of this subject is really just consequences of that one idea.

Two things analytics engineering explicitly is not: it's not writing the ingestion pipelines that load raw data (that's data engineering, and the "EL" in ELT — see etl-vs-elt-and-pipeline-design for why ELT largely won over ETL once cheap warehouse compute made in-warehouse transformation practical), and it's not building the BI layer or dashboards on top (that's typically the analyst or BI engineer, consuming the marts an analytics engineer produces). Knowing where your job ends is itself an interview signal — a candidate who says "I'd also just write the ingestion connector" in a dbt-focused interview is telling you they haven't drawn the boundary clearly.


dbt Models and the Dependency DAG

A dbt model is a single .sql file containing one SELECT statement. dbt takes that statement, wraps it in the appropriate DDL/DML for the configured materialization, and runs it against the warehouse. That's the entire mental model — no separate config language for "what to build," just SQL plus two Jinja functions that turn a folder of independent files into a dependency graph.

ref() is how one model depends on another:

-- models/marts/fct_orders.sql
select
    o.order_id,
    o.customer_id,
    o.order_status,
    o.order_date,
    p.total_amount
from {{ ref('stg_orders') }} as o
left join {{ ref('stg_payments_agg') }} as p
    on o.order_id = p.order_id

source() is how a model references a raw table that was loaded by an ingestion pipeline rather than built by dbt itself:

# models/staging/sources.yml
sources:
  - name: raw_app
    database: prod_warehouse
    schema: raw_app_data
    tables:
      - name: orders
        loaded_at_field: _loaded_at
        freshness:
          warn_after: {count: 12, period: hour}
          error_after: {count: 24, period: hour}
-- models/staging/stg_orders.sql
select
    id as order_id,
    customer_id,
    status as order_status,
    created_at as order_date
from {{ source('raw_app', 'orders') }}

Neither function is a string interpolation trick, even though that's what it compiles to. When dbt parses a project, it scans every model for ref() and source() calls and builds a directed acyclic graph (DAG): an edge from stg_orders to fct_orders because the latter calls ref('stg_orders'). dbt then runs models in topological order — a model only executes after everything it depends on has succeeded — and can parallelize models that don't depend on each other. This is the same DAG concept covered generally in airflow-and-workflow-orchestration, except here the graph is inferred automatically from the SQL itself rather than declared by hand; you never write "run B after A," you just write {{ ref('A') }} inside B and the ordering falls out.

ref() also solves a second problem that has nothing to do with ordering: environment resolution. {{ ref('stg_orders') }} compiles to a fully qualified name — analytics_dev.dbt_hamid.stg_orders in a developer's sandbox schema, analytics_prod.core.stg_orders in production — based on the target dbt is running against. Hardcoding select * from analytics_prod.core.stg_orders inside another model would silently work in development (pointed at prod data) while making it impossible to test changes to stg_orders in isolation, and it would break entirely the moment someone renames a schema. Every table reference inside a dbt project should go through ref() or source() for exactly this reason — it's the single most common code-review flag in a dbt PR, and interviewers will ask you to spot it in a snippet that hardcodes a schema name.

One consequence worth stating explicitly: because the DAG is derived from ref() calls, you can ask dbt to run or test just a slice of itdbt run --select fct_orders+ runs fct_orders and everything downstream of it; dbt run --select +fct_orders runs fct_orders and everything it depends on. This selection syntax is what makes CI on a large project tractable: a PR that only touches stg_orders doesn't need to rebuild the whole warehouse, only stg_orders and its downstream closure.


Materializations: View, Table, Incremental, Ephemeral

Materialization is the strategy dbt uses to turn a model's SELECT statement into something queryable in the warehouse. It's a config() setting, and choosing it wrong is one of the most common performance and cost mistakes in a dbt project.

Materialization What dbt builds Freshness Build cost Query cost Typical use
view A SQL view (the query re-runs on every downstream read) Always current — reflects underlying tables instantly Near zero at dbt run time Paid on every query; expensive if queried often or if the underlying joins are heavy Staging models, lightly-queried models, anything where storage cost isn't worth paying for freshness you don't need
table A physical table, fully rebuilt (CREATE TABLE AS SELECT) every run As fresh as the last full run Full recompute every run — expensive for large tables Cheap — reading a materialized table Marts that are queried heavily by BI tools/dashboards, and any model where recompute is small enough to just redo in full
incremental A physical table, but only new/changed rows are processed on subsequent runs As fresh as the last incremental run Cheap after the first run — processes only the delta Cheap — reading a materialized table Large fact tables / event tables where a full rebuild would be too slow or too expensive to run on every schedule
ephemeral Nothing — no view, no table, at all N/A — it doesn't persist Zero (no separate build step) Interpolated as a CTE into whatever references it, so its cost is paid inside the referencing model's query Lightweight, reusable logic (a rename/cast step, a small filter) that you want to ref() from multiple places without cluttering the warehouse with one-off views

The trade-off underneath the table is always the same one: freshness and query speed cost storage and compute; deferring that cost to query time (view, ephemeral) is free until someone queries it often, at which point it stops being free. A view materialization on a model joined by five downstream marts, each queried by a dashboard that refreshes every few minutes, recomputes that join constantly — that's a strong signal to materialize it as a table instead. Conversely, materializing every staging model as a table when it's only ever read by one or two downstream models wastes storage and adds unnecessary build time to every dbt run for no query-time benefit.

Setting it is a one-line config, either per model or as a project-wide default in dbt_project.yml:

-- models/marts/fct_orders.sql
{{ config(materialized='table') }}

select ...
# dbt_project.yml
models:
  my_project:
    staging:
      +materialized: view
    marts:
      +materialized: table

ephemeral deserves a specific caution: because it produces no object in the warehouse, it can't be queried directly (by a BI tool, by dbt test, by anyone debugging with a raw SQL client), and heavy chains of ephemeral models can produce a single, deeply nested compiled query that's painful to read when something breaks. Use it for small, genuinely reusable transformation steps — not as a way to avoid deciding between view and table.


Incremental Models: Strategies and a Worked Example

A table materialization rebuilds everything, every run. For a fact table with billions of rows, that's often not viable — a nightly job that recomputes the entire history of every order ever placed, just to add yesterday's rows, wastes compute linearly with the table's total size instead of with the size of what actually changed. Incremental models solve this: on the first run they build a full table like any other; on every subsequent run, they process only the rows that are new or changed since the last run, and merge or append that delta into the existing table.

The is_incremental() macro is the mechanism. It evaluates to true only when three conditions all hold: the model already exists as a table in the target schema, the run is not a --full-refresh, and the model is materialized as incremental. Inside that conditional, you write the filter that limits the query to new/changed data:

-- models/marts/fct_orders.sql
{{ config(
    materialized='incremental',
    unique_key='order_id',
    incremental_strategy='merge'
) }}

select
    order_id,
    customer_id,
    order_status,
    order_date,
    updated_at,
    total_amount
from {{ ref('stg_orders') }}

{% if is_incremental() %}
where updated_at > (select coalesce(max(updated_at), '1900-01-01') from {{ this }})
{% endif %}

{{ this }} refers to the model's own already-built table — the incremental filter is literally "give me rows newer than the newest row I already have." On the very first run (no table exists yet), is_incremental() is false, the where clause is compiled out entirely, and dbt does a full build. On every run after that, only the delta is selected and processed.

Strategy determines what happens to that delta once selected:

  • merge (the default on warehouses that support MERGE, e.g. Snowflake, BigQuery, Databricks): upserts the delta into the existing table, matching on unique_key. Rows with a unique_key that already exists get updated in place; new keys get inserted. This is the right choice whenever source rows can be updated after they first appear — an order whose order_status changes from pending to shipped needs its existing row overwritten, not duplicated.
  • append: no matching, no unique_key needed — the delta is simply inserted. Correct only for genuinely immutable, append-only data (e.g., a raw event log where each row is a distinct event that never changes after being written). Using append on data that can be updated produces duplicate rows for the same logical entity — one of the most common incremental-model bugs.
  • insert_overwrite (common on BigQuery and Spark/Databricks): instead of row-level matching, it replaces entire partitions that contain any changed data. This is typically faster than merge at very large scale because it operates at the partition level rather than doing row-by-row matching, but it requires the table to be partitioned on a column that aligns with how the incremental filter selects data (usually a date). This strategy connects directly to file-formats-partitioning-and-storage-layout — the partitioning scheme you choose at the storage layer determines whether insert_overwrite is even efficient, since it works by dropping and rewriting whole partitions.

unique_key is required for merge and ignored (or invalid) for append; it can be a single column or a list of columns forming a composite key. Getting it wrong is a specific, well-known failure mode: if unique_key isn't actually unique in the incoming delta — say, a source system emits two "updated" rows for the same order_id in the same batch due to a retry — most warehouses' MERGE implementation will error or silently pick one nondeterministically, because a single MERGE statement can't decide which of two candidate rows should win for the same target key. The fix is deduplicating the delta before the merge, typically with a window function (ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY updated_at DESC)) inside the model's SELECT — see advanced-sql-window-functions-and-ctes for the mechanics of that pattern.

Two gotchas interviewers like to probe:

  1. Late-arriving data. If the incremental filter is updated_at > max(updated_at already in the table), and a row arrives late — its updated_at is older than the current max, perhaps because of a slow upstream pipeline or a backfill — the filter silently skips it forever, because the max-watermark logic assumes strictly increasing arrival order. The common fix is a lookback window: where updated_at > (select max(updated_at) from {{ this }}) - interval '3 days', re-processing a small buffer of recently-seen rows on every run so a late arrival within the window still gets picked up (the merge strategy makes this safe — reprocessing already-merged rows is a no-op).
  2. Schema drift. If an upstream source adds or changes a column, an incremental model doesn't automatically pick that up the way a full table rebuild would, because it's only ever inserting/merging deltas into a table whose schema was fixed at first build. dbt has an on_schema_change config (ignore, fail, append_new_columns, sync_all_columns) to control this explicitly, but the safest mental model is: when an incremental model's logic or the upstream schema changes meaningfully, run dbt run --full-refresh on it to rebuild from scratch, then let incremental runs resume from that clean baseline.

Incremental models are the piece of dbt most connected to spark-performance-tuning and data-warehouses-and-lakehouses: the strategy that performs well is a function of the underlying engine's execution model (row-level MERGE support, partition pruning, file compaction on insert_overwrite), not a dbt-specific concern — dbt is just generating the SQL, the warehouse is what actually executes it well or badly.


Testing: Schema Tests and Custom Tests

A model that builds successfully isn't the same thing as a model that's correct. dbt's testing layer is what turns "the query ran without erroring" into "the query ran and the data satisfies the invariants I expect" — and it's usually the first thing a strong candidate brings up unprompted when asked "how do you know a dbt project is trustworthy."

Built-in generic (schema) tests are declared in YAML against a model's columns and compile to a SELECT that should return zero rows if the test passes:

# models/marts/schema.yml
models:
  - name: fct_orders
    columns:
      - name: order_id
        tests:
          - unique
          - not_null
      - name: customer_id
        tests:
          - not_null
          - relationships:
              to: ref('dim_customers')
              field: customer_id
      - name: order_status
        tests:
          - accepted_values:
              values: ['pending', 'shipped', 'delivered', 'cancelled', 'refunded']

The four built-ins cover most of what a data-quality review actually asks for: unique (no duplicate values — the exact property a broken unique_key/merge strategy above would violate), not_null, accepted_values (an enum constraint), and relationships (referential integrity — every customer_id in fct_orders exists in dim_customers, catching orphaned foreign keys that a plain SQL JOIN would otherwise hide by silently dropping unmatched rows).

Custom generic tests are parameterized macros you write once and reuse across any model/column, for assertions specific to your business rules that don't map to one of the built-ins:

-- tests/generic/test_non_negative.sql
{% test non_negative(model, column_name) %}
select *
from {{ model }}
where {{ column_name }} < 0
{% endtest %}
      - name: total_amount
        tests:
          - non_negative

Singular tests are one-off, non-parameterized assertions written as a plain .sql file in the tests/ directory — used for a check specific to one model that isn't worth generalizing into a macro:

-- tests/assert_no_future_order_dates.sql
select order_id, order_date
from {{ ref('fct_orders') }}
where order_date > current_date()

Both generic and singular tests share the same contract: the query should return zero rows on success; any row returned is a failing record, and dbt test reports the count. This is what makes tests useful for debugging, not just pass/fail signaling — a failing relationships test returns the actual orphaned rows, not just "18 rows failed."

The practical discipline: tests run in CI on every pull request (typically dbt build, which runs models and their tests together in dependency order, so a failing test on an upstream model blocks anything built on top of it from even being attempted), and a merge is blocked on a red test. This is the dbt-specific instance of a broader idea covered fully in data-quality-testing-and-observability — dbt tests are one layer of a data quality program (schema-level, deterministic, run at build time), not the whole program; freshness monitoring, anomaly detection on distributions, and row-count/volume checks over time are complementary and usually live outside dbt itself.


Documentation and the Lineage Graph

A model without documentation is a black box to everyone except the person who wrote it, and in a project with hundreds of models, that person eventually forgets too. dbt treats documentation as a first-class, version-controlled artifact rather than a wiki page that drifts out of sync with the code.

Descriptions live alongside tests, in the same YAML:

models:
  - name: fct_orders
    description: >
      One row per order. Grain: order_id. Joins order status from the
      source system with aggregated payment totals. Refreshed incrementally
      every hour; see the `updated_at` incremental filter for the freshness
      watermark.
    columns:
      - name: order_id
        description: "Primary key. Matches the source system's order id 1:1."
      - name: order_status
        description: "{{ doc('orders_status') }}"

For longer, reusable descriptions (a status enum's meaning, a business definition worth stating once and referencing everywhere), dbt supports doc blocks in separate .md files:

{% docs orders_status %}
Current lifecycle state of the order. `pending` until payment clears,
`shipped` once a tracking number is assigned, `delivered` on carrier
confirmation, `cancelled`/`refunded` are terminal states set by the
support tooling and never reversed.
{% enddocs %}

dbt docs generate compiles all of this — descriptions, column types inferred from the warehouse, test coverage per model — into a static site (dbt docs serve runs it locally), and the centerpiece of that site is the lineage graph: an interactive visualization of the exact DAG described earlier, built automatically from every ref() and source() call in the project. This is not a diagram someone draws and lets go stale — it's generated from the same dependency information dbt uses to decide run order, so it's guaranteed to match the actual project structure.

The lineage graph earns its keep on two very concrete tasks: onboarding (a new team member can visually trace how a mart is built up from staging models without reading every file), and impact analysis (before changing a column in stg_orders, you can see every downstream model that would be affected — the same closure dbt run --select stg_orders+ would rebuild). dbt also supports exposures — a YAML declaration that a dashboard, ML feature pipeline, or other external consumer depends on a given model — which extends the lineage graph past the warehouse boundary, so "what breaks if I change this column" answers "and this Looker dashboard" too, not just downstream dbt models.


Project Structure: Staging, Intermediate, Marts

Almost every mature dbt project converges on the same three-layer folder structure, and understanding why — not just being able to name the layers — is what interviewers are actually checking for.

models/
  staging/
    stg_orders.sql
    stg_payments.sql
    stg_customers.sql
    sources.yml
    schema.yml
  intermediate/
    int_payments_aggregated_to_order.sql
    int_orders_with_first_purchase_flag.sql
  marts/
    finance/
      fct_orders.sql
      fct_payments.sql
    marketing/
      dim_customers.sql
      fct_customer_ltv.sql
  • Staging models are a thin, 1:1 layer over each raw source table: renaming columns to a consistent convention, casting types, light standardization (lowercasing a status string) — but deliberately no joins and no business logic. Each staging model exists to be the single place that knows how to read one specific raw table, so that every downstream model reads a clean, consistently named interface instead of raw source quirks. Typically materialized as view (cheap, always fresh, rarely queried directly by end users).
  • Intermediate models are where joins and multi-step business logic live, as reusable building blocks that aren't meant to be queried directly by analysts or BI tools — they exist to keep any single mart model from becoming an unreadable 300-line query. An intermediate model like int_payments_aggregated_to_order might exist purely so two different marts can ref() the same aggregation instead of each re-deriving it slightly differently. Often materialized as view or ephemeral.
  • Marts are the final, consumer-facing layer: wide, business-defined fact and dimension tables (see data-modeling-dimensional-and-normalized for the fact/dimension vocabulary itself), organized by business domain rather than by source system, and built to be queried directly by dashboards and analysts. Typically materialized as table or incremental, since this is the layer that gets hit hardest by downstream query traffic.

The reason this layering earns its complexity rather than being bureaucracy: it enforces a single responsibility and a strict downstream direction (staging never depends on intermediate or marts; intermediate never depends on marts), which is what prevents circular dependencies and makes the DAG actually navigable at scale. It also means business logic is written exactly once — if "active customer" is defined in one intermediate model, every mart that needs that definition ref()s it rather than re-implementing it, which is the direct fix for the "three dashboards, three different numbers" problem analytics engineering exists to solve in the first place. And it isolates the blast radius of a raw-source schema change to the staging layer — if an ingestion tool renames a source column, only the one staging model that reads it needs to change; everything downstream, referencing the staging model's stable interface, is unaffected.


Fully Worked Example: An Incremental Orders Fact Table, End to End

Putting the pieces together: a raw orders table is loaded hourly by an ingestion pipeline (see etl-vs-elt-and-pipeline-design) into raw_app.orders, and the goal is a queryable, tested, documented fct_orders table that BI tools hit directly, staying fresh without a full rebuild every run.

1. Declare the source, with a freshness check:

# models/staging/sources.yml
sources:
  - name: raw_app
    schema: raw_app_data
    tables:
      - name: orders
        loaded_at_field: _loaded_at
        freshness:
          warn_after: {count: 2, period: hour}
          error_after: {count: 6, period: hour}

2. Staging model — thin, renamed, no logic:

-- models/staging/stg_orders.sql
{{ config(materialized='view') }}

select
    id as order_id,
    customer_id,
    status as order_status,
    created_at as order_date,
    updated_at,
    amount_cents / 100.0 as order_amount
from {{ source('raw_app', 'orders') }}

3. Marts model — incremental, merge strategy, deduplicated delta:

-- models/marts/finance/fct_orders.sql
{{ config(
    materialized='incremental',
    unique_key='order_id',
    incremental_strategy='merge',
    on_schema_change='fail'
) }}

with source as (
    select *
    from {{ ref('stg_orders') }}
    {% if is_incremental() %}
    where updated_at > (
        select coalesce(max(updated_at), '1900-01-01') from {{ this }}
    ) - interval '3 days'
    {% endif %}
),

deduplicated as (
    select
        *,
        row_number() over (
            partition by order_id order by updated_at desc
        ) as rn
    from source
)

select
    order_id,
    customer_id,
    order_status,
    order_date,
    updated_at,
    order_amount
from deduplicated
where rn = 1

Note the two defenses stacked together: the - interval '3 days' lookback guards against late-arriving updates, and the row_number() dedup guards against the same order_id appearing twice in one delta batch — both problems described in the incremental section above, made concrete here rather than left abstract.

4. Tests and documentation:

# models/marts/finance/schema.yml
models:
  - name: fct_orders
    description: >
      One row per order, grain order_id. Incremental, merged hourly on
      updated_at with a 3-day lookback for late-arriving updates.
    columns:
      - name: order_id
        description: "Primary key."
        tests: [unique, not_null]
      - name: customer_id
        tests:
          - not_null
          - relationships: {to: ref('dim_customers'), field: customer_id}
      - name: order_status
        tests:
          - accepted_values:
              values: ['pending', 'shipped', 'delivered', 'cancelled', 'refunded']
      - name: order_amount
        tests: [non_negative]

5. Running it. dbt run --select fct_orders on a brand-new environment finds no existing fct_orders table, so is_incremental() evaluates false, the where clause compiles away, and the full history builds as a one-time table load. Every subsequent hourly run finds the table already exists, is_incremental() evaluates true, and the query only touches rows updated within the last 3 days of the current watermark — orders of magnitude cheaper than a full rebuild once the table has real history. dbt build --select fct_orders runs the model and its schema tests together, failing the run (and, in CI, blocking the merge) if any test returns a row. If the upstream stg_orders staging model's shape changes in a way on_schema_change='fail' should catch, the fix is dbt run --select fct_orders --full-refresh to rebuild cleanly, then let hourly incremental runs resume.

This is the same pattern — layered project structure, ref()-driven DAG, deliberate materialization choice, tested and documented output — scaled up across an entire warehouse in case-study-batch-analytics-platform, and it's the concrete SQL skill set case-study-sql-interview-gauntlet and sql-query-optimization-and-indexing assume you can already produce fluently before optimizing it further.


Common Mistakes and Interview Traps

  • Describing dbt as "just running SQL files" instead of naming the actual engineering surface: dependency resolution via ref()/source(), environment-aware compilation, materialization as a cost decision, testing, documentation, and CI.
  • Hardcoding a schema-qualified table name instead of ref()/source(), which breaks environment isolation (dev vs. prod) and silently drops the model out of the DAG, so dbt can't sequence or select it correctly.
  • Materializing everything as table "to be safe," paying full-rebuild compute on every run for models that are queried rarely enough that a view would be free, or materializing a heavily-queried, expensive join as a view and paying its cost on every single downstream read instead of once at build time.
  • Using append as an incremental strategy on data that can be updated after it first appears, silently producing duplicate rows for the same logical entity instead of upserting.
  • Using merge with a unique_key that isn't actually unique in the incoming delta, without deduplicating first — the classic source of a MERGE error or nondeterministic row selection.
  • An incremental filter with no lookback window, silently dropping late-arriving updates that fall behind the current high-water mark.
  • Putting joins and business logic in a staging model, breaking the "staging is a thin 1:1 layer" convention and making a raw-source schema change ripple into logic that has nothing to do with the source itself.
  • Treating dbt test as the entire data-quality story instead of one layer of it — schema tests catch deterministic, known invariants at build time; they don't catch a metric quietly drifting out of its historical range, which needs the broader observability practice covered in data-quality-testing-and-observability.
  • Skipping documentation on the reasoning that "the SQL is self-explanatory," then having the lineage graph and doc site go stale or empty exactly when a new team member needs them most.
  • Not running --full-refresh after a meaningful upstream schema or logic change to an incremental model, leaving it silently built on a stale definition until someone notices the numbers look wrong.

Key Takeaways

  • Analytics engineering is the SQL-first discipline that applies software-engineering practices — version control, review, CI, testing, documentation — to the transformation layer that sits between data engineering (extract/load) and analysts (consumption); dbt is the tool that operationalizes it.
  • ref() and source() are not string interpolation conveniences — they're what lets dbt infer a dependency DAG from plain SQL, resolve table names per environment, and select a slice of the project (+model+) instead of rebuilding everything on every change.
  • Materialization is a deliberate freshness/cost trade-off, not a default: view and ephemeral defer cost to query time, table pays full rebuild cost for fast, always-consistent reads, and incremental gets table-like query speed while only reprocessing the delta.
  • Incremental models need a strategy (merge for updatable data with a real unique_key, append only for genuinely immutable rows, insert_overwrite for partition-aligned large-scale rewrites), a lookback window to catch late arrivals, and deduplication before merging — skipping any of the three is the most common source of incremental-model bugs.
  • dbt's testing layer (unique, not_null, accepted_values, relationships, plus custom generic and singular tests) catches known, deterministic invariants at build time and blocks CI on failure; it's one layer of data quality, not the whole program.
  • Documentation and the auto-generated lineage graph aren't optional polish — they're what makes impact analysis ("what breaks if I change this column") and onboarding tractable in a project with hundreds of interdependent models.
  • The staging → intermediate → marts layering isn't bureaucracy: it isolates raw-source changes to one thin layer, ensures business logic is defined exactly once and reused rather than re-derived, and keeps the DAG's dependency direction strictly downstream so it stays navigable as the project grows.

Ready to test your knowledge?

Practice questions

We use cookies for product analytics to improve OmniAtlas. See our Privacy Policy.