Atlas vs dbt: Different Jobs, Better Together
These are not competing tools. dbt transforms data inside your schemas. It compiles SQL, resolves a model DAG, and materializes the tables and views its models describe. Atlas manages the schema layer those models sit on, meaning the databases, source tables, lookup data, and roles and grants, plus migration history, linting and CI review for all of it. Most teams that need one need the other, and the two run against the same warehouse without overlapping.
What Each Owns
dbt compiles SQL models, resolves ref() and source() into a DAG, materializes tables
incrementally or as snapshots, runs tests that assert on data, and generates model documentation
and exposures.
Atlas plans migrations for existing objects, keeps a migration history, lints
changes before they run, detects drift between the warehouse and the
code, manages lookup-table rows with INSERT, UPSERT, or SYNC semantics, and applies the same
migration directory across environments.
Applied to the objects in one warehouse:
| Object | Owner |
|---|---|
Model tables and views (analytics.stg_events, analytics.daily_active_users) | dbt, recreated on every run |
| CSV seeds | dbt, loaded with dbt seed |
| Databases the models are built into | Atlas |
| Source and raw tables that ingestion loads and models read | Atlas |
| Lookup tables that are part of the source contract, and their rows | Atlas |
| Roles, users, and grants, including those dbt connects with | Atlas |
| Migration history, linting, and CI review for all of the above | Atlas |
Neither is a shortcoming of the other tool. They describe different objects.
The boundary between the two is a glob list in atlas.hcl. Atlas manages the analytics database
as an object and ignores the set of objects dbt materializes inside it. See
Where the Line Falls below for the exact config.
Is dbt a Migration Tool?
dbt's materializations are not migrations. They rebuild an object from its model definition. Running
dbt run twice on an unchanged project drops and recreates the same objects rather than computing a
change:
1 of 2 START sql view model `analytics`.`stg_events` ........................... [RUN]
1 of 2 OK created sql view model `analytics`.`stg_events` ...................... [OK in 0.11s]
2 of 2 START sql table model `analytics`.`daily_active_users` .................. [RUN]
2 of 2 OK created sql table model `analytics`.`daily_active_users` ............. [OK in 0.45s]
Finished running 1 table model, 1 view model in 0 hours 0 minutes and 4.63 seconds (4.63s).
That model is fine for something you can rebuild from a select. It does not work for a source
table that ingestion writes into, which holds data no model can regenerate. For those objects
someone has to plan a change to the existing structure, which is what a migration is:
ALTER TABLE `raw`.`events` ADD COLUMN `session_id` String;
Atlas generates that file from a one-column edit to the checked-in schema, and it goes through
atlas migrate lint and atlas migrate apply like any other migration. This is not a gap in dbt:
source tables are not dbt's objects to migrate.
What Atlas Adds
On top of planning that migration, Atlas covers three things a dbt project has no mechanism for:
-
Who reviewed this DDL? A change to a source table is typed into the warehouse by whoever has access. dbt has no plan step for it, no migration file, and nothing for a reviewer to approve. Atlas generates a migration file from the schema change, so it arrives as a diff in a pull request that someone approves and merges.
-
Has the warehouse drifted? dbt reads sources at run time and fails if something is missing, but it does not compare the warehouse against an intended state. A column that changed in type or a grant someone widened will not surface at all. Atlas diffs the live warehouse against the checked-in schema, and can block a deployment when they disagree.
-
Is this change safe to apply? Atlas lints generated migrations and fails on destructive changes before they reach a warehouse.
The Same Warehouse, Four Changes
Each of the following examples was run against one ClickHouse database holding both Atlas-managed source tables and dbt-managed models.
| The change | dbt | Atlas |
|---|---|---|
| A model's SQL changes | dbt run replaces the object | Ignored, as the change was inside the excluded database |
| A source table needs a new column | No mechanism, as the object is not dbt's | migrate diff writes ALTER TABLE raw.events ADD COLUMN session_id String |
| A source column that models read is dropped | Models fail at run time, after the fact | migrate lint fails first: Dropping non-virtual column "email" DS103 |
| dbt needs write access to build models | Documented as a prerequisite | GRANT ... ON analytics.* TO dbt_runner planned and applied as a migration |
The third row is the one worth dwelling on. Atlas's linter reports the destructive change before it reaches a warehouse, and exits non-zero so CI can stop it:
-- analyzing version 20260812082333
-- destructive changes detected:
-- L1: Dropping non-virtual column "email" https://atlasgo.io/lint/analyzers#DS103
-- suggested fix:
-> Add a pre-migration check to ensure column "email" is NULL before dropping it
Where the Line Falls
One exclude list in atlas.hcl decides where the boundary that prevents the two from interfering
with one another lies:
exclude = [
"analytics.*", // every object dbt builds
"atlas_schema_revisions", // Atlas migration history
"atlas_schema_revisions.*",
]
With this in place and after dbt run has materialized two models into analytics, run:
atlas schema diff --env local --from "$CLICKHOUSE_URL" --to "file://schema.ch.hcl"
Schemas are synced, no changes to be made.
Remove the single "analytics.*" line and the same command against the same warehouse plans this
instead:
-- Drop "stg_events" view
DROP VIEW `analytics`.`stg_events`;
-- Drop "daily_active_users" table
DROP TABLE `analytics`.`daily_active_users`;
That is the whole integration. Name dbt's database in exclude, and the two tools stop being able to
disagree. The full walkthrough builds this from an empty warehouse.
If Atlas were to manage all of your warehouse DDL, the two tools would be in conflict on every
dbt run. Instead, this guide ensures that model DDL belongs to dbt and Atlas leaves it alone.
Adding Atlas to an Existing dbt Project
There is nothing to remove or rewrite. Point Atlas at the warehouse you already have, with dbt's database excluded, and inspect it:
atlas schema inspect --env local > schema.ch.hcl
On the warehouse from the walkthrough, which contained both layers at the time, the output holds the
source tables, the roles, the user and grants, and the analytics database as an object with none
of dbt's models inside it:
schema "analytics" {
engine = Atomic
}
schema "raw" {
engine = Atomic
}
permission {
to = role.dbt_runner
for = schema.analytics
privileges = [ALTER, CREATE_TABLE, CREATE_VIEW, DROP_TABLE, DROP_VIEW, INSERT, OPTIMIZE, SELECT, TRUNCATE]
}
permission {
to = role.dbt_runner
for = schema.raw
privileges = [SELECT]
}
role "dbt_runner" {
}
user "dbt" {
member_of = [role.dbt_runner]
auth_type = "no_password"
}
That file is your starting point. Commit it, and from here atlas migrate diff plans changes to the
schema layer while dbt run keeps building models exactly as before.
For a warehouse whose schema layer predates Atlas, see importing an existing database to baseline the migration directory against what is already deployed.
Next Steps
Schema Management for dbt Projects
The full worked example: source tables, lookup data, grants, lint, and CI
ClickHouse Migrations
Declarative and versioned workflows for ClickHouse
ClickHouse and dbt
Sort order on source tables, grants for dbt, and dimension data as code
Safe DDL on Large Tables
Which ALTERs rewrite data, and how to block a destructive one
Warehouse Drift Detection
Detect a warehouse that stopped matching its schema while dbt rebuilds models
Atlas Configuration
Project files, envs and the exclude patterns used above
Have questions? Feedback? Find our team on our Discord server.