How to build Snowflake Cortex Agents using dbt and Cortex Code
Computers have been doing work for humans for decades. The fundamental loop has always been the same: a person has a …
For a long time, managing Snowflake objects as code meant picking between a few imperfect options. Terraform is great for account-level plumbing but awkward for tables and views. schemachange and Flyway are migration runners, so you write every ALTER yourself and hope the order is right. dbt owns transformations but not the containers they live in. Most teams end up with a mix of all three, plus a folder of “run this once in Snowsight” scripts.
DCM Projects (Database Change Management) is Snowflake’s native answer. You describe the end state in SQL using DEFINE statements, Snowflake works out the difference against what’s deployed, shows you a plan, and applies it. State lives inside Snowflake, not in a state file you have to protect. DCM Projects went generally available in August 2026.
In this article, we will walk through a working demo that uses DCM Projects the way a growing data org would: a thin platform layer that onboards teams, and self-service team layers that own everything inside their perimeter. We will bootstrap it, plan and deploy it across DEV / STG / PRD, wire it into GitHub Actions, and cover the gotchas I hit along the way.
All the code used in this article is available here: entechlog/snowflake-examples/snow-infra/dcm
A DCM project is a folder with a manifest and SQL definition files:
dcm/
├── manifest.yml # targets (envs) + templating variables
└── sources/
├── definitions/*.sql # DEFINE DATABASE / SCHEMA / TABLE / VIEW / ...
└── macros/*.sql # Jinja macros, auto-loaded
Definition files use DEFINE instead of CREATE. There is no ALTER and no ordering to worry about. You describe what should exist, and DCM resolves dependencies across files:
DEFINE TABLE {{ raw_db }}.SEED.CUSTOMER (
customer_id NUMBER,
customer_name VARCHAR,
email VARCHAR,
signup_date DATE,
region VARCHAR,
is_active BOOLEAN
)
CHANGE_TRACKING = TRUE
COMMENT = 'Customer reference data (seeded)';
The manifest maps targets to a DCM PROJECT object in Snowflake and a templating configuration, which is how the same definitions produce DEV_, STG_ and PRD_ objects:
targets:
DCM_SALES_DEV:
account_identifier: "{{ env.SNOWFLAKE_ACCOUNT }}"
project_name: DEV_SALES_DCM_DB.PROJECTS.INFRA
project_owner: "{{ env.SNOWFLAKE_ROLE }}"
templating_config: DEV
templating:
defaults:
team_name: "SALES"
data_retention_days: 1
configurations:
DEV:
env_code: "DEV"
PRD:
env_code: "PRD"
data_retention_days: 7
Two commands do the work, both from the Snowflake CLI (version 3.24.0 or later):
snow dcm plan uploads the files, renders the templates, and shows what would be created, altered or droppedsnow dcm deploy applies the plan and records the deployment, with an alias, in the project’s historyHere is a real plan from the demo after changing one schema comment. Nothing else is touched:
❯ Step 1/4 - UPLOAD - ✓ Completed (3s)
❯ Step 2/4 - RENDER - ✓ Completed (1s)
❯ Step 3/4 - COMPILE - ✓ Completed (0s)
❯ Step 4/4 - PLAN - ✓ Completed (12s)
ALTER SCHEMA DEV_SALES_RAW_DB.SEED
└─ changed COMMENT: Small reference tables seeded by dbt → Small reference
tables (seeded by post_scripts/01_…
Planned 1 entity (0 to create, 1 to alter, 0 to drop).
That is the whole idea: the first deploy creates everything, no change gives an empty plan, adding a column gives a single ALTER, and manual drift shows up in the next plan as something to reconcile.
The obvious way to use DCM is one big project that declares everything. That works for one team. It stops working when ten teams need to ship changes on their own schedule, and every change to anyone’s table goes through the same project, the same reviewers and the same deployer role with account-wide privileges.
The demo splits responsibility into two kinds of DCM project:
flowchart TB
subgraph PLATFORM["PLATFORM LAYER | deployer: SVC_PLATFORM_DCM_ROLE"]
direction TB
PP["DEV_PLATFORM_DCM_DB.PROJECTS.INFRA"]
PP --> ROLE["Per-team deployer role
SVC_<TEAM>_DCM_ROLE
CREATE DATABASE / WAREHOUSE / ROLE"]
PP --> TEAMWH["Per-team DCM warehouse
SVC_<TEAM>_DCM_WH_XS"]
PP --> DCMDB["Per-team DCM database
{ENV}_<TEAM>_DCM_DB"]
PP --> USER["Per-team service user
SVC_<TEAM>_DCM_USER
pre/post deploy scripts"]
end
subgraph TEAM["TEAM LAYER (one per team) | deployer: SVC_<TEAM>_DCM_ROLE"]
direction TB
TP["DEV_SALES_DCM_DB.PROJECTS.INFRA"]
TP --> DBS["RAW / PREP / DW databases"]
TP --> SCHEMAS["Schemas"]
TP --> OBJS["Tables, views,
dynamic tables"]
end
PLATFORM ==>|"hands off role, warehouse and DCM database
via OWNERSHIP transfer"| TEAM
CREATE DATABASE / CREATE WAREHOUSE / CREATE ROLE, a small warehouse for DCM operations, and an empty {ENV}_<TEAM>_DCM_DB where the team’s own DCM project will live. Ownership of the warehouse and database is transferred to the team role. It runs only when a team is onboarded or org-wide scaffolding changes.MANAGE GRANTS.The platform crosses each team’s perimeter exactly once. After onboarding, the platform layer never touches the team’s objects again, and the team never needs an account-admin-shaped role.
Names follow an env-first convention (DEV_SALES_RAW_DB, SVC_SALES_DCM_ROLE). I covered the reasoning in Snowflake object naming conventions, and the demo’s macros build every name from it.
The SALES team project builds a small but complete chain:
RAW_DB PREP_DB DW_DB DCM_DB
└── SEED ───► ├── DIM ───► ├── DIM └── PROJECTS
└── FACT ├── FACT └── INFRA ◄── DCM PROJECT
└── OBT ◄── dynamic table
CHANGE_TRACKING = TRUE, so downstream dynamic tables can refresh incrementallySTG_CUSTOMER, STG_SALES_ORDER) over RAWDEFINE DYNAMIC TABLE {{ dw_db }}.OBT.CUSTOMER_SALES_ORDER
WAREHOUSE = SVC_SALES_DCM_WH_XS
TARGET_LAG = 'DOWNSTREAM'
INITIALIZE = ON_SCHEDULE
AS
SELECT c.customer_id, c.customer_name, c.region,
o.sales_order_id, o.order_date, o.order_total_usd, o.status
FROM {{ prep_db }}.DIM.STG_CUSTOMER c
LEFT JOIN {{ prep_db }}.FACT.STG_SALES_ORDER o
ON c.customer_id = o.customer_id;
INITIALIZE = ON_SCHEDULE skips the synchronous first refresh during deploy, which keeps plan and deploy cycles fast. The seed script triggers the refresh afterwards.
DIM.CUSTOMER and FACT.SALES_ORDER tables are there to show plain and clustered tables under DCM. Nothing loads them, since that is the job of your transformation tool. The dynamic table reads straight from PREP so the demo has a working end-to-end chain without dbt.ACCOUNTADMIN for the one-time bootstrapsnow-tools Docker container with it installed:cd snowflake-examples/snow-tools
cp .env.template .env # set SNOWFLAKE_HOME, SNOWFLAKE_ACCOUNT, SNOWFLAKE_ROLE
docker-compose up -d --build
docker exec -it snow-tools bash
All the commands below run inside that container, from snow-infra/dcm.
This is the only part that needs ACCOUNTADMIN. Run platform/bootstrap/01_create_platform_service_role.sql in Snowsight. It creates:
SVC_PLATFORM_DCM_ROLE with CREATE DATABASE / WAREHOUSE / ROLE / USER WITH GRANT OPTION and MANAGE GRANTSSVC_PLATFORM_DCM_USER (TYPE = SERVICE, key-pair only) and SVC_PLATFORM_DCM_WH_XSThe WITH GRANT OPTION matters. The platform project re-grants CREATE DATABASE and friends to each team role, and for account-level privileges MANAGE GRANTS alone isn’t enough. Without the grant option, the platform deploy fails with Grant not executed: Insufficient privileges.
Generate the platform key pair and register the public key (this one paste into Snowsight is unavoidable, since the CLI can’t connect yet):
./platform/bootstrap/00_generate_platform_rsa_key.sh
Copy config.toml.template into your SNOWFLAKE_HOME and set your ORG-ACCOUNT. It defines six connections, dcm-platform-{dev,stg,prd} and dcm-sales-{dev,stg,prd}. Then test:
snow connection test --connection dcm-platform-dev
A DCM project can’t declare the DCM PROJECT object it lives in, so that one is created imperatively for each env:
snow sql --connection dcm-platform-dev -f platform/bootstrap/02_create_platform_project_objects.sql
./platform/scripts/01_plan.sh DEV
./platform/scripts/02_deploy.sh DEV "initial-platform"
The platform definition loops over the teams: list in the manifest:
{% for team in teams %}
{% if deploy_account_user_roles %}
DEFINE ROLE {{ team_dcm_role(team.name) }}
COMMENT = '{{ team.name }} DCM deployer (creates and owns team objects)';
GRANT CREATE DATABASE ON ACCOUNT TO ROLE {{ team_dcm_role(team.name) }};
GRANT CREATE WAREHOUSE ON ACCOUNT TO ROLE {{ team_dcm_role(team.name) }};
GRANT CREATE ROLE ON ACCOUNT TO ROLE {{ team_dcm_role(team.name) }};
-- ... team DCM warehouse, ownership transferred to the team role
{% endif %}
DEFINE DATABASE {{ db_name(team.name, 'DCM') }}
DATA_RETENTION_TIME_IN_DAYS = {{ data_retention_days }};
GRANT OWNERSHIP ON DATABASE {{ db_name(team.name, 'DCM') }}
TO ROLE {{ team_dcm_role(team.name) }};
{% endfor %}
Two details are worth calling out:
deploy_account_user_roles, which is true only in the DEV configuration. STG and PRD only create their {ENV}_SALES_DCM_DB. Deploy platform DEV first.USER objects, so 02_deploy.sh wraps the deploy: a pre-step runs CREATE USER IF NOT EXISTS SVC_SALES_DCM_USER TYPE = SERVICE, and a post-step grants the new team role to that user. Plan stays strictly read-only.Then deploy STG and PRD the same way (02_deploy.sh STG …, 02_deploy.sh PRD …).
Generate the team key and register it through the platform connection, so there is no Snowsight paste this time. The platform role created the user, so it owns it and can set its key:
./teams/sales/bootstrap/00_generate_rsa_key.sh --register-via dcm-platform-dev
snow connection test --connection dcm-sales-dev
Create the team’s DCM project inside the database the platform handed over (CI does this automatically, more on that below):
snow sql --connection dcm-sales-dev -q "
USE ROLE SVC_SALES_DCM_ROLE;
USE WAREHOUSE SVC_SALES_DCM_WH_XS;
CREATE SCHEMA IF NOT EXISTS DEV_SALES_DCM_DB.PROJECTS;
CREATE DCM PROJECT IF NOT EXISTS DEV_SALES_DCM_DB.PROJECTS.INFRA;
"
export SNOWFLAKE_ROLE=SVC_SALES_DCM_ROLE
./teams/sales/scripts/01_plan.sh DCM_SALES_DEV
./teams/sales/scripts/02_deploy.sh DCM_SALES_DEV "initial-team"
On a fresh env this creates 20 entities: 3 databases, 9 schemas (the 6 declared ones plus the 3 auto-created PUBLIC schemas, which DCM tracks), 4 tables, 2 views and 1 dynamic table.
--save-output on plan writes out/plan_result.json and the rendered SQL under out/rendered/, which is handy for reviewing exactly what Jinja produced:
jq -c '.changeset[] | {type, fqn: .object_id.fqn}' out/plan_result.json
DCM manages structure, not data. The seed script truncates and loads the RAW tables, then refreshes every dynamic table the project manages:
snow sql --connection dcm-sales-dev -f teams/sales/dcm/post_scripts/01_seed_data.sql
EXECUTE DCM PROJECT IDENTIFIER($PROJECT_FQN) REFRESH ALL;
SELECT * FROM DEV_SALES_DW_DB.OBT.CUSTOMER_SALES_ORDER;
Rows in the OBT means RAW → PREP → DW works end to end. Every deploy is recorded against the project:
snow dcm list-deployments --target DCM_SALES_DEV --connection dcm-sales-dev
| created_on | name | alias |
|---------------------------+--------------+---------------------------|
| 2026-10-31 17:54:16 | DEPLOYMENT$4 | ci-deploy-817d70c9747... |
| 2026-10-31 17:22:44 | DEPLOYMENT$3 | ci-deploy-cd4e1d5e64f... |
| 2026-06-17 21:36:39 | DEPLOYMENT$2 | ci-deploy-c0a45cef721... |
| 2026-06-17 14:56:54 | DEPLOYMENT$1 | initial-team |
DCM auto-loads macros from each project’s sources/macros/ and doesn’t allow {% import %} or {% include %}. With a platform project and N team projects, that would mean N copies of the naming macros drifting apart.
The demo keeps one canonical file in shared/macros/env_helpers.sql and syncs it into every project before each plan or deploy. The per-project copies are gitignored:
{% macro db_name(team, layer) -%}
{{ env_code | upper }}_{{ team | upper }}_{{ layer | upper }}_DB
{%- endmacro %}
{% macro team_dcm_role(team) -%}
SVC_{{ team | upper }}_DCM_ROLE
{%- endmacro %}
The wrapper scripts and CI call scripts/sync_macros.sh automatically, and it discovers teams/*/dcm on its own, so a new team needs no change to the script.
The workflow (.github/workflows/dcm-deploy.yml) drives the team layer:
| Trigger | Action | Target |
|---|---|---|
Pull request touching snow-infra/dcm/** |
plan | DEV |
Push to develop |
plan + deploy | DEV |
Push to main |
plan + deploy | STG, then PRD (manual approval) |
Manual (workflow_dispatch) |
plan or deploy | any env, from any branch |
Snowflake publishes official DCM actions, but they authenticate with OIDC through workload identity federation. The demo uses key-pair auth instead, through two small composite actions:
snow-cli-plan-deploy installs the CLI, writes a connection from GitHub secrets, and runs plan or deployensure-team-dcm-project runs CREATE DCM PROJECT IF NOT EXISTS before every deploy, so a new env or team needs no manual Step 5snow dcm deploy has no flag to block destructive changes, so the deploy action adds a drop guard. It re-plans first and refuses to deploy if anything would be dropped:
snow dcm plan --target "$TARGET" --connection ci --save-output
DROPS="$(jq '[.changeset[] | select(.type == "DROP")] | length' out/plan_result.json)"
if [[ "$DROPS" -gt 0 ]]; then
echo "Plan contains ${DROPS} DROP change(s); refusing to deploy" >&2
exit 1
fi
snow dcm deploy --target "$TARGET" --alias "$ALIAS" --connection ci
Deleting a definition, or renaming an object in one (DCM keys objects by name, so the old name is planned as a drop), is all it takes to remove a production table. This guard turns that into a failed job instead. Set the action’s allow_drops: "true" input when a drop is intended.
Each GitHub environment (snowflake-dev, snowflake-stg, snowflake-prd) holds three secrets: SNOWFLAKE_ACCOUNT, SNOWFLAKE_USER and SNOWFLAKE_PRIVATE_KEY. PRD gets a required reviewer.
Adding MARKETING takes four steps, and none of them touches SALES:
- name: MARKETING to teams: in the platform manifest, and deploy platform DEV, then STG and PRDSF_USER=SVC_MARKETING_DCM_USER KEY_NAME=svc_marketing_dcm \
./teams/sales/bootstrap/00_generate_rsa_key.sh --register-via dcm-platform-dev
teams/sales/ to teams/marketing/, change team_name and the target names, and add MARKETING connectionsEach team’s project and deployment history lives in its own database, so neither team can see or change the other’s plan.
FUTURE grants need MANAGE GRANTS, even on databases you own. The team role owns its objects, so plain grants work. Future grants are the exception. If you add access roles to a team project, either create schemas WITH MANAGED ACCESS (the schema owner can then define future grants inside it), have the platform layer issue the future grants, or look at the newer INHERITED grants. Inside DCM definitions, ON FUTURE and ON ALL grants are slated for deprecation in favor of INHERITED grants, which were in preview at the time of writing.
Account-level objects belong to exactly one target. A role or warehouse declared in DEV, STG and PRD would be fought over by three projects. Declare it in one, here DEV, and only reference it from the others. The flip side shows up at cleanup: purge the platform DEV project last, because it owns the team role and warehouse that STG and PRD depend on.
Post scripts aren’t run for you. Neither the CLI deploy nor the CI workflow runs post_scripts/. Seed data and dynamic table refreshes are explicit steps.
DCM tracks the PUBLIC schema that comes with every new database. Don’t be surprised to see it in plans and entity counts.
The account and role mismatch warning is cosmetic. With {{ env.SNOWFLAKE_ACCOUNT }} in the manifest, the CLI compares the raw template string with the session and prints a warning. The connection itself uses the interpolated value.
CLI changes since preview. If you built against an early preview, three things changed: the CLI needs 3.24.0+, saved plans moved to out/plan_result.json (plus out/rendered/), and list-deployments now takes --target. snow dcm create and snow dcm purge are new. The demo’s CI pins snowflake-cli>=3.24.0.
PURGE drops every object a project manages and keeps the project and its history. Run the team projects first and the platform last:
snow sql -f teams/sales/scripts/04_purge.sql --connection dcm-sales-dev
snow sql -f platform/scripts/04_purge.sql --connection dcm-platform-dev
The repo also has 98_cleanup_for_fresh_test.sql to reset to just after bootstrap, and 99_teardown.sql to remove everything, including the platform deployer.
The demo stops at structure on purpose. Natural next steps:
DEV_SALES_DW_RO_AR, SALES_ANALYST_FR) and handle future grants with one of the options abovesnow dcm test leaves previewIf you’re already using Terraform, schemachange or something home-grown for Snowflake objects, I’d love to hear how DCM compares in your setup. Leave a comment below.
This blog represents my own viewpoints and not those of my employer. All product names, logos, and brands are the property of their respective owners.
Computers have been doing work for humans for decades. The fundamental loop has always been the same: a person has a …
As data platforms grow, one of the most expensive patterns is duplicating raw data across multiple storage engines. If …