What you will be able to do
- Explain how a stateless, idempotent tool uses artifacts from a previous run, and which features need the --state flag
- Choose between state:new, state:modified, state:old, state:unmodified and the state:modified subselectors
- Predict which changes do and do not mark a node as modified, including tags, meta, macros, seeds and vars
- Rerun work with result: selectors and source_status:fresher+, and avoid the result:fail+ trap
- Configure --state, --defer and --defer-state, and keep CI from comparing against a manifest it just overwrote
Key concept
State (the --state manifest) — State is a saved set of artifacts, mainly manifest.json, from an earlier dbt invocation. When you pass its path to --state, dbt compares your current project with that snapshot to decide what to select, and the same snapshot tells it where unbuilt parents live.
1.Why a stateless tool keeps state
dbt is designed so that a run does not depend on earlier runs. If you run a model once or a thousand times on the same raw data, you get the same result. A run only needs the project code and the database objects that exist right now. This property is called idempotence, and every state feature depends on it: if a model returns different output on a rerun, it is not safe to skip, compare or defer.
dbt still keeps a record of what happened, in the form of artifacts. The most important one is manifest.json, which describes every resource in the project, including models, tests, macros, sources and exposures, along with their configs and properties. Any command that parses the project writes it, which is every command except deps, clean, debug and init. Even a run that selects only a few models still lists every enabled resource in the manifest. By default the manifest is written to target/.
Passing the path to an old manifest with --state lets dbt use that snapshot. Three features depend on it:
- the state: selector, which finds new or modified resources by comparing the current code with the saved manifest
- deferral, which points references to unbuilt, unselected parents at the environment recorded in the manifest
- dbt clone, which clones nodes based on where the manifest says they live (see [dbt clone](lesson:4.2))
Using the state selector and deferral together is called "slim CI": a pull request builds only what changed and reads everything else from production.
Checkpoint 1 of 9· Check yourself
A colleague says that once a CI job reads a production manifest through --state, its results are no longer reproducible. What is correct?
The artifacts affect what dbt selects and how it resolves references. The operations are still stateless, so the same inputs, manifest included, give the same output.
“given the same manifest and the same raw data, dbt will produce the same transformed result.”Source: docs.getdbt.com
2.Selecting new and modified nodes with state:
The state: method compares nodes by unique_id. A node is new if the comparison manifest has no node with the same unique_id. A node is modified if it is new or if anything about an existing node changed. For each of these there is an inverse: state:old and state:unmodified, which are useful for excluding nodes that have not changed. Because a single modified check can catch more than you want, dbt also offers subselectors that each check one kind of change.
| Selector | Matches |
|---|---|
| state:new | No node with the same unique_id exists in the comparison manifest |
| state:old | A node with the same unique_id exists in the comparison manifest |
| state:modified | All new nodes, plus any change to existing nodes |
| state:unmodified | Existing nodes with no changes |
| state:modified.body | Changes to the node body, such as model SQL or seed values |
| state:modified.configs | Config changes, excluding database/schema/alias/tags/meta |
| state:modified.relation | Changes to database/schema/alias |
| state:modified.persisted_descriptions | Description changes, only where persist_docs is enabled |
| state:modified.macros | Changes to upstream macros, called directly or indirectly |
| state:modified.contract | Changes to a model's contract (column name and data_type) |
The rule behind modification is simple: a change counts if it could affect how the resource is materialized. That is why tags and meta are ignored. A description counts only when persist_docs writes it to the database. Every other config change counts. Plain state:modified also checks a few things specific to certain resource types, such as a source's freshness or quoting rules, an exposure's maturity, and changes to access, deprecation_date or latest_version, because each of these can change how references resolve. A changed contract is a special case: removing a column or changing its type is a breaking change and raises an error.
Some changes are detected through dependencies, and some are not detected at all. A model that calls a changed macro is marked modified, and so is a model whose macro calls a changed macro. Seeds are compared in two ways. For a seed under 1 MiB, dbt stores a hash of the file, so editing one row is enough to mark it modified. For a seed of 1 MiB or more, dbt only checks whether the file path changed, and it warns that it cannot compare the contents. Changes that arrive through var or env_var are not detected as lineage. The model is caught only if the new value produces a different configuration.
dbt run -s "state:modified"
dbt test -s "state:modified"Checkpoint 2 of 9· Check yourself
An analyst corrects one row in a 3 MiB country_codes.csv seed and opens a pull request. The CI job runs dbt build --select state:modified+. What happens to the seed?
dbt does not hash seeds of 1 MiB or more. It warns and compares only the path, so a content-only edit is invisible to state:modified.
“Seed files 1 MiB or larger — Included in the state:modified selector only when the seed file path changes.”Source: docs.getdbt.com
Checkpoint 3 of 9· Exam question
Your CI pipeline runs the following commands in order: `dbt build --select state:modified+ --state ./target --target-path ./target`, where `./target` holds both the previous deploy's manifest and the current run's compiled artifacts. On the next CI run, `state:modified+` silently returns no results even though model changes were merged in between. What is the most likely cause, and what should you change?
Correct answer: A — The current run's own parsing overwrote `./target/manifest.json` before comparison ran, so `--state` should point at a manifest stored in a separate `./state` folder, decoupled from `--target-path`.
- A. Pointing `--state` and `--target-path` at the same directory means the parse step for the current run writes a fresh manifest.json into that folder before the comparison happens, so dbt ends up comparing the project against itself. Storing the prior manifest in a dedicated path like `./state`, separate from where the current run writes its own artifacts, removes that overwrite risk.
- B. A leading `+` extends selection upstream to ancestors, which is unrelated to why the comparison returns nothing; the problem here is that the comparison manifest itself is no longer the prior deploy's manifest. Adding an upstream operator would not restore a correct baseline to compare against.
- C. `dbt build` accepts `--target-path` and `--state` together without conflict; they are independent flags controlling where compiled output goes and what manifest to diff against, respectively. Removing `--target-path` would not address the real issue, which is that both flags happen to resolve to the same directory.
- D. `state:modified` compares the manifest, not run_results.json — that file backs `result:` selectors and `dbt retry`, not `state:modified`. Running `dbt retry` first would not refresh or protect the comparison manifest and would not fix the described symptom.
3.Selecting by earlier results: result: and source_status:
Run results are also part of state. dbt run, dbt test, dbt build and dbt seed each write run_results.json, which records each node's execution time and status. With result:<status> and --state, a later command can select nodes by how they finished last time. Not every status applies to every resource type: tests are the only nodes that fail, warn or pass, and tests are never reported as success or skipped.
| Selector | model | seed | snapshot | test |
|---|---|---|---|---|
| result:error | yes | yes | yes | yes |
| result:success | yes | yes | yes | — |
| result:skipped | yes | yes | yes | — |
| result:fail | — | — | — | yes |
| result:warn | — | — | — | yes |
| result:pass | — | — | — | yes |
# run all models that generated errors on the prior invocation of dbt run
dbt run --select "result:error" --state path/to/artifacts
# run all tests that failed on the prior invocation of dbt test
dbt test --select "result:fail" --state path/to/artifacts
# run all the models associated with failed tests from the prior invocation of dbt build
dbt build --select "1+result:fail" --state path/to/artifactsWhere you put the graph operator matters. A test has no downstream nodes, so result:fail+ selects only the failed test. It does not select the model that produced the bad data. To rebuild that model, put the operator in front, as in 1+result:fail, which adds the test's direct parents. 1+result:fail+ also adds everything downstream of those parents. By contrast, result:error matches any resource type that errored. You can also combine state and result selectors in one command, so a single CI step can retry last run's errors and build new work at the same time. For rerunning a failed job from the point of failure, there is a dedicated command, [dbt retry](lesson:7.2).
Checkpoint 4 of 9· Check yourself
After a nightly dbt build, the unique test on fct_orders fails. You fix the model SQL and want to rebuild fct_orders and re-run the test. Which selector does that?
Tests have no children, so result:fail+ returns only the test. The 1+ prefix adds the test's direct parent, fct_orders.
“Tests don't have downstream nodes in the DAG, so using the result:fail+ selector will only return the failed test itself”Source: docs.getdbt.com
Checkpoint 5 of 9· Fill the gap
Complete this command so it captures last run's errors OR any new or modified models, plus their children.
dbt run --select "result:<status>+" ? --defer --state ./<dbt-artifact-path>The two selectors are separated by a space, which means union. state:modified+ adds new or changed models and everything downstream of them.
Source: docs.getdbt.comSource freshness is also stored as state. dbt source freshness writes sources.json, which records max_loaded_at for each source. The selector source_status:fresher+ compares two of these files and builds only the sources that received new data, plus everything downstream of them. The comparison needs a sources.json on both sides, so freshness has to run in the job that produced the state and again in the current job. Setting up freshness checks is covered in [source freshness](lesson:6.2).
# You can also set the DBT_ENGINE_STATE environment variable instead of the --state flag.
dbt source freshness # must be run again to compare current to previous state
dbt build --select "source_status:fresher+" --state path/to/prod/artifactsCheckpoint 6 of 9· Exam question
Review this CI script: ```bash dbt deps dbt build --select state:modified+ --defer --state ./state cp target/manifest.json ./state/manifest.json ``` Each run mostly works, but regressions from two runs ago keep resurfacing as "modified" even when nothing has changed since the last successful run. Which line is misconfigured, and what should happen instead?
Correct answer: A — The `cp` line runs after every build regardless of outcome, so a failed run's manifest overwrites the last known-good state; it should run only once a build finishes successfully.
- A. Because the `cp` command has no conditional guard, it copies whatever manifest.json a run produced into the state folder even when that run errored out partway through, poisoning the baseline for the next comparison with a broken or incomplete manifest. Gating the copy behind a successful build keeps the stored baseline trustworthy.
- B. `dbt deps` installs the packages a project depends on before parsing can succeed at all; skipping it risks the entire parse failing if any package-sourced macros or models are referenced. It has no bearing on the described symptom of stale regressions resurfacing.
- C. `--favor-state` changes how dbt chooses between a locally-built relation and the deferred one when both exist, it is not a substitute for `--defer` itself and swapping the flags would not fix a corrupted baseline manifest. The described symptom is about a bad state artifact, not about which relation gets favored.
- D. Pointing `--state` at `./target` would recreate the exact overwrite problem this script is trying to avoid, since the current run also writes its manifest there. The script's separate `./state` directory is the correct pattern; the bug is in how that directory gets updated, not where it points.
4.Deferral and separate state paths
State selection alone leaves a gap. If CI builds only a modified model_b, its ref('model_a') points to the CI schema, where model_a was never built, and the query fails. --defer closes that gap. dbt points a ref at the state manifest's environment only when two conditions hold: the referenced node is not selected, and it does not exist in the current target (or --favor-state is passed). Ephemeral models are never deferred, because they are inlined into the queries that reference them. Deferral needs both --defer and --state.
dbt run --select [...] --defer --state path/to/artifacts
dbt test --select [...] --defer --state path/to/artifactsComparison and deferral can use different manifests. --state sets the manifest you compare against to find changes, and --defer-state sets the manifest that unbuilt references resolve to. If --defer-state is not set, deferral uses --state. Usually that is what you want: compare against production and fall back to production. Use a separate path when you want to compare against one point in time but defer to an environment that was built at another.
| CLI flag | Environment variable | Value |
|---|---|---|
| --state | DBT_ENGINE_STATE | file path |
| --defer | DBT_ENGINE_DEFER | boolean |
| --defer-state | DBT_ENGINE_DEFER_STATE | file path, deferral only (optional) |
If you set both a flag and its environment variable, the flag wins. The old variable names DBT_ARTIFACT_STATE_PATH and DBT_DEFER_TO_STATE were deprecated in dbt v1.5 and still work for now. The state artifacts must use a schema version that the running dbt version can read. dbt v1.8 through v2.0 all write manifest v12.
Deferral can make tests behave unexpectedly. dbt test -s state:modified selects tests on new or modified resources and also tests that are themselves new or modified. A new relationships test between one modified model and one unmodified model will compare the CI copy of one with the production copy of the other. If CI uses limited or sampled data, that test is likely to fail for reasons unrelated to your change. Excluding those tests from the CI test step is one fix.
dbt run -s "state:modified"
dbt test -s "state:modified" --exclude "test_name:relationships"Checkpoint 7 of 9· Check yourself
A CI job runs dbt run --select int_orders --defer --state prod-artifacts. None of int_orders' parents is selected or built in the CI schema. Which parent reference still resolves to the CI schema rather than to production?
Unselected parents that do not exist in the target are deferred to production. Ephemeral models are never deferred, because they are inlined rather than built.
“Ephemeral models are never deferred”Source: docs.getdbt.com
5.State pitfalls in CI and scheduled jobs
Pointing --state at target/ breaks change detection. dbt overwrites target/manifest.json while it parses. It writes the new manifest first and reads it back afterwards, so it compares the project with itself, finds no changes, and may warn that the saved manifest was not found. Do not set --state and --target-path to the same location. There are three ways to fix the workflow:
- move the generated manifest.json into its own folder, such as state/, before the next invocation
- write the build stage's output to a different --target-path
- pass --no-write-json when listing modified nodes, as in dbt ls --no-write-json --select state:modified --state target
state:modified only notices changes to code and configuration. New rows in a source table are not a modification. A scheduled job that selects state:modified+ will succeed without building anything on nights when no code changed. That is expected behavior, and it is a sign the selector is in the wrong job. Use it in CI and merge jobs, where the goal is to build what a change touched. Production jobs that must build every time should not use it.
Checkpoint 8 of 9· Check yourself
A nightly production job runs dbt build --select state:modified+ --state prod-artifacts. New orders land every night, but the marts stopped updating last week. What is the fix?
state:modified detects code, config and manifest-level changes, never new data. A job that must build every night should not filter on it.
“If your job needs to build models on every scheduled run regardless of code changes, remove the state:modified selector from that job.”Source: docs.getdbt.com
Configs that change by environment used to cause false positives. For example, a materialization set through env_var renders differently in dev and prod, so it could look modified when nothing changed. The behavior flag state_modified_compare_more_unrendered_values fixes this by comparing the unrendered Jinja text instead of the rendered values. It defaults to true from dbt v1.12 and the 2026.09 v1 Latest track, and in v2 the flag is removed and the behavior is always on (see [behavior flags](lesson:3.5)). Expect one side effect. The first CI run after the switch compares a baseline that stored rendered values with a new parse that stores Jinja text, so every node with Jinja in its config looks modified. That costs one extra rebuild. When production writes a new baseline manifest, the extra matches disappear.
Checkpoint 9 of 9· Exam question
A nightly `dbt build` runs 20 models. 12 complete successfully, 1 fails on a SQL compilation error, and 7 downstream models are skipped as a result. After fixing the SQL error in that one model, what is the most efficient way to complete the run without re-executing the 12 models that already succeeded?
Correct answer: A — Run `dbt retry`, which reads `run_results.json` from the failed invocation and re-executes only the errored and skipped nodes, leaving the 12 successful models untouched.
- A. `dbt retry` is built specifically for this scenario: it inspects `run_results.json` from the prior invocation and resumes execution starting from the errored and skipped nodes, skipping anything that already completed successfully. This is the most direct path back to a fully successful run.
- B. A full refresh rebuilds all 20 models regardless of their prior status, throwing away the work already done by the 12 that succeeded and taking far longer than necessary to recover from a single model's SQL error.
- C. State comparison against last week's manifest would select the fixed model along with anything else that changed since then, but it has no awareness of which nodes errored or were skipped in last night's specific run, so it isn't guaranteed to line up cleanly with the 8 nodes that need rerunning.
- D. `dbt clean` removes compiled artifacts and installed packages, none of which caused the SQL compilation error; it does nothing to target just the failed and skipped nodes, so a subsequent full build would still re-execute all 20 models.
Exam traps
Each one states something that sounds right. Open it to see what is actually true.
1.Editing a model's tags or meta in YAML makes it state:modified, so CI will rebuild it.Why is that wrong?
tags and meta are metadata-only, at both the resource and the column level. They never trigger state:modified. A description change counts only when persist_docs is enabled.
Covered in Selecting new and modified nodes with state:
2.result:fail+ reruns the failed test along with the model it tests and that model's children.Why is that wrong?
Tests have no downstream nodes, so result:fail+ returns only the test. Use 1+result:fail to include the tested model, or 1+result:fail+ to add its descendants too.
Covered in Selecting by earlier results: result: and source_status:
3.state:modified+ will pick up models whose source tables received new rows.Why is that wrong?
state:modified only detects changes to code, configuration and manifest metadata. When none of those changed, the job succeeds and builds nothing.
Covered in State pitfalls in CI and scheduled jobs
4.Pointing --state at target/ in the same job is fine, because the previous manifest is already there.Why is that wrong?
dbt overwrites target/manifest.json during parsing, before it reads it for comparison. Keep the saved manifest in a separate folder or use a different --target-path.
Covered in State pitfalls in CI and scheduled jobs
Practise it for real
Save a baseline manifest and use state:modified to list exactly what an edit changed
1.Run any parsing command, such as dbt ls, then copy target/manifest.json into a new folder called state/.
Why: The baseline has to live outside target/, because dbt overwrites target/manifest.json every time it parses.
You should see: state/manifest.json exists and will not change when you run dbt again.
2.Run dbt ls --select "state:modified" --state state/ without editing anything.
Why: This confirms that the comparison is against the saved snapshot and not against a manifest that was just rewritten.
You should see: No nodes are listed.
3.Add a tag to one model's YAML, then run the same dbt ls command again.
Why: tags are metadata, not modifications.
You should see: No nodes are listed, even though the YAML changed.
4.Edit that model's SQL and run dbt ls --select "state:modified+" --state state/.
Why: A body change counts as a modification, and + adds everything downstream.
You should see: The edited model and its children are listed.
Stuck? Get a nudge
If step 2 lists nodes you did not touch, check whether your config uses Jinja such as env_var and which dbt version wrote the baseline.
Sources
Every claim above is drawn from one of these pages, quoted as it was written on the date shown.
- 1.
“dbt can leverage artifacts from a prior invocation as long as their file path is passed to the --state flag.”
↩︎ Why a stateless tool keeps state“dbt can leverage artifacts from a prior invocation as long as their file path is passed to the --state flag.”
↩︎ Key concept“given the same manifest and the same raw data, dbt will produce the same transformed result.”
↩︎ Checkpoint - 2.
“This file contains a full representation of your dbt project's resources”
↩︎ Why a stateless tool keeps state - 3.
“state:new: There is no node with the same unique_id in the comparison manifest”
↩︎ Selecting new and modified nodes with state:“can be used to select resources based on their result status from a prior run”
↩︎ Selecting by earlier results: result: and source_status:“using the result:fail+ selector will only return the failed test itself and not the model or anything built on top of it”
↩︎ Exam trap 2“Changing a tags or meta value (including on individual columns in a YAML file) doesn't trigger state:modified”
↩︎ Prediction“Seed files 1 MiB or larger — Included in the state:modified selector only when the seed file path changes.”
↩︎ Checkpoint“Tests don't have downstream nodes in the DAG, so using the result:fail+ selector will only return the failed test itself”
↩︎ Checkpoint - 4.
“dbt will mark modified any resource that depends on a changed macro, or on a macro that depends on a changed macro.”
↩︎ Selecting new and modified nodes with state:“First, it overwrites target/manifest.json before it can be used for change detection.”
↩︎ State pitfalls in CI and scheduled jobs“Changes to tags and meta, whether set at the resource level or on individual columns in a YAML file, don't count as modifications”
↩︎ Exam trap 1“When dbt detects no code or configuration changes since the deferred manifest was last produced, the job succeeds without building any models.”
↩︎ Exam trap 3“Avoid setting --state and --target-path to the same path with state-dependent features like --defer and state:modified”
↩︎ Exam trap 4“If your job needs to build models on every scheduled run regardless of code changes, remove the state:modified selector from that job.”
↩︎ Checkpoint - 5.
“This requires both the previous and current states to have the sources.json artifact available.”
↩︎ Selecting by earlier results: result: and source_status:“If both the flag and env var are provided, the flag takes precedence.”
↩︎ Deferral and separate state paths - 6.
“If --defer-state is not specified, deferral will use the manifest supplied to --state.”
↩︎ Deferral and separate state paths“Ephemeral models are never deferred”
↩︎ Checkpoint - 7.https://docs.getdbt.com/reference/global-configs/behavior-flags/state_modified_compare_more_unrendered_valuesOfficial docs
“In dbt v1.12, state_modified_compare_more_unrendered_values defaults to true, reducing false positives during state:modified checks”
↩︎ State pitfalls in CI and scheduled jobs