dbt interview questions: refs, sources, and DAG lineage

Reviewed by Mark Dickie · Last updated

4 questions on this page you can answer and see marked on the spot. Go to the first one

dbt (data build tool) is a transformation framework that turns SQL into modular models connected by a directed acyclic graph (DAG). For interviews, you need to understand how ref() and source() create the edges of that DAG, how dbt resolves build order from those edges, and how lineage lets you trace a column or metric back to its raw origin. Expect questions on the difference between ref() and source(), what happens when a dependency is missing or circular, and how {{ config(materialized='...') }} changes the way a model participates in the DAG.

ConceptFunction / SyntaxWhat it does in the DAG
Reference to another model{{ ref('stg_orders') }}Creates an edge: this model depends on stg_orders, so dbt builds it first
Reference to a source table{{ source('raw', 'orders') }}Declares a dependency on an external table defined in a .yml sources block
Source freshnesssource('raw','orders') + freshness: in YAMLLets dbt source freshness check warn/error thresholds on loaded_at columns
Model lineagedbt ls --select stg_orders+Lists every downstream model that depends on stg_orders
  1. ref() vs source() — know the distinction cold. ref() points at another dbt model (something dbt itself builds). source() points at a table or view that already exists in your warehouse, declared in a sources YAML file. Mixing them up is one of the most common interview mistakes.
  2. Build order is automatic. You never write CREATE TABLE statements in a specific order. dbt reads the DAG, topologically sorts it, and runs models in dependency order. If you reference a model that does not exist, the compile step fails before anything runs.
  3. Selection syntax is fair game. Interviewers love asking about +model_name (upstream), model_name+ (downstream), 2+model_name (two levels up), and @model_name (the model and everything downstream, including parents). Be ready to write or interpret a selector.
  4. Circular dependencies break the DAG. If model A refs model B and model B refs model A, dbt raises a CycleError at compile time. Know that this is by design — a DAG, by definition, has no cycles.

What does a dbt interview test about refs and sources?

Interviewers typically probe three areas: whether you can write correct Jinja for model dependencies, whether you understand the compile-to-run lifecycle (DAG resolution happens at compile, not at run), and whether you can reason about lineage for impact analysis — e.g., "if you change a column in stg_customers, which downstream models are affected?" You should be able to answer that with a selector like stg_customers+ and explain that dbt's manifest.json stores the full dependency graph for tooling to query.

How does materialization interact with DAG lineage?

A model's materialization (table, view, incremental, ephemeral) changes how it behaves as a DAG node but does not change the dependency edges themselves. An ephemeral model, for instance, is never built as a database object — dbt inlines its SQL as a CTE into every model that refs it. This means an ephemeral model has no entry in the relations graph at run time, yet it still participates in the DAG for compile-time ordering. Interviewers may ask you when to choose ephemeral vs a view, and the answer usually comes down to query cost and reuse patterns rather than correctness.

Key facts

  • Tarmac's dbt interview questions cover 20 questions at difficulty 1–5 of 5.
  • Tarmac tracked 428 job postings asking for dbt in September 2026.
  • Roles asking for dbt advertise a median base salary of £80,000, across 267 job postings as of September 2026.
  • Tarmac last reviewed these dbt interview questions on 13 October 2026.

At a glance

Questions20
Difficulty1–5 of 5
FormatsMultiple choice, Coding exercise, Short answer, Flashcard, Ordering, True / false
Interactive5 run your code against tests, in the app

What you'll review

  1. ref function
  2. dag lineage
  3. dependency resolution

Practice questions

Try one before you open the answer. Pick an option and press Check; it's marked on the spot.

In a dbt model you write select * from {{ ref('stg_orders') }} instead of select * from analytics.stg_orders. What does using ref() give you that the hard-coded name does not?#

Options

Show answer

ref() is what lets dbt understand your project. Each ref('stg_orders') becomes an edge in the DAG, so dbt builds upstream models first and resolves the relation to the correct database and schema for whichever target you run against — dev locally, prod in deployment. A hard-coded analytics.stg_orders gives dbt no dependency information and freezes the name to one environment. It does no caching and never re-runs upstream models.

Why:

ref() is the backbone of dbt. At parse time dbt records every ref('stg_orders') as an edge in the project DAG, which lets it (1) build models in dependency order — stg_orders is guaranteed to exist before any model that refs it — and (2) resolve the relation to the right database/schema for the active target, so the same code points at your dev schema locally and the prod schema in deployment. A hard-coded analytics.stg_orders has neither property: dbt can't see the dependency, so it may build in the wrong order, and the name is frozen to one environment. ref() does no caching and doesn't re-run upstream models (the caching claim and the re-run claim are wrong); it isn't cosmetic. It compiles to a fully-qualified relation name, but the value is the dependency graph and environment-aware resolution behind it.

In dbt, every model that uses {{ ref('other_model') }} creates a dependency edge in the DAG: other_model must run before the model that references it. Given a dictionary representing this lineage graph — where dag[model_name] is a list of model names that model_name directly depends on via ref() — write a function that returns all upstream ancestors of a given model (both direct and transitive dependencies), as a sorted list. This is the set of models dbt would need to run before the given model.#

Starter code

def get_upstream(model, dag):
    """Return all upstream ancestors of `model` in the dbt ref() DAG.

    dag[model_name] is a list of models that model_name directly depends on.
    Return a sorted list of every ancestor (direct + transitive).
    """
    # TODO: implement
    return []

Your solution must pass

  • single_direct_ref
  • transitive_chain

This one is written and run, not read. Solve it in the app and your code is executed against these tests and the hidden ones.

In dbt, what is the primary purpose of using ref('stg_customers') in a model's SQL?#

Show answer

ref('stg_customers') references another dbt model named stg_customers rather than a raw table. It establishes a dependency in the DAG so dbt builds stg_customers first, and it automatically resolves the correct schema and table name for that model at run time.

Why:

The question asks for the primary purpose of ref(), which is an open question with no fixed set of choices, so it stays a short answer. The core idea is that ref() points to another dbt model rather than a raw table, which creates a dependency edge in the DAG so dbt builds the referenced model first. It also resolves the correct relation name for the current target environment. The keywords check for ref plus a term describing the dependency or DAG relationship.

In dbt, what is the primary purpose of the {{ ref('model_name') }} function when used inside a model's SQL query?#

Show answer

It tells dbt that the current model depends on another model. dbt uses this dependency information to construct the project's DAG, determine the correct execution order, and resolve the fully-qualified table name for the target environment at compile time.

Why:

The ref() function tells dbt that the current model depends on another model. dbt uses this dependency information to construct the project's DAG, determine the correct execution order, and resolve the fully-qualified table name for the target environment at compile time.

In dbt, every ref('model_name') call in a model creates a dependency edge in the DAG: the referenced model must be built before the model that calls ref(). dbt resolves these edges at compile time and produces a build order.#

Starter code

def get_build_order(models):
    # models: list of {"name": str, "refs": [str, ...]}
    # Return a list of model names in build order (dependencies first).
    # Break ties alphabetically.
    pass

Your solution must pass

  • basic_dag
  • single_model

This one is written and run, not read. Solve it in the app and your code is executed against these tests and the hidden ones.

In a dbt project, a source raw with table raw_customers is declared in sources.yml. There is a staging model stg_customers.sql that should read from that raw table, and a dimension model dim_customers.sql that should depend on stg_customers. Which pair of FROM-clause snippets correctly wires up the DAG lineage using dbt functions?#

Options

Show answer

The correct wiring uses source('raw', 'raw_customers') in the staging model and ref('stg_customers') in the dimension model. source() reads the externally registered raw table as a leaf node, while ref() creates a DAG edge to another dbt model so it builds first.

Why:

source() must be used for tables declared under the sources: key in a .yml file, so the raw table is read with {{ source('raw', 'raw_customers') }} in the staging model. stg_customers is a dbt model (a SQL file), so the dimension model must reach it with {{ ref('stg_customers') }}, which creates the dependency edge and ensures the staging model is built first. The option pairing ref('raw_customers') fails because no model named raw_customers exists, since it is a source. The option using source('staging', 'stg_customers') fails because stg_customers is a model, not a declared source. The option that swaps the two functions reverses the intended dependencies and fails for the same reasons.

Order the phases dbt goes through when you invoke dbt run, from first to last.#

Put these in order

Show answer

dbt run proceeds in five phases. First it parses the project, rendering Jinja and resolving every ref()/source(). Next it builds the dependency DAG and sorts the nodes. Then it compiles each selected model into plain SQL under target/ without touching the warehouse. Only then does it execute that SQL against the database in dependency order so upstream tables exist first. Finally it writes artifacts like manifest.json and run_results.json that record the graph and each node's status.

Why:

A dbt invocation is a pipeline. First dbt parses the whole project — reading every model, macro, and yml file and rendering Jinja enough to discover each ref()/source() call (this is where the manifest of nodes is assembled). It then builds the dependency graph (DAG) from those resolved references and topologically sorts it to decide execution order. Next it compiles the selected models, fully rendering their Jinja into plain SQL written under target/compiled/ — at this point there is concrete SQL but nothing has touched the warehouse yet. Only then does it execute that SQL against the database, materializing models in dependency order so upstream tables exist before downstream ones run. Finally it writes artifacts (manifest.json, run_results.json) capturing the graph and per-node timing/status, which power docs, state comparison, and slim CI. Mixing up parse-before-compile or execute-before-compile is the common mistake; SQL must be compiled before it can run.

In a dbt project, three upstream relations exist in your DAG:#

Starter code

SELECT s.store_id, s.store_name, COUNT(DISTINCT so.customer_id) AS unique_customers, SUM(so.order_amount) AS total_revenue FROM stg_orders so JOIN stg_customers sc ON so.customer_id = sc.customer_id GROUP BY s.store_id, s.store_name

Your solution must pass

  • visible_case

This one is written and run, not read. Solve it in the app and your code is executed against these tests and the hidden ones.

A dbt project has three models and one source, and nothing else references them. The source raw.orders is declared in sources.yml. stg_orders contains SELECT * FROM {{ source('raw', 'orders') }}. int_orders contains SELECT * FROM {{ ref('stg_orders') }}. fct_orders contains SELECT * FROM {{ ref('int_orders') }}. You run dbt run --select fct_orders with no other selectors or flags. Which models does dbt build? Name every model that is built, and say whether any upstream model is included.#

Show answer

Only fct_orders is built. The --select fct_orders selector matches just that node. Upstream ancestors are not included unless you add the + graph operator (for example dbt run --select +fct_orders), which would also build int_orders and stg_orders. The source raw.orders is never built by dbt, because sources are existing database objects that dbt only reads from.

Why:

dbt run --select fct_orders uses the plain node selector, which picks only the named model. Ancestors are added only when the + graph operator is used (+fct_orders selects all parents, fct_orders+ selects children). The ref() calls create dependency edges, but those edges do not make the selector walk upstream. stg_orders and int_orders are therefore not built. The source() call registers raw.orders as a read-only source node, which dbt run never builds. The only model built is fct_orders.

Consider a dbt Core 1.7+ project that uses the default generate_schema_name macro (no overrides). The profiles.yml target sets schema: analytics. A model file stg_orders.sql contains {{ config(schema='marketing') }} at the top. What schema name will dbt materialize stg_orders into? Give the schema name only, not the database.#

Show answer

dbt will materialize stg_orders into the schema analytics_marketing. The default generate_schema_name macro checks whether the custom schema is none; since config(schema='marketing') sets it to marketing, the macro returns the target schema (analytics), an underscore, and the custom schema name, producing analytics_marketing.

Why:

The default generate_schema_name macro in dbt Core 1.7+ checks whether custom_schema_name is none. If it is, it returns the target schema alone. Here config(schema='marketing') sets custom_schema_name to marketing, so the macro returns the target schema (analytics) joined to the custom schema with an underscore, yielding analytics_marketing. The question asks only for the schema name, so the answer is analytics_marketing and the database is not part of it.

Consider this dbt project with four models and two sources defined in sources.yml (the raw source with tables customers and orders already exists in the warehouse):#

Show answer

dbt builds four models: stg_customers, stg_orders, orders_enriched, and customer_summary. The leading + selects customer_summary plus all of its upstream ancestors via ref() calls, and orders_enriched is the only direct parent while stg_orders and stg_customers are its ancestors. The raw sources are not built or executed by dbt run; they are treated as pre-existing tables and appear only as lineage leaves.

Why:

The leading + in +customer_summary tells dbt to select that model together with every ancestor in the DAG, meaning every model reachable by following ref() calls upward. customer_summary refs orders_enriched, which refs both stg_orders and stg_customers, so all three ancestors plus customer_summary itself are selected and built in dependency order (parents before children). source() calls create source nodes (raw.customers, raw.orders) in the lineage graph, but dbt run never builds or executes sources; they are treated as pre-existing tables and excluded from the run even though they appear as leaves in the graph.

How does dbt determine the build order for the models in a project?#

Show answer

dbt parses each model's SQL for {{ ref('...') }} and {{ source('...') }} calls, constructs a directed acyclic graph (DAG) where each ref() creates an edge from the referenced model to the referencing model, then topologically sorts the DAG so that every model is built only after all of its upstream dependencies.

Why:

dbt statically parses model code for {{ ref('...') }} and {{ source('...') }} calls at compile time. Each ref('model_b') inside model_a creates a directed edge model_b -> model_a in the DAG. source() calls create leaf source nodes but those are never executed by dbt run. dbt then topologically sorts the graph so that parents always run before children, which is the build order used during dbt run.

In a dbt project, every {{ ref('parent') }} call inside a model creates a directed dependency: the child model depends on the parent model. The full set of these edges forms the project's DAG.#

Starter code

-- TODO: complete the recursive CTE to traverse the full upstream DAG
WITH RECURSIVE upstream AS (
    SELECT DISTINCT parent_model AS model_name
    FROM model_deps
    WHERE child_model = 'fct_orders'

    UNION

    SELECT md.parent_model
    FROM model_deps md
    JOIN upstream u ON 1 = 1  -- BUG: this cross-joins; replace with the correct lineage condition
)
SELECT DISTINCT model_name
FROM upstream
ORDER BY model_name;

Your solution must pass

  • standard_dag

This one is written and run, not read. Solve it in the app and your code is executed against these tests and the hidden ones.

A dbt project has these lineage edges, where each ref() or source() makes the right-hand node depend on the left-hand node: stg_customers → int_customer_orders, stg_orders → int_customer_orders, int_customer_orders → fct_orders, fct_orders → mart_revenue_summary. stg_customers reads source('raw','customers') and stg_orders reads source('raw','orders'), and the raw sources have no ancestors. Running dbt run --select 2+fct_orders executes which model nodes? Name every model that is executed, and explain why mart_revenue_summary is not included and why the source nodes are not counted as executed models.#

Show answer

dbt run --select 2+fct_orders executes four models: fct_orders, int_customer_orders, stg_customers, and stg_orders. The 2+ prefix walks upstream (ancestor) edges from fct_orders for at most two generations: int_customer_orders is generation 1, and its parents stg_customers and stg_orders are generation 2. mart_revenue_summary is a downstream descendant, and the + prefix never traverses downstream, so it is excluded. The raw sources sit at generation 3, beyond the limit, and dbt run never executes sources in any case because they are not models, so they are not part of the executed set.

Why:

The N+ selector picks the named node plus its ancestors limited to N upstream generations. From fct_orders, int_customer_orders is one generation up, and stg_customers and stg_orders are two generations up, so all four of those models fall within the window. mart_revenue_summary is downstream of fct_orders and the selector does not traverse downstream edges, so it is excluded. The raw sources are a third generation up and sit outside the window; they are also not models that dbt run executes. The executed set is therefore exactly fct_orders, int_customer_orders, stg_customers, and stg_orders.

In a dbt project, fct_orders calls ref('int_customer_orders'). int_customer_orders calls ref('stg_customers') and ref('stg_orders'). stg_customers calls source('raw', 'customers') and stg_orders calls source('raw', 'orders'). The command dbt run --select 2+fct_orders also selects the source nodes raw.customers and raw.orders. Is this statement true or false?#

Options

Show answer

The statement is false: dbt run --select 2+fct_orders does not select the source nodes raw.customers and raw.orders. The 2+ prefix walks at most two upstream generations from the named model. Those sources sit three generations above fct_orders, so the selector stops before reaching them.

Why:

The N+model_name selector includes the named node plus its ancestors up to N generations of upstream edges. Counting upstream from fct_orders (generation 0): int_customer_orders is generation 1, stg_customers and stg_orders are generation 2, and the source nodes raw.customers and raw.orders are generation 3. Because 2+ stops after generation 2, the sources fall outside the selection, so the statement is false. The selected set is fct_orders, int_customer_orders, stg_customers, and stg_orders. Using +fct_orders with no numeric limit would have included the sources.

A dbt project has these models with the following ref() dependencies (each arrow means the source model refs the target at compile time, establishing the DAG lineage dbt uses for execution ordering and cascading skip behavior on failure): stg_customers → raw_customers; stg_orders → raw_orders; int_orders_joined → stg_orders and stg_customers; dim_customers → stg_customers; fct_orders → int_orders_joined. During a full dbt run, the model stg_customers raises an error and fails. Which downstream models will dbt skip as a result of that failure? List the exact model names.#

Show answer

dbt will skip int_orders_joined, fct_orders, and dim_customers. Since stg_customers fails, dbt cancels every model that depends on it directly or transitively. int_orders_joined and dim_customers both ref stg_customers directly, so they are skipped. fct_orders depends on int_orders_joined, so it is also skipped transitively. The model stg_orders does not depend on stg_customers, so it still runs.

Why:

When a model fails during a dbt run, dbt marks all of its downstream dependents (models that ref it, directly or transitively) as skipped rather than attempting to run them against a missing or stale upstream. The lineage here is: stg_customers is referenced directly by int_orders_joined (which refs both stg_orders and stg_customers) and by dim_customers (which refs only stg_customers). fct_orders refs int_orders_joined, making it a transitive dependent of stg_customers. Therefore the skipped set is exactly int_orders_joined, fct_orders, and dim_customers. stg_orders is unaffected because it has no dependency path to or from stg_customers.

In dbt, every model creates a node in a DAG. Dependencies arise from two macros: ref('other_model') (depends on another model) and source('raw','table') (depends on an external source that is never itself executed). dbt's --select grammar uses + to expand the selection: +model means the model and all upstream dependencies (ancestors), model+ means the model and all downstream dependents (descendants), and +model+ means both. Given a list of model definitions and a selector string, resolve the set of models that should be executed and return their names in topological order (every dependency before its dependents). When multiple models are simultaneously unblocked, break ties alphabetically. If the selected subgraph contains a cycle, return None.#

Starter code

def resolve_execution_order(models, selector):
    model_map = {m['name']: m for m in models}

    # TODO: Build dependency adjacency (refs only) and reverse adjacency.
    # TODO: Parse the selector ('+' prefix = upstream, '+' suffix = downstream).
    # TODO: BFS in each direction to resolve the selected set.
    # TODO: Topologically sort the selected set (alphabetical tie-break).
    # TODO: Return None if a cycle is detected among selected models.

    return []

Your solution must pass

  • upstream_selection
  • downstream_selection

This one is written and run, not read. Solve it in the app and your code is executed against these tests and the hidden ones.

In a dbt project with target.schema set to analytics, you are using dbt's default, unmodified generate_schema_name macro. The project is configured as follows in dbt_project.yml (the indented hierarchy maps to the directory structure under models/):#

Show answer

models/shared/common_dim.sql will be built into the analytics schema. The shared directory has no +schema key, so custom_schema_name is none; the default macro's none branch returns target.schema, which is analytics.

models/marketing/campaigns.sql will be built into the marketing schema. The +schema: marketing key sets custom_schema_name to marketing; since it is not none, the macro's else branch returns custom_schema_name | trim, i.e. marketing, without concatenating target.schema.

Why:

The default generate_schema_name macro checks whether custom_schema_name is none. For models/shared/common_dim.sql, no +schema is configured, so custom_schema_name is none and the macro returns target.schema (analytics). For models/marketing/campaigns.sql, the +schema: marketing key sets custom_schema_name to marketing; since it is not none, the macro returns just custom_schema_name | trim (marketing) without prepending or concatenating target.schema. This non-concatenating behavior is the key point: the default macro does not produce analytics_marketing. A custom schema name replaces target.schema; it does not extend it.

In distributed systems, linearizability is said to be a stronger consistency model than sequential consistency. State the specific ordering constraint that linearizability guarantees but sequential consistency does not, and explain why sequential consistency permits what linearizability forbids in this regard.#

Show answer

Linearizability preserves real-time ordering: if operation A completes before operation B starts (they do not overlap in real time), then in the equivalent sequential history A must appear before B. Each operation is anchored to a linearization point within its real-time execution interval. Sequential consistency does not preserve real-time ordering between operations from different processes; it only requires a single sequential history that respects each individual process's program order. Therefore, sequential consistency permits a read or write by one process to appear before an already-completed operation by another process, as long as each process's own operations remain in program order. Linearizability forbids this because it ties every operation to a single atomic point between its invocation and response, forcing all observers to agree on the real-time order of non-overlapping operations.

Why:

Linearizability is a strict refinement of sequential consistency. Sequential consistency requires only that there exist a total order of all operations consistent with each process's program order, and it makes no claim about the real-time relationship between operations on different processes. Linearizability adds the real-time constraint: if operation A completes (responds) before operation B begins (is invoked), the sequential history must place A before B. This is achieved by assigning each operation a linearization point atomically within its execution interval, so non-overlapping operations inherit the real-time order of their intervals. Because of this, a linearizable history is always sequentially consistent, but a sequentially consistent history may violate real-time ordering and thus fail linearizability; for example, a write by one process that completes before a read by another process could appear after that read under sequential consistency, which linearizability forbids.

In the Raft consensus algorithm, the 'Figure-8' scenario (Raft paper, §5.4.2) demonstrates that a log entry from a prior leader's term could be replicated to a majority of servers yet later be silently overwritten by a different entry from a newer leader's term. What specific rule does Raft impose on when a leader may advance its commitIndex, and why is this restriction necessary to preserve State Machine Safety?#

Show answer

Raft requires that a leader may only advance commitIndex by counting replicas of log entries that belong to the leader's current term. An entry from a prior term may already be stored on a majority of servers, yet a later leader can still overwrite it, because a server's log is only protected by the election restriction and a newer leader's log can differ at that index. The current leader cannot tell from its own log whether such a prior-term entry is truly safe from being overwritten, so counting it would risk committing an entry that a different leader later replaces, which would violate State Machine Safety. By committing only current-term entries, and committing earlier entries transitively once a current-term entry on the same log prefix is committed, Raft ensures that no committed entry is ever overwritten.

Why:

Raft's rule is that a leader advances commitIndex only when the entry being counted from a majority belongs to the leader's current term; prior-term entries are committed only indirectly, when a current-term entry after them reaches a majority. The Figure-8 scenario shows why: an entry from an earlier term can sit on a majority of servers and still be overwritten by a later leader whose log lacks it, so majority replication alone does not make it committed. Committing a current-term entry is safe because, by the election restriction, any future leader must already contain every committed entry, and once a current-term entry is committed all entries before it on that log are committed transitively. This preserves State Machine Safety, since no committed entry can later be replaced.

Related interview questions

Job market

See dbt salaries and hiring demand from live job postings.

Practise these until they stick

That's every question we hold on this topic, and the page marks what you pick. What it can't do is remember. A free account keeps every answer, and what you miss comes back until it's right: after a day, then at longer gaps.

Start with this topic

Free · the whole bank · 100 marked answers per 30 days · written feedback on the paid plan

Interview booked? Practise the job ad instead

What moved, monthly

One email a month when the bulletin comes out: what moved in the markets we track, and the new question topics we published. Confirm your address to join. Unsubscribe any time.