Schema Management for dbt Projects
dbt builds models. Every dbt run issues the CREATE, REPLACE, and
DROP statements for the tables and views its models materialize.
The schema layer underneath those models is a different matter. Databases, the source tables ingestion writes into, lookup tables other systems read, and the roles and grants dbt itself connects with are all outside any model's materialization. Nothing in a dbt project plans changes to them, reviews them, or tells you when the warehouse stopped matching what your code expects.
This guide manages that layer with Atlas against ClickHouse using versioned migrations. At the end,
dbt run and Atlas are both able to operate on the same warehouse without conflicts.
Prerequisites
- Docker
- Atlas installed:
- macOS + Linux
- Homebrew
- Docker
- Windows
- CI
- Manual Installation
To download and install the latest release of the Atlas CLI, simply run the following in your terminal:
curl -sSf https://atlasgo.sh | sh
Get the latest release with Homebrew:
brew install ariga/tap/atlas
To pull the Atlas image and run it as a Docker container:
docker pull arigaio/atlas
docker run --rm arigaio/atlas --help
If the container needs access to the host network or a local directory, use the --net=host flag and mount the desired
directory:
docker run --rm --net=host \
-v $(pwd)/migrations:/migrations \
arigaio/atlas migrate apply \
--url "mysql://root:pass@:3306/test"
Download the latest release and move the atlas binary to a file location on your system PATH.
GitHub Actions
Use the setup-atlas action to install Atlas in your GitHub Actions workflow:
- uses: ariga/setup-atlas@v0
with:
cloud-token: ${{ secrets.ATLAS_CLOUD_TOKEN }}
Other CI Platforms
For other CI/CD platforms, use the installation script. See the CI/CD integrations for more details.
- dbt with the ClickHouse adapter. The transcripts below come from
dbt-core1.11.12 withdbt-clickhouse1.10.1 - An Atlas Pro account
ClickHouse support, roles, users, and permissions are available to Pro users. Run atlas login to
authenticate.
Start a Warehouse
Run two containers with Docker. The first is the warehouse, while the second will serve as the dev database Atlas uses to compute diffs.
docker run -d --name warehouse \
-e CLICKHOUSE_PASSWORD=pass \
-e CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 \
-p 9000:9000 -p 8123:8123 \
--ulimit nofile=262144:262144 \
clickhouse/clickhouse-server:24.8-alpine
docker run -d --name warehouse-dev \
-e CLICKHOUSE_PASSWORD=pass \
-e CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 \
-p 9001:9000 \
--ulimit nofile=262144:262144 \
clickhouse/clickhouse-server:24.8-alpine
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 is required. Without it the default user cannot manage
access control, and the first migration fails on CREATE ROLE:
Code: 497. DB::Exception: default: Not enough privileges. To execute this query, it's necessary to have the grant CREATE ROLE ON *.*. (ACCESS_DENIED)
Export the two URLs:
export CLICKHOUSE_URL="clickhouse://default:pass@localhost:9000"
export CLICKHOUSE_DEV_URL="clickhouse://default:pass@localhost:9001"
Neither includes a database path. ClickHouse roles and users are server-level objects, so Atlas needs a server-scoped connection to manage them alongside the databases.
Instead of running the second container yourself, you can point dev at docker://clickhouse/23.11
in the Atlas configuration file and let Atlas start and clean up the dev
database per command. A long-lived container keeps the dev database on the same version as the warehouse.
Define the Schema Layer
To start managing your schema as code with Atlas, begin by defining the desired state.
With dbt, the desired state will hold the objects dbt depends on, but not those it creates. Note
that there are no model tables or views. The analytics schema is declared because dbt needs
somewhere to build, and its contents are left empty on purpose.
The grants are on schema.analytics because dbt's model tables cannot be enumerated ahead of time.
Schema-scope grants let Atlas manage dbt's access without knowing anything about dbt's models.
schema "default" {
}
schema "raw" {
}
schema "analytics" {
}
// Source tables: loaded by ingestion, read by dbt.
table "events" {
schema = schema.raw
engine = MergeTree
column "event_id" {
type = UUID
null = false
}
column "user_id" {
type = UInt64
null = false
}
column "event_type" {
type = String
null = false
}
column "occurred_at" {
type = DateTime
null = false
}
primary_key {
columns = [column.occurred_at, column.event_id]
}
}
table "customers" {
schema = schema.raw
engine = MergeTree
column "customer_id" {
type = UInt64
null = false
}
column "email" {
type = String
null = false
}
column "country_code" {
type = String
null = false
}
column "created_at" {
type = DateTime
null = false
}
primary_key {
columns = [column.customer_id]
}
}
// Lookup table: schema and rows both live in version control.
table "country_codes" {
schema = schema.raw
engine = MergeTree
column "code" {
type = String
null = false
}
column "name" {
type = String
null = false
}
primary_key {
columns = [column.code]
}
}
data {
table = table.country_codes
rows = [
{ code = "US", name = "United States" },
{ code = "CA", name = "Canada" },
{ code = "IL", name = "Israel" },
{ code = "VN", name = "Vietnam" },
]
}
// Access: dbt connects as a least-privilege user.
role "dbt_runner" {
}
role "bi_reader" {
}
user "dbt" {
member_of = [role.dbt_runner]
}
permission {
for = schema.raw
to = role.dbt_runner
privileges = [SELECT]
}
permission {
for = schema.analytics
to = role.dbt_runner
privileges = [SELECT, INSERT, CREATE_TABLE, CREATE_VIEW, DROP_TABLE, DROP_VIEW, ALTER, OPTIMIZE, TRUNCATE]
}
permission {
for = schema.analytics
to = role.bi_reader
privileges = [SELECT]
}
Configure the Boundary
Atlas's configuration file points to all the pieces needed to plan schema changes, including the URLs to our database and dev database, the path to our schema definition, linting behaviors, and so on.
Three details in this configuration file carry the dbt integration:
excludeuses the glob patterns documented for project files. Pattern format depends on the connection's scope. With the server-scoped URL,analyticsmatches the database itself andanalytics.*matches the objects inside it. Excluding onlyanalytics.*keeps the database under management while its contents are left to dbt.modeturns on role and permission management, which is off by default.data.includescopes lookup-data sync to the one table that has adatablock. Without it,mode = SYNCalso plansDELETE FROM raw.eventsfor rows that nodatablock describes.
env "local" {
url = getenv("CLICKHOUSE_URL")
dev = getenv("CLICKHOUSE_DEV_URL")
schema {
src = "file://schema.ch.hcl"
mode {
roles = true
permissions = true
}
}
// The boundary: dbt owns every object it materializes inside "analytics",
// so Atlas ignores them. The "analytics" database itself stays managed.
exclude = [
"analytics.*", // every object dbt builds
"atlas_schema_revisions", // Atlas migration history
"atlas_schema_revisions.*",
]
// Lookup table rows are part of the schema layer, not of any dbt model.
data {
mode = SYNC
include = ["raw.country_codes"]
max_rows = 1000
}
migration {
dir = "file://migrations"
}
lint {
destructive {
error = true
}
}
}