A dbt project is a collection of SQL files that reference other SQL files. The reference is not a string concatenation; it is a Jinja function call that returns a fully qualified warehouse identifier at compile time. Every {{ ref('stg_customers') }} resolves to something like acme_analytics.dbt_prod.stg_customers, and every {{ source('jaffle', 'raw_customers') }} resolves to whatever database and schema the YAML source declaration says the raw object lives in. The accuracy of dependency resolution is the most important property of a dbt project, because the DAG that the scheduler uses to order runs is built directly from the parse of those two functions across every model in the project.
The first dependency you have to verify is the one that points outside dbt itself: the raw object. source() is the only safe way to read from a table dbt did not create. Hard-coded from analytics_raw.jaffle.raw_customers strings break the lineage graph because dbt cannot see them, which means a column rename in the source will not propagate as a build error and a downstream model will silently start returning incorrect data. Sources are declared in a YAML file under the models/ tree, typically in models/staging/jaffle/_jaffle__sources.yml. The YAML lists the source name, the database and schema it points to, and the tables that belong to it. Once declared, every reference in SQL is through {{ source('jaffle', 'raw_customers') }}.
The second dependency category is the one that points inside dbt: a model that depends on another model. ref() takes a model name and returns the resolved warehouse identifier of that model. The model name is the file name without the .sql extension and is unique within the project. If you build a model called int_orders_enriched.sql, every downstream model that needs that result writes {{ ref('int_orders_enriched') }}. The dbt compiler walks every model, parses every Jinja call, and stitches them into a directed acyclic graph stored as target/manifest.json. The order of dbt run is a topological sort of that graph, which is why a missing ref() does not just hide a dependency; it actively rearranges the run order and produces failures that look like race conditions but are really missing edges.
Verifying that a dependency is wired correctly is a routine activity. The first technique is reading the compiled SQL under target/compiled/<project>/<model_path>/<model>.sql. The compiled file shows every Jinja call resolved to a real warehouse identifier, so a {{ ref('stg_orders') }} that does not appear in the compiled file means the model never referenced the upstream and the DAG edge is missing. The second technique is dbt ls, which prints every node dbt recognises, filtered by selector syntax. dbt ls --select +int_orders_enriched returns the model and every ancestor; if a staging model you expect to see is absent, the dependency is missing. The third technique is dbt deps followed by dbt compile, which verifies that every Jinja function call resolves without error before any SQL hits the warehouse.
A common failure mode is hardcoding a database or schema name. New analysts often write from dev_raw.jaffle.raw_customers because that worked in their development environment, then promote that code to production where the raw schema is called something else. The model now points to a non-existent object in production, and the failure surfaces as a database error, not a dbt error. The fix is always the same: replace the hardcoded reference with a source() call backed by an environment-aware YAML declaration. Environment switching is handled at the connection level (the target in profiles.yml) and at the source declaration level (database and schema fields can reference environment variables), so the same SQL code resolves to different objects in dev, ci, and prod without edits.
Using dbt packages
A dbt package is a dbt project distributed for reuse. Packages contain models, macros, tests, seeds, and snapshots that the consuming project can call as if it had authored them. The two packages every practitioner sees on the exam are dbt_utils (canonical helpers like surrogate_key, union_relations, date_spine, pivot, not_empty_string) and dbt_expectations (an expanded test suite that mirrors Great Expectations). Packages are installed by listing them in packages.yml at the project root and running dbt deps, which clones them into the dbt_packages/ directory and registers their nodes in the project's DAG.
# packages.yml
packages:
- package: dbt-labs/dbt_utils
version: 1.3.0
- package: calogica/dbt_expectations
version: 0.10.4
- git: "https://github.com/acme/dbt_internal_helpers.git"
revision: v0.4.0
The exam tests the difference between dbt deps and dbt clean. dbt deps installs or updates packages into dbt_packages/ and is required after any change to packages.yml. dbt clean deletes the contents of any directory listed under clean-targets: in dbt_project.yml, which by default is target/ and dbt_packages/. Running dbt clean discards installed packages, so it must be followed by dbt deps before the next dbt run. Mixing these two up is a classic distractor.
dbt deps # install packages declared in packages.yml
dbt compile # verify all package references resolve
dbt clean # delete target/ and dbt_packages/
When you call {{ dbt_utils.generate_surrogate_key(['order_id', 'order_date']) }} inside a model, dbt resolves that macro at compile time and inlines the SQL the macro returns. The macro itself lives under dbt_packages/dbt_utils/macros/. The package version is pinned in packages.yml, and version drift is a real risk: an unpinned package can silently change behavior between runs, so production projects pin to a major-minor and only upgrade deliberately. Private packages are pulled from git via the git: and revision: keys, and the revision should be a tag or a commit SHA, never main.
💡 Exam Trap: Hardcoded
from database.schema.tablestrings inside a model break the dbt DAG even when they compile successfully. The model will run, butdbt ls --select +my_modelwill not show the upstream, anddbt run --select state:modified+will not rebuild downstream models when the raw object changes shape.
💡 Exam Trap: A package change in
packages.ymldoes not take effect until you re-rundbt deps. Runningdbt runafter editingpackages.ymlwithoutdbt depseither compiles against the old package version or fails with a missing macro error, depending on whether the package was previously installed.
⚠️ Anti-Pattern: Pinning a package to a git branch like
mainso that "we always get the latest features". A branch pin reintroduces non-determinism into every CI build and is the most common cause of "the same SQL passed yesterday and fails today" tickets. Always pin to a semantic version, a git tag, or a commit SHA.
📊 Limit: A dbt project can install any number of packages, but every macro name across all installed packages must be unique unless you qualify it with the package namespace, for example
{{ dbt_utils.surrogate_key(...) }}rather than the bare{{ surrogate_key(...) }}. Collisions raise a compile error.
🎯 Scenario: A team installs a private analytics-helpers package pinned to
revision: main. The package author renames a macro fromsafe_dividetosafe_divin a commit. The next morning, every dbt build in CI fails with "macro 'safe_divide' not found". The fix is twofold: revert the rename in the package, and pin the consuming project'spackages.ymlto a commit SHA so a future rename cannot break builds without a deliberate upgrade.
Decision Anchor. Choose ref() when the upstream is a model that dbt builds (anything under models/, plus seeds and snapshots). Choose source() when the upstream is a raw table loaded by an external tool (Fivetran, Stitch, Snowpipe, Airbyte). Never call a raw table by its database.schema.table identifier inside a model.
