preloader
blog-post

How to manage Snowflake infrastructure as code with DCM Projects

Table of Contents

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

DCM Projects in two minutes

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 dropped
  • snow dcm deploy applies the plan and records the deployment, with an alias, in the project’s history

Here 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 pattern: thin platform, self-service teams

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
  • Platform project (one per env, run by the platform team): for each team in a list, it creates a deployer role with 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.
  • Team project (one per team per env, run by the team): declares the team’s databases, schemas, tables, views and dynamic tables. Because the team role created those objects, it owns them, and an owner can grant privileges on what it owns without 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.

What the team layer declares

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
  • RAW: two seed tables with CHANGE_TRACKING = TRUE, so downstream dynamic tables can refresh incrementally
  • PREP: cleaned views (STG_CUSTOMER, STG_SALES_ORDER) over RAW
  • DW: a dimension table, a fact table clustered by date, and a “one big table” dynamic table that joins the PREP views
DEFINE 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.

The DW 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.

Prerequisites

  • A Snowflake account with ACCOUNTADMIN for the one-time bootstrap
  • Snowflake CLI 3.24.0+. The demo ships a snow-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.

Walkthrough

Step 1: Bootstrap the platform deployer

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 GRANTS
  • SVC_PLATFORM_DCM_USER (TYPE = SERVICE, key-pair only) and SVC_PLATFORM_DCM_WH_XS

The 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.

Step 2: Keys and connections

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

Step 3: Create the platform DCM projects

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

Step 4: Plan and deploy the platform layer

./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:

  • Account-level objects are declared once. Roles and warehouses aren’t per-env, so they are guarded by 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.
  • Users aren’t a DCM entity. DCM can’t declare 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 …).

Step 5: Hand off to the team

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;
"

Step 6: Plan and deploy the team layer

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

Step 7: Seed and verify

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              |

Sharing macros across projects

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.

CI/CD with GitHub Actions

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 deploy
  • ensure-team-dcm-project runs CREATE DCM PROJECT IF NOT EXISTS before every deploy, so a new env or team needs no manual Step 5

snow 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.

Onboarding another team

Adding MARKETING takes four steps, and none of them touches SALES:

  1. Add - name: MARKETING to teams: in the platform manifest, and deploy platform DEV, then STG and PRD
  2. Generate and register the key:
    SF_USER=SVC_MARKETING_DCM_USER KEY_NAME=svc_marketing_dcm \
      ./teams/sales/bootstrap/00_generate_rsa_key.sh --register-via dcm-platform-dev
    
  3. Copy teams/sales/ to teams/marketing/, change team_name and the target names, and add MARKETING connections
  4. Plan and deploy the MARKETING project

Each team’s project and deployment history lives in its own database, so neither team can see or change the other’s plan.

Gotchas and lessons learned

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.

Cleaning up

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.

What’s next

The demo stops at structure on purpose. Natural next steps:

  • Declare access roles and functional roles in the team project (DEV_SALES_DW_RO_AR, SALES_ANALYST_FR) and handle future grants with one of the options above
  • Post the plan as a pull request comment so reviewers see the diff without opening the job log
  • Add data quality expectations to definitions once snow dcm test leaves preview
  • Wire the platform layer into CI too, with its own approval gate

If 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.

References

Share this blog:
Comments

Related Articles