Category: dbt

Transform your data with dbt (data build tool). Learn best practices for analytics engineering, SQL-based data modeling, testing, and documenting your modern data stack.

  • How to Query Snowflake in DuckDB (And Cut Your Bill While Doing It)

    How to Query Snowflake in DuckDB (And Cut Your Bill While Doing It)

    TL;DR

    • Snowflake’s 60-second minimum billing means a 4-second query gets charged for a full minute — you’re paying for 55 seconds of nothing
    • You can query Snowflake data in DuckDB via two routes: Iceberg tables on S3 (no warehouse needed) or ADBC using Apache Arrow (up to 38x faster than ODBC)
    • Once data is local in DuckDB, every subsequent query is free — no cloud credits consumed
    • A hybrid triage approach (short queries → DuckDB/MotherDuck, heavy ETL → Snowflake) cuts BI compute costs by 70–90% in practice
    • Dev and CI/CD workloads moved to local DuckDB eliminate an entire category of cloud spend entirely

    I’ve been building on Snowflake long enough to know the ritual. Warehouse wakes up. Query runs in three seconds. Warehouse idles. You get billed for sixty seconds anyway. Multiply that by every analyst, every BI dashboard refresh, every dbt run in your dev environment — and suddenly you’re staring at a bill that feels completely disconnected from the actual work that happened.

    For a long time I assumed this was just the price of doing business on a best-in-class cloud warehouse. What I didn’t realise — until I started taking DuckDB seriously — is that a meaningful chunk of that bill doesn’t have to exist at all.

    This article covers three concrete methods to get Snowflake data into DuckDB, the cost math behind why you’d want to, and how to decide what actually belongs on which engine.


    THE REAL PROBLEM: YOU’RE PAYING FOR COMPUTE YOU DIDN’T USE

    Snowflake bills compute per second — but only after a 60-second minimum each time a warehouse resumes from suspension. A query that takes five seconds gets billed for a full minute. You paid for 55 seconds of nothing.

    It gets worse at scale. When a BI dashboard fires 20 queries on load, each taking three seconds, that single page view triggers 1,200 seconds of billed compute time. The actual work? One minute.

    And then warehouse sizing compounds it further. Each size increase in Snowflake doubles credit consumption. Teams defaulting to Medium or Large for everything are paying a 4x to 8x cost premium for workloads that could run perfectly well on X-Small.

    I’ve seen this exact pattern on almost every Snowflake environment I’ve worked in. Oversized warehouse, auto-suspend set to ten minutes, no resource monitors, nobody looking at query history.


    QUICK WINS INSIDE SNOWFLAKE FIRST

    Before touching the architecture, fix the obvious things. These alone can cut spend by 20–40%.

    Set AUTO_SUSPEND to exactly 60 seconds. Not lower — setting it below 60 is counterproductive because a query arriving in that first minute triggers another 60-second minimum. Not higher — every idle second past 60 is wasted money.

    Default to X-Small warehouses. Only scale up when a specific workload has a documented SLA that requires it.

    Add resource monitors:

    CREATE OR REPLACE RESOURCE MONITOR monthly_etl_monitor
    WITH CREDIT_QUOTA = 5000
    TRIGGERS ON 75 PERCENT DO NOTIFY
            ON 100 PERCENT DO SUSPEND;
    
    ALTER WAREHOUSE etl_heavy_wh 
    SET RESOURCE_MONITOR = monthly_etl_monitor;

    METHOD 1 — QUERYING SNOWFLAKE ICEBERG TABLES DIRECTLY IN DUCKDB

    If your organisation has moved to Iceberg tables with underlying data stored in S3, you can read those tables directly in DuckDB — no Snowflake warehouse running, no credits consumed.

    Install the extensions:

    INSTALL httpfs;
    LOAD httpfs;
    INSTALL iceberg;
    LOAD iceberg;

    Configure AWS credentials:

    CREATE SECRET (
        TYPE S3,
        PROVIDER CREDENTIAL_CHAIN
    );

    Find the current metadata file for your Snowflake-managed Iceberg table:

    SELECT PARSE_JSON(
      SYSTEM$GET_ICEBERG_TABLE_INFORMATION('YOUR_DB.YOUR_SCHEMA.YOUR_TABLE')
    )['metadataLocation']::varchar;

    Query it in DuckDB:

    SELECT
        customer_id,
        COUNT(*)
    FROM iceberg_scan('s3://your-bucket/path/to/metadata/00001-xxxx.metadata.json')
    GROUP BY 1;

    Materialise once for fast repeated queries:

    CREATE TABLE payments AS 
    SELECT * FROM iceberg_scan('s3://your-bucket/.../metadata.json');

    After this: same aggregation runs in 1.5s instead of 54s.

    Real benchmark: a SELECT * on a 110-million row table finished in 29 seconds in DuckDB on an M1 MacBook. Same query on an X-Small Snowflake warehouse took 72 seconds.

    The honest limitation: DuckDB’s Iceberg support is still maturing. You need direct S3 access and have to point DuckDB at a specific metadata file rather than a catalog. This will improve over time, but it works today.


    METHOD 2 — QUERYING NATIVE SNOWFLAKE TABLES VIA ADBC

    Not on Iceberg yet? ADBC (Arrow Database Connectivity) is the right tool here.

    Apache Arrow is a columnar memory format. When you connect Snowflake to DuckDB via ADBC, data stays columnar the entire way. Traditional ODBC forces Snowflake to convert columnar → row for transfer, then DuckDB converts row → columnar for processing. DuckDB’s benchmarks show ADBC is up to 38x faster than ODBC.

    Install:

    pip install adbc_driver_snowflake pyarrow duckdb cryptography

    Connect to Snowflake and pull data as an Arrow table:

    import adbc_driver_snowflake.dbapi
    import duckdb
    import os
    from read_private_key import read_private_key
    
    SNOWFLAKE_CONFIG = {
        'adbc.snowflake.sql.account': os.getenv('SNOWFLAKE_ACCOUNT'),
        'adbc.snowflake.sql.warehouse': os.getenv('SNOWFLAKE_WAREHOUSE'),
        'adbc.snowflake.sql.role': os.getenv('SNOWFLAKE_ROLE'),
        'adbc.snowflake.sql.database': os.getenv('SNOWFLAKE_DATABASE'),
        'username': os.getenv('SNOWFLAKE_USER'),
        'adbc.snowflake.sql.client_option.jwt_private_key_pkcs8_value': pem_key,
        'adbc.snowflake.sql.auth_type': 'auth_jwt'
    }
    
    snowflake_conn = adbc_driver_snowflake.dbapi.connect(
        db_kwargs={**SNOWFLAKE_CONFIG}
    )
    
    snowflake_cursor = snowflake_conn.cursor()
    snowflake_cursor.execute("SELECT * FROM SANDBOX_DB.MY_SCHEMA.RAW_ORDERS")
    
    # Fetch as Arrow table — stays columnar, no serialisation overhead
    arrow_table = snowflake_cursor.fetch_arrow_table()
    
    # Persist locally in DuckDB
    duckdb_conn = duckdb.connect('demo.db')
    duckdb_conn.execute("""
        CREATE TABLE IF NOT EXISTS raw_orders AS 
        SELECT * FROM arrow_table
    """)

    One heads-up: figuring out the connection parameters using a private key is not straightforward — the docs aren’t great on this point. The private key needs to be re-encoded into PEM format before passing it to the ADBC driver:

    from cryptography.hazmat.primitives import serialization
    
    def read_private_key(private_key_path: str, private_key_passphrase: str = None) -> str:
        with open(private_key_path, 'rb') as key_file:
            private_key = serialization.load_pem_private_key(
                key_file.read(),
                password=private_key_passphrase.encode() if private_key_passphrase else None
            )
            pem_key = private_key.private_bytes(
                encoding=serialization.Encoding.PEM,
                format=serialization.PrivateFormat.PKCS8,
                encryption_algorithm=serialization.NoEncryption()
            )
            return pem_key.decode('utf-8')

    Once that’s sorted, the workflow is clean: pull data from Snowflake via ADBC once, materialise it locally in DuckDB, query it as many times as you want — zero Snowflake credits consumed after the initial pull.


    METHOD 3 — THE HYBRID ARCHITECTURE: ROUTE WORKLOADS BY TYPE

    The two methods above are great for development and ad-hoc analysis. For production BI workloads, the cleanest solution I’ve seen is a hybrid architecture where you triage queries by workload type.


    The insight that unlocked this for me was using Snowflake’s query_history to actually categorise what’s running:

    WITH query_stats AS (
        SELECT
            warehouse_name,
            user_name,
            query_id,
            execution_time / 1000 AS execution_seconds
        FROM snowflake.account_usage.query_history
        WHERE
            start_time >= DATEADD('day', -30, CURRENT_TIMESTAMP())
            AND warehouse_name IS NOT NULL
            AND execution_status = 'SUCCESS'
    )
    SELECT
        warehouse_name,
        user_name,
        COUNT(query_id) AS query_count,
        MEDIAN(execution_seconds) AS median_execution_seconds,
        CASE
            WHEN query_count > 1000 AND median_execution_seconds < 30 
                THEN 'Interactive BI / High Frequency'
            WHEN query_count <= 1000 AND median_execution_seconds < 60 
                THEN 'Ad-Hoc Exploration'
            WHEN median_execution_seconds >= 300 
                THEN 'Batch ETL / Heavy Analytics'
            ELSE 'General Purpose'
        END AS workload_category
    FROM query_stats
    GROUP BY warehouse_name, user_name
    ORDER BY query_count DESC;

    Use MEDIAN not AVG — outlier queries skew the average and give a misleading picture of typical duration.

    Routing logic:

    • Short and bursty BI (sub-30s, high frequency) → move to usage-based engine. Real math: $528/month on Snowflake X-Small running continuously vs $5.87/month on per-second billing for the same workload.
    • Dev and CI/CD → local DuckDB, zero cloud credits
    • Heavy batch ETL, multi-TB → keep on Snowflake, 60s minimum is irrelevant for hour-long jobs

    This is the same principle I apply when thinking about orchestration — use the right tool for the job, not the most powerful tool for everything. I wrote about a similar decision process in why I stopped using Snowflake Tasks for orchestration — the short version is that mature orchestration tools give you far more control over exactly this kind of workload routing.


    WHEN TO STAY ON SNOWFLAKE

    Multi-terabyte batch processing — predictable provisioned compute matters more than idle cost savings when a job runs for hours.

    Enterprise governance — complex data masking, RBAC at scale, data residency requirements. Snowflake’s security surface is mature. DuckDB isn’t designed for this.

    Already-efficient workloads — if a warehouse runs at high utilisation for 8 hours straight, there’s no idle tax to eliminate. Don’t fix what isn’t broken.


    WHAT REAL COST SAVINGS LOOK LIKE

    • One SaaS company: 70%+ reduction in warehousing costs after moving to DuckDB-based solution
    • Okta: $60,000/month Snowflake spend for threat detection reduced substantially using parallel DuckDB instances
    • A data engineering team: 79% immediate reduction in Snowflake BI spend using DuckDB as a caching layer, 7x faster query times

    None of these required abandoning Snowflake. They required deciding which workloads actually needed it.


    FREQUENTLY ASKED QUESTIONS

    Can you query Snowflake data in DuckDB without a Snowflake warehouse running?
    Yes — two ways. Iceberg tables via the iceberg extension (no warehouse), or native tables via ADBC. Both require a brief initial connection, but once data is materialised locally, all subsequent queries are free.

    What is ADBC and why is it faster than ODBC?
    ADBC keeps data in columnar format throughout. ODBC forces columnar → row → columnar conversion. DuckDB benchmarks show ADBC up to 38x faster for transfers.

    How much can I realistically save?
    For short, high-frequency dashboard queries: 70–90% is consistent across documented cases. The 60-second minimum means a 4-second query costs 15x what it should.

    Is DuckDB production-ready?
    For single-node analytical workloads under a few terabytes: yes. Multi-user concurrency at scale and enterprise governance: not yet.

    Do I need Iceberg?
    No. ADBC works with native Snowflake tables. The main friction is private key encoding, which the docs don’t explain well.

    Will this work with dbt?
    Yes. dbt-duckdb lets you run your full dbt project locally against DuckDB. Pull source data once from Snowflake, develop and test for free, deploy to Snowflake in production only.This eliminates cloud compute costs for the entire development loop. I’ve written about dbt native projects and pipeline patterns if you want more context on how this fits into a Snowflake-first stack.


    Related blogs :

    → DuckDB official docs and installation
    → DuckDB ADBC benchmarks
    → Apache Arrow project
    → Snowflake query_history view docs
    → dbt-duckdb adapter on GitHub
    → Greybeam ADBC connection code on GitHub

  • The Problem with dbt Tests Nobody Talks About — They Pass and You Still Ship Bad Data

    The Problem with dbt Tests Nobody Talks About — They Pass and You Still Ship Bad Data

    I’ve been running dbt in production for a while now. And I’ll be honest — there was a phase where I genuinely believed that if my dbt tests were green, I was good. Green means clean, right?

    Wrong.

    This is the quiet failure mode that nobody in the dbt community writes about loudly enough. Your tests pass. Your CI/CD pipeline goes green. Your DAG runs without errors. And somewhere downstream, an analyst is staring at a revenue number that’s off by 30% and has no idea why.


    TL;DR: dbt’s built-in tests (not_null, unique, accepted_values, relationships) validate data structure, not data correctness. Your pipeline goes green and you still ship wrong numbers. This post breaks down exactly why that happens, what the real gaps are, and what custom tests, volume monitoring, and source-layer checks actually fix


    Let me walk you through exactly how this happens — because I’ve lived it.


    What Are dbt Tests Actually Checking?

    Before we get to the failure modes, let’s be precise about what dbt’s generic tests actually do — because I think the confusion starts here.

    dbt gives you four built-in generic tests out of the box:

    • not_null — checks that a column has no null values
    • unique — checks that all values in a column are distinct
    • accepted_values — checks that a column only contains values from a predefined list
    • relationships — checks referential integrity between two models

    These are constraint tests. They validate the shape of your data — grain, nullability, referential integrity. They do not validate whether the values are correct, whether the volume is expected, or whether the business logic in your SQL is actually right.

    That distinction is everything.

    models:
      - name: fct_daily_revenue
        columns:
          - name: transaction_id
            tests:
              - not_null
              - unique
          - name: revenue_amount
            tests:
              - not_null

    This test suite passes even if every revenue_amount is 100x too large. It passes if your join silently drops 40% of records because a key format changed upstream. It passes if a currency unit changed after a vendor migration and nobody touched the schema.

    None of that is a bug in dbt. It’s working exactly as designed. The problem is the mental model we build around it.


    The Scenario That Broke Me

    We had a pipeline pulling sales transaction data from an API. The dbt model joined it against a product dimension, aggregated daily revenue, and pushed it to a reporting layer. All four generic tests — passing. Every single day.

    What was actually happening: the upstream API started returning amounts in a different currency unit after a vendor migration. No schema change. No new nulls. No duplicate keys. Just the values silently shifting by a factor of 100.

    Our not_null test on revenue_amount? Passed. Our unique test on transaction_id? Passed. Our downstream revenue dashboard was off by two orders of magnitude for three weeks before an analyst caught it during a QBR.

    Three weeks. All green tests. All wrong data.

    That’s when I stopped treating dbt tests as a data quality guarantee and started treating them as what they actually are: a contract enforcement layer.


    The Three Gaps Nobody Talks About

    1. Volume Drift — Records Disappear and Nothing Breaks

    If your fct_orders model typically produces 50,000 rows a day and one morning it produces 12,000 — no generic test will catch that. The data that is there is perfectly valid. You just lost 38,000 records somewhere in your pipeline and dbt has no idea.

    This is one of the most common real-world pipeline failures I see, and it’s completely invisible to constraint-based tests.

    The fix is a custom singular test or a dbt_utils recency/row-count assertion:

    -- tests/assert_row_count_within_threshold.sql
    {% set threshold = 0.2 %}
    select 1
    from (
      select count(*) as today_count
      from {{ ref('fct_orders') }}
      where order_date = current_date
    ) today
    cross join (
      select avg(daily_count) as avg_count
      from (
        select order_date, count(*) as daily_count
        from {{ ref('fct_orders') }}
        where order_date between current_date - 14 and current_date - 1
        group by order_date
      ) history
    ) baseline
    where abs(today_count - avg_count) / nullif(avg_count, 0) > {{ threshold }}

    This returns a row — which dbt interprets as a test failure — when today’s row count deviates more than 20% from the 14-day average. Simple, practical, catches real failures. I also wrote about a similar pattern in how dbt integrates natively with Apache Airflow for pipeline orchestration — the combination of orchestration visibility and volume tests gives you a much more honest picture of pipeline health than either alone.

    2. Business Logic Correctness — The Math Can Still Be Wrong

    dbt tests validate columns in isolation. They don’t validate relationships between columns, or whether the calculations in your model are actually right.

    Take something simple:

    select
      order_id,
      unit_price,
      quantity,
      unit_price * quantity as line_total
    from {{ source('orders', 'order_lines') }}

    You can have not_null on all three columns, accepted_values on quantity to ensure it’s positive — and still ship models where line_total is wrong because unit_price was populated in cents from one source and dollars from another. No generic test catches that unless you explicitly write:

    -- tests/assert_line_total_matches_components.sql
    select *
    from {{ ref('fct_order_lines') }}
    where abs(line_total - (unit_price * quantity)) > 0.01

    Writing that test requires you to already know the business rule. Which means data quality at this layer requires domain knowledge, not just dbt knowledge. If you’re using Snowflake, pairing this with Cortex-based automated data quality checks can flag anomalies in derived metrics that pure SQL assertion tests would miss — something I covered in depth when building Snowflake Cortex accelerators for automated data quality.

    3. Silent Join Fan-Out and Record Loss

    This one has bitten me more than once. A many-to-one join accidentally becomes many-to-many because a dimension table you assumed was unique… wasn’t. Or a left join silently drops records because a key format changed from integer to string somewhere upstream.

    The result: your fact table either fans out (double-counting revenue) or silently loses records, and every generic test still passes because the columns that remain are perfectly valid.

    The safeguard is writing uniqueness tests on your dimension tables and asserting that your fact-to-dimension join doesn’t increase row count:

    -- tests/assert_no_join_fanout.sql
    with before_join as (
      select count(*) as row_count from {{ ref('fct_orders') }}
    ),
    after_join as (
      select count(*) as row_count
      from {{ ref('fct_orders') }} o
      left join {{ ref('dim_customers') }} c on o.customer_id = c.customer_id
    )
    select 1
    from before_join b
    cross join after_join a
    where a.row_count > b.row_count

    What Actually Helps

    Write custom singular tests for critical models. Don’t rely only on generic column-level tests for anything that feeds a financial or executive dashboard. If the number matters, test the business rule explicitly.

    Add volume and freshness monitoring at source. Whether you use dbt_utils.recency, Elementary, or a hand-rolled SQL assertion — track volume. It’s the cheapest signal you have that something went wrong upstream.

    sources:
      - name: raw_transactions
        tables:
          - name: transactions
            tests:
              - dbt_utils.recency:
                  datepart: hour
                  field: created_at
                  interval: 3
            columns:
              - name: amount
                tests:
                  - dbt_utils.accepted_range:
                      min_value: 0
                      max_value: 1000000

    Test at source, not just at the model layer. If an upstream format changes, you want the failure at ingestion, not after three transformation layers have already propagated it downstream.

    Use dbt_utils and Elementary seriously. The dbt_utils package has range tests, expression tests, and recency checks that fill a lot of the structural gaps. Elementary adds anomaly detection on top of that, which gets you closer to actual data observability rather than just constraint validation.

    Review your SQL, not just your CI badge. Every model that feeds a critical metric should have a comment explaining the expected grain, the join logic, and the expected value ranges. Future you — and the next engineer — will thank you when something breaks at 2am.


    The Mindset Shift

    I had to reframe how I think about dbt tests. They’re not a data quality guarantee. They’re a contract enforcement layer. They ensure your data meets its structural promises. That’s genuinely useful — but it’s not the same as ensuring your data is correct.

    Real data quality requires a combination of:

    • Structural tests — what dbt gives you natively (constraint validation)
    • Business logic tests — custom singular tests you write based on domain knowledge
    • Volume and freshness monitoring — dbt_utils, Elementary, or your own row count assertions
    • Code review culture — someone actually looks at the SQL, not just whether CI passed

    The green checkmark in your pipeline is not permission to stop thinking. It’s permission to look at the next layer of potential failure.

    I spent a long time treating dbt tests as a safety net. They’re more like a fence — useful, visible, and completely ineffective against threats that don’t come through the gate.


    Frequently Asked Questions

    Do dbt tests guarantee data quality?

    No. dbt’s built-in generic tests — not_null, unique, accepted_values, relationships — validate structural constraints on your data. They confirm that a column has no nulls, that keys are unique, or that values fall within an expected set. They do not verify whether the actual values are correct, whether business logic in your SQL is right, or whether record volumes are within expected ranges. For genuine data quality coverage, you need custom singular tests, volume monitoring, and source-layer assertions alongside dbt’s native tests.

    What is the difference between dbt generic tests and singular tests?

    Generic tests in dbt are reusable, schema-defined checks applied to columns across multiple models — not_null and unique are the most common. Singular tests are standalone SQL queries that you write specifically for a model or business rule: they return rows when something is wrong and pass when they return no rows. Singular tests are where you validate business logic — things like “line_total should always equal unit_price × quantity” or “today’s row count should be within 20% of the 14-day average.” Both types live in your tests/ directory and run with dbt test.

    Can dbt catch silent record loss in joins?

    Not automatically. If a join accidentally drops records — due to a key format change, a null key, or a mismatched data type — dbt’s generic tests won’t flag it unless you’ve explicitly written a test to assert row count consistency before and after the join. This is one of the most common silent failure modes in production dbt pipelines. Writing a custom singular test that compares pre- and post-join row counts is the most reliable way to catch it.

    How do I monitor row count changes in dbt?

    There are a few approaches. The dbt_utils package includes a recency test for freshness monitoring. For volume, you can write a custom singular test that compares today’s row count against a rolling average from the past 14 days — any deviation beyond a threshold (say 20%) triggers a failure. For more automated anomaly detection across all your models, Elementary integrates directly with dbt and adds statistical monitoring without requiring you to write individual volume tests for every model.

    What is the best way to test business logic in dbt?

    Write singular tests that encode the business rule explicitly in SQL. For example, if your model calculates revenue = quantity × unit_price, write a test that queries the model and returns rows where abs(revenue - (quantity * unit_price)) > 0.01. If there are cross-column invariants — like a refund amount should never exceed the original transaction amount — write that as a test too. The key insight is that these tests require domain knowledge: you need to know what correct looks like before you can assert it. That’s a conversation between data engineers and the business teams who own the metrics.

    Does dbt have built-in anomaly detection?

    dbt itself does not include statistical anomaly detection. The core framework focuses on constraint-based testing. For anomaly detection — flagging unexpected spikes, drops, or distribution shifts in your data — you need either the Elementary package, which sits on top of dbt and adds automated monitoring, or a dedicated data observability platform like Monte Carlo, Soda, or Bigeye. In Snowflake environments specifically, combining dbt with Cortex-based quality checks can add an AI-assisted layer on top of your existing test suite.


    The Honest Closing

    The reason nobody talks loudly about this is that it’s uncomfortable. We build testing frameworks because they give us confidence. Admitting that green tests can coexist with broken data means admitting that the confidence was partly false.

    But I’d rather have that honest conversation in a blog post than explain to a VP why the quarterly revenue numbers were wrong — and then pull up a CI pipeline that was green the whole time.

    Write the custom tests. Monitor the volumes. Test the business rules. Trust the process, not just the color of the badge.


    Related reading from the blog:

  • Orchestrating Snowflake dbt Projects with Airflow — End-to-End Pipeline Guide

    Orchestrating Snowflake dbt Projects with Airflow — End-to-End Pipeline Guide

    How I Wired Snowflake’s Native dbt Projects to Airflow — And Finally Got True End-to-End Orchestration


    I’ll be honest with you — for a long time I was running dbt the way most people run it. dbt Core installed on a server, profiles.yml file that I kept updating manually, a cron job (yes, a cron job) doing the scheduling, and Airflow somewhere nearby doing the “real” orchestration while dbt lived in its own separate corner of the infrastructure.

    It worked. It was fine. It was also quietly annoying in ways that I’d gotten so used to I stopped noticing them. Managing the dbt server separately. Keeping the Snowflake credentials synced in two places. Debugging failures by jumping between the Airflow UI, SSH logs on the dbt server, and Snowsight — all at once.

    Then Snowflake went GA with dbt Projects in November 2025, and I spent a weekend rebuilding the whole thing. This article is what I learned.

    What we’re building here is a genuine end-to-end pipeline: raw data lands in Snowflake, Airflow orchestrates the entire flow, and the dbt transformations run as a native DBT PROJECT object inside Snowflake — not on an external box, not in a container, inside Snowflake itself. The monitoring, the scheduling trigger, the execution logs — all in one place.

    Let’s build it from the ground up.


    First — What Exactly Is a dbt Project on Snowflake?

    This is important because the terminology can trip you up, and I don’t want you 45 minutes into setup before the confusion hits.

    dbt Projects on Snowflake let you use familiar Snowflake features to create, edit, test, run, and manage dbt Core projects. You can use Workspaces in Snowsight to work with dbt project files and directories and deploy a dbt project as a schema-level DBT PROJECT object.

    The key word there is object. Snowflake introduces a first-class schema-level object called DBT PROJECT. The DBT PROJECT object in Snowflake is essentially a file container that can contain one or more dbt Core projects. Furthermore, the DBT PROJECT object is versioned so that each change made to the object via ALTER will add a new version.

    This means your dbt project — the models, the sources YAML, the dbt_project.yml — lives inside Snowflake as a versioned, native object. Not on a VM. Not in an S3 bucket somewhere. In Snowflake itself.

    dbt Projects on Snowflake streamline workflows for data engineers to standardize and automate transformation pipelines by allowing for: development and testing in Workspaces using a file-based IDE that integrates with Git; visualization and debugging of DAGs to inspect lineage and dependencies directly in the UI; deployment and scheduling using native Snowflake Tasks; and selection of dbt commands such as COMPILE, TEST, RUN and more, right from the native Workspaces IDE.

    So yes — you can schedule and run it purely with Snowflake Tasks and never touch Airflow. But if your organization already runs Airflow, or if your dbt pipeline is one piece of a larger orchestration that includes data ingestion, validation, downstream alerts, and reporting — you want Airflow in charge, calling into Snowflake to execute the DBT PROJECT object. That hybrid approach is exactly what this article covers.


    The Architecture We’re Building

    Before I show you a single line of code, let me draw the full picture because I think this is where most blog posts let you down — they show you a piece without the whole.

    [Source System / S3 / API]
            ↓
    [Airflow DAG starts]
            ↓
      Task 1: Load raw data → Snowflake staging table (via COPY INTO or S3 stage)
            ↓
      Task 2: Run data quality checks on raw data (SQLExecuteQueryOperator)
            ↓
      Task 3: EXECUTE DBT PROJECT → runs dbt build on your native Snowflake dbt project
            ↓
      Task 4: Post-run row count validation (SQLExecuteQueryOperator)
            ↓
      Task 5: Trigger downstream alert / Slack notification / refresh BI layer
            ↓
    [Pipeline complete]

    Airflow owns the orchestration. Snowflake owns the execution of the dbt transformations. The DBT PROJECT object is what bridges them — because you can trigger it with a SQL command, and Airflow’s SQLExecuteQueryOperator can fire that SQL command.

    That SQL command, by the way, is beautifully simple:

    EXECUTE DBT PROJECT my_database.my_schema.my_dbt_project
      ARGS = 'dbt build'
      VERSION = 'LAST';

    EXECUTE DBT PROJECT executes the specified dbt project object or the dbt project in a Snowflake workspace using the dbt command and command-line options specified. Snowflake Documentation

    One SQL statement. That’s all Airflow needs to fire. Let me now show you the full setup to make that work.


    Step 1: Snowflake Setup — Roles, Warehouse, and Permissions

    I always start here because bad permissions cause the most confusing failures, and they surface late in the process when you’re tired and frustrated.

    USE ROLE ACCOUNTADMIN;
    
    -- Create a dedicated role for dbt execution
    CREATE OR REPLACE ROLE dbt_executor_role;
    GRANT ROLE dbt_executor_role TO ROLE SYSADMIN;
    
    -- Create the service user Airflow will use
    CREATE OR REPLACE USER airflow_svc_user
      PASSWORD = 'YourStrongPassword123!'
      DEFAULT_ROLE = dbt_executor_role
      DEFAULT_WAREHOUSE = dbt_transform_wh
      COMMENT = 'Airflow service user for dbt orchestration';
    
    GRANT ROLE dbt_executor_role TO USER airflow_svc_user;
    
    -- Create a dedicated warehouse for dbt runs
    USE ROLE SYSADMIN;
    CREATE OR REPLACE WAREHOUSE dbt_transform_wh
      WITH WAREHOUSE_SIZE = 'SMALL'
      AUTO_SUSPEND = 120
      AUTO_RESUME = TRUE
      INITIALLY_SUSPENDED = TRUE;
    
    GRANT ALL ON WAREHOUSE dbt_transform_wh TO ROLE dbt_executor_role;
    
    -- Grant database and schema privileges
    GRANT USAGE ON DATABASE analytics_db TO ROLE dbt_executor_role;
    GRANT USAGE, CREATE TABLE, CREATE VIEW ON SCHEMA analytics_db.staging TO ROLE dbt_executor_role;
    GRANT USAGE, CREATE TABLE, CREATE VIEW ON SCHEMA analytics_db.marts TO ROLE dbt_executor_role;
    
    -- Grant the ability to execute dbt project objects
    GRANT EXECUTE DBT PROJECT ON SCHEMA analytics_db.transforms TO ROLE dbt_executor_role;

    I made a mistake my first time through — I granted object-level access but forgot the schema-level EXECUTE DBT PROJECT privilege, which is separate. The error message wasn’t obvious. Save yourself that 20-minute debugging session.


    Step 2: Deploy Your dbt Project as a Native Snowflake Object

    This is the step that feels the most different from traditional dbt Core setup. You’re not installing dbt on a server. You’re registering your project inside Snowflake.

    Option A: Via Snowsight Workspaces (recommended for first time)

    Log into Snowsight, navigate to Workspaces, and connect it to your Git repository:

    -- First, create an API integration for GitHub
    CREATE OR REPLACE API INTEGRATION github_integration
      API_PROVIDER = git_https_api
      API_ALLOWED_PREFIXES = ('https://github.com/yourorg/')
      ENABLED = TRUE;
    
    -- Create the Git repository object in Snowflake
    CREATE OR REPLACE GIT REPOSITORY dbt_project_repo
      API_INTEGRATION = github_integration
      GIT_CREDENTIALS = my_github_secret
      ORIGIN = 'https://github.com/yourorg/your-dbt-project.git';

    Option B: Deploy via SQL (great for CI/CD)

    -- Create the DBT PROJECT object from your connected Git repo
    CREATE OR REPLACE DBT PROJECT analytics_db.transforms.sales_dbt_project
      FROM GIT REPOSITORY dbt_project_repo
      REF = 'main'
      TARGET_PATH = 'models/'
      WAREHOUSE = dbt_transform_wh;

    Install dbt dependencies:

    Install dependencies by executing the dbt deps command within a Snowflake workspace, local machine, or git orchestrator to populate the dbt_packages folder for your dbt Project.

    -- Run this once after creating the project, or include in CI/CD
    EXECUTE DBT PROJECT analytics_db.transforms.sales_dbt_project
      ARGS = 'dbt deps'
      VERSION = 'LAST';

    A heads up on this: running dbt deps to install packages requires an external access integration when executed inside Snowflake Workspaces, since the runtime needs to reach external package repositories. Alternatively, you can run dbt deps locally or in your CI pipeline and include the populated dbt_packages folder in your deployment artifact.

    I found it cleaner to run dbt deps in my GitHub Actions pipeline and commit the dbt_packages folder, rather than configuring external access integrations for every environment. Your call — both approaches work.

    Verify it deployed correctly:

    -- Check your dbt project versions
    SHOW DBT PROJECTS IN SCHEMA analytics_db.transforms;
    
    -- Test execute manually before wiring Airflow
    EXECUTE DBT PROJECT analytics_db.transforms.sales_dbt_project
      ARGS = 'dbt compile'
      VERSION = 'LAST';

    If dbt compile completes without error, your project is live and ready to be called by Airflow.


    Step 3: Set Up a Real dbt Project Structure

    Let me show you what the actual project looks like. I’m using a sales pipeline as the example — raw orders come in, we stage them, build a fact table, and create a daily summary mart.

    dbt_project.yml:

    name: 'sales_pipeline'
    version: '1.0.0'
    config-version: 2
    
    profile: 'snowflake_prod'
    
    model-paths: ["models"]
    test-paths: ["tests"]
    seed-paths: ["seeds"]
    
    models:
      sales_pipeline:
        staging:
          +schema: staging
          +materialized: view
        marts:
          +schema: marts
          +materialized: table

    models/staging/stg_orders.sql:

    -- Staging model: clean and type-cast raw orders
    WITH raw AS (
        SELECT * FROM {{ source('raw', 'orders_raw') }}
    ),
    
    cleaned AS (
        SELECT
            order_id::VARCHAR           AS order_id,
            customer_id::VARCHAR        AS customer_id,
            order_date::DATE            AS order_date,
            UPPER(TRIM(status))         AS order_status,
            amount::DECIMAL(18, 2)      AS order_amount,
            region::VARCHAR             AS region,
            CURRENT_TIMESTAMP()         AS _loaded_at
        FROM raw
        WHERE order_id IS NOT NULL
          AND order_date >= '2023-01-01'
    )
    
    SELECT * FROM cleaned

    models/marts/fct_daily_orders.sql:

    -- Fact table: daily order summary by region
    WITH staged AS (
        SELECT * FROM {{ ref('stg_orders') }}
    )
    
    SELECT
        order_date,
        region,
        order_status,
        COUNT(DISTINCT order_id)                    AS total_orders,
        COUNT(DISTINCT customer_id)                 AS unique_customers,
        SUM(order_amount)                           AS total_revenue,
        AVG(order_amount)                           AS avg_order_value,
        SUM(CASE WHEN order_status = 'RETURNED' 
                 THEN order_amount ELSE 0 END)      AS returned_amount,
        CURRENT_TIMESTAMP()                         AS _refreshed_at
    FROM staged
    GROUP BY order_date, region, order_status
    ORDER BY order_date DESC, region

    models/staging/sources.yml:

    version: 2

    sources:

    • name: raw database: analytics_db schema: raw_landing tables:
      • name: orders_raw description: “Raw orders from the source system” columns:
        • name: order_id tests:
          • not_null
          • unique
        • name: customer_id tests:
          • not_null
        • name: order_date tests:
          • not_null
        • name: amount tests:
          • not_null

    models/marts/schema.yml:

    version: 2
    
    models:
      - name: fct_daily_orders
        description: "Daily order summary by region and status"
        columns:
          - name: order_date
            tests:
              - not_null
          - name: total_orders
            tests:
              - not_null
          - name: total_revenue
            tests:
              - not_null

    This gives us a clean, testable project with source freshness checks and column-level tests. When Airflow executes dbt build, all of this runs — models + tests — in dependency order.


    Step 4: Wire It All Together in Airflow

    Now the fun part. I’m going to show you a complete Airflow DAG that:

    1. Validates raw data arrived in Snowflake
    2. Fires the native dbt project execution
    3. Validates row counts on the output marts
    4. Sends a Slack notification on success or failure

    First, install the Snowflake provider if you haven’t:

    pip install apache-airflow-providers-snowflake

    Set up your Snowflake connection in the Airflow UI (Admin → Connections):

    Connection ID : snowflake_analytics
    Connection Type : Snowflake
    Account  : yourorg.us-east-1
    Login    : airflow_svc_user
    Password : YourStrongPassword123!
    Schema   : transforms
    Database : analytics_db
    Warehouse: dbt_transform_wh
    Role     : dbt_executor_role

    Now the DAG:

    dags/sales_pipeline_dag.py:

    from airflow import DAG
    from airflow.providers.snowflake.operators.snowflake import SQLExecuteQueryOperator
    from airflow.operators.python import PythonOperator, BranchPythonOperator
    from airflow.operators.empty import EmptyOperator
    from airflow.utils.dates import days_ago
    from datetime import datetime, timedelta
    import logging
    
    # ── Default args ────────────────────────────────────────────────
    default_args = {
        'owner': 'data-engineering',
        'depends_on_past': False,
        'retries': 1,
        'retry_delay': timedelta(minutes=5),
        'email_on_failure': True,
        'email': ['[email protected]'],
    }
    
    SNOWFLAKE_CONN = 'snowflake_analytics'
    
    # ── SQL snippets ─────────────────────────────────────────────────
    RAW_DATA_CHECK_SQL = """
    SELECT COUNT(*) AS raw_row_count
    FROM analytics_db.raw_landing.orders_raw
    WHERE order_date = CURRENT_DATE() - 1;
    """
    
    EXECUTE_DBT_SQL = """
    EXECUTE DBT PROJECT analytics_db.transforms.sales_dbt_project
      ARGS = 'dbt build --select staging.stg_orders+ --vars "{\\"run_date\\": \\"{{ ds }}\\"}"'
      VERSION = 'LAST';
    """
    
    MART_VALIDATION_SQL = """
    SELECT
        COUNT(*) AS mart_row_count,
        MAX(order_date) AS latest_date,
        SUM(total_revenue) AS total_revenue
    FROM analytics_db.marts.fct_daily_orders
    WHERE order_date = CURRENT_DATE() - 1;
    """
    
    ROW_COUNT_GUARD_SQL = """
    SELECT
        CASE
            WHEN COUNT(*) = 0
            THEN 'FAIL: No rows found in mart for yesterday'
            ELSE 'PASS: ' || COUNT(*) || ' rows present'
        END AS validation_result
    FROM analytics_db.marts.fct_daily_orders
    WHERE order_date = CURRENT_DATE() - 1;
    """
    
    # ── DAG definition ───────────────────────────────────────────────
    with DAG(
        dag_id='sales_pipeline_end_to_end',
        default_args=default_args,
        description='End-to-end sales pipeline: raw → dbt native project → marts',
        schedule_interval='0 6 * * *',     # 6 AM UTC daily
        start_date=days_ago(1),
        catchup=False,
        tags=['snowflake', 'dbt', 'sales'],
    ) as dag:
    
        # Task 1: Check raw data arrived
        check_raw_data = SQLExecuteQueryOperator(
            task_id='check_raw_data_arrived',
            conn_id=SNOWFLAKE_CONN,
            sql=RAW_DATA_CHECK_SQL,
            handler=lambda cursor: logging.info(
                f"Raw row count: {cursor.fetchone()[0]}"
            ),
        )
    
        # Task 2: Execute the native dbt project on Snowflake
        run_dbt_project = SQLExecuteQueryOperator(
            task_id='execute_dbt_project_snowflake',
            conn_id=SNOWFLAKE_CONN,
            sql=EXECUTE_DBT_SQL,
            # Give dbt build enough time for large projects
            execution_timeout=timedelta(hours=2),
        )
    
        # Task 3: Post-run mart validation
        validate_mart_output = SQLExecuteQueryOperator(
            task_id='validate_mart_output',
            conn_id=SNOWFLAKE_CONN,
            sql=ROW_COUNT_GUARD_SQL,
            handler=lambda cursor: logging.info(
                f"Validation result: {cursor.fetchone()[0]}"
            ),
        )
    
        # Task 4: Run broader stats query (logged for observability)
        log_mart_stats = SQLExecuteQueryOperator(
            task_id='log_mart_statistics',
            conn_id=SNOWFLAKE_CONN,
            sql=MART_VALIDATION_SQL,
        )
    
        # Task 5: Success marker
        pipeline_complete = EmptyOperator(task_id='pipeline_complete')
    
        # ── Dependencies ─────────────────────────────────────────────
        (
            check_raw_data
            >> run_dbt_project
            >> validate_mart_output
            >> log_mart_stats
            >> pipeline_complete
        )

    Step 5: Running Specific dbt Selectors from Airflow

    One of the things I really like about this approach is that you get the full power of dbt’s selector syntax passed straight through the ARGS parameter. You don’t have to run the entire project every time.

    Run only staging models:

    EXECUTE_STAGING_ONLY = """
    EXECUTE DBT PROJECT analytics_db.transforms.sales_dbt_project
      ARGS = 'dbt run --select staging.*'
      VERSION = 'LAST';
    """

    Run a specific model and all its downstream dependencies:

    EXECUTE_ORDERS_DOWNSTREAM = """
    EXECUTE DBT PROJECT analytics_db.transforms.sales_dbt_project
      ARGS = 'dbt build --select stg_orders+'
      VERSION = 'LAST';
    """

    Run tests only, separate from the model run:

    RUN_DBT_TESTS = """
    EXECUTE DBT PROJECT analytics_db.transforms.sales_dbt_project
      ARGS = 'dbt test --select staging.*'
      VERSION = 'LAST';
    """

    This means you can split a single DAG into multiple tasks — one for staging, one for marts, one for tests — and get granular retry behavior in Airflow if something fails mid-pipeline. Instead of rerunning everything, Airflow retries only the failed task.

    Here’s that pattern as a DAG:

    run_staging = SQLExecuteQueryOperator(
        task_id='run_dbt_staging',
        conn_id=SNOWFLAKE_CONN,
        sql="""
            EXECUTE DBT PROJECT analytics_db.transforms.sales_dbt_project
              ARGS = 'dbt run --select staging.*'
              VERSION = 'LAST';
        """,
    )
    
    test_staging = SQLExecuteQueryOperator(
        task_id='test_dbt_staging',
        conn_id=SNOWFLAKE_CONN,
        sql="""
            EXECUTE DBT PROJECT analytics_db.transforms.sales_dbt_project
              ARGS = 'dbt test --select staging.*'
              VERSION = 'LAST';
        """,
    )
    
    run_marts = SQLExecuteQueryOperator(
        task_id='run_dbt_marts',
        conn_id=SNOWFLAKE_CONN,
        sql="""
            EXECUTE DBT PROJECT analytics_db.transforms.sales_dbt_project
              ARGS = 'dbt run --select marts.*'
              VERSION = 'LAST';
        """,
    )
    
    run_staging >> test_staging >> run_marts

    This is how I actually run it in practice. If staging tests fail, marts never execute. If marts fail, I retry marts without re-running staging. Clean dependency management with minimal code.


    Step 6: Handling New Versions of Your dbt Project

    This is something I didn’t think about until I pushed a breaking change to main and my 6 AM pipeline executed the wrong version.

    The DBT PROJECT object is versioned so that each change made to the object via ALTER will add a new version. The versions are named according to the pattern VERSION$<num>.

    In practice, your CI/CD pipeline (GitHub Actions, etc.) should update the DBT PROJECT object after any merge to main:

    # .github/workflows/deploy_dbt.yml
    name: Deploy dbt Project to Snowflake
    
    on:
      push:
        branches: [main]
    
    jobs:
      deploy:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v3
    
          - name: Install Snowflake CLI
            run: pip install snowflake-cli-labs
    
          - name: Deploy new dbt project version
            env:
              SNOWFLAKE_ACCOUNT: ${{ secrets.SNOWFLAKE_ACCOUNT }}
              SNOWFLAKE_USER: ${{ secrets.SNOWFLAKE_USER }}
              SNOWFLAKE_PASSWORD: ${{ secrets.SNOWFLAKE_PASSWORD }}
            run: |
              snow dbt deploy \
                --project-name analytics_db.transforms.sales_dbt_project \
                --from-git \
                --ref main

    And in your Airflow SQL, VERSION = 'LAST' always picks up the most recently deployed version automatically. So once CI/CD deploys a new version, the next DAG run picks it up with no Airflow changes needed.


    Step 7: Monitoring — What to Watch and Where

    Before this setup, I was watching three screens at once when something went wrong. Now it’s mostly one.

    In Snowsight:

    -- Check recent dbt project execution history
    SELECT
        query_id,
        query_text,
        execution_status,
        start_time,
        end_time,
        DATEDIFF('second', start_time, end_time) AS duration_seconds,
        error_message
    FROM TABLE(
        INFORMATION_SCHEMA.QUERY_HISTORY(
            END_TIME_RANGE_START => DATEADD('day', -1, CURRENT_TIMESTAMP()),
            RESULT_LIMIT => 50
        )
    )
    WHERE query_text ILIKE '%EXECUTE DBT PROJECT%'
    ORDER BY start_time DESC;

    Row count drift detection (add this as an Airflow task):

    -- Compare today's mart row count to yesterday's
    -- Flag if it drops more than 20%
    WITH today AS (
        SELECT COUNT(*) AS cnt
        FROM analytics_db.marts.fct_daily_orders
        WHERE order_date = CURRENT_DATE() - 1
    ),
    yesterday AS (
        SELECT COUNT(*) AS cnt
        FROM analytics_db.marts.fct_daily_orders
        WHERE order_date = CURRENT_DATE() - 2
    )
    SELECT
        today.cnt                                               AS today_rows,
        yesterday.cnt                                           AS yesterday_rows,
        ROUND((today.cnt - yesterday.cnt) / NULLIF(yesterday.cnt, 0) * 100, 2) AS pct_change,
        CASE
            WHEN today.cnt < yesterday.cnt * 0.80
            THEN 'ALERT: Row count dropped over 20%'
            ELSE 'OK'
        END AS status
    FROM today, yesterday;

    I added this query as a SQLExecuteQueryOperator task right after the mart validation step. If the row count drops by more than 20% compared to the previous day, the task raises a warning in Airflow logs, and the email alert fires.

    Not every data quality problem shows up as a dbt test failure. Sometimes the data just quietly shrinks because an upstream feed stopped delivering. This catches that.


    What This Setup Actually Changed for Me

    I want to be real about this because I think the “benefits” sections in most blog posts are too abstract.

    Before: My pipeline had six moving parts. Airflow DAG on one server. dbt installed on a separate instance. profiles.yml with credentials that needed updating every time we rotated passwords. Separate monitoring in CloudWatch for the dbt server. Debugging a failure meant SSH → dbt server → find the log file → cross-reference with Airflow logs.

    After: The pipeline has three moving parts — Airflow, Snowflake, and GitHub. The dbt credentials are managed by Airflow’s Snowflake connection, which I was already maintaining. Debugging a failure means clicking into the Airflow task logs (which capture the SQL response from Snowflake) and if I need more detail, running the QUERY_HISTORY query above in Snowsight.

    Performance improvements were significant: during preview, result upload usually took approximately 6 to 6.5 minutes. Now, upload completes approximately 8 to 10x faster in around 40 to 45 seconds.

    The startup time improvement alone was worth it for me. My morning pipeline used to take 28-32 minutes. It now consistently runs in 18-22 minutes. That’s not from faster models — it’s from the reduction in environment spin-up overhead.


    A Few Gotchas I Hit Along the Way

    1. The EXECUTE DBT PROJECT command is synchronous by default. Airflow will wait for it to complete before marking the task done. For large projects this is fine — you want that behavior. Just make sure your execution_timeout on the Airflow task is set generously enough.

    2. Cross-project references don’t work the way you might expect. Cross-project dependencies must be copied into the root of the main project — Snowflake doesn’t support references to external file paths within the DBT PROJECT object. If you have multiple dbt projects, plan your consolidation before deploying.

    3. The VERSION = 'LAST' behavior. This always runs the most recently deployed version. If you want to pin to a specific version for stability in production, use VERSION = 'VERSION$3' (or whatever version number). I run LAST in dev and a pinned version in prod, deployed via CI/CD.

    4. Warehouse auto-resume and the first task. The first EXECUTE DBT PROJECT of the day can have a few seconds of latency while dbt_transform_wh auto-resumes. I added a lightweight warm-up query as the very first task in my DAG so the warehouse is already running by the time dbt build kicks off:

    warm_up_warehouse = SQLExecuteQueryOperator(
        task_id='warm_up_warehouse',
        conn_id=SNOWFLAKE_CONN,
        sql="SELECT CURRENT_TIMESTAMP();",
    )
    
    warm_up_warehouse >> check_raw_data >> run_dbt_project >> ...

    Costs almost nothing. Saves 5-10 seconds of variability at the start of every run.


    Why I Think This Is the Right Direction

    I started exploring this because nobody told me to. My team’s existing setup worked. A reasonable person would have left it alone.

    But the more I looked at this setup, the more I kept thinking about the overhead we carry when tools don’t talk to each other natively. Every boundary between systems is a place where credentials leak, latency is added, and debugging gets harder. The native dbt project in Snowflake closes one of those boundaries. Airflow still owns orchestration — which is where it belongs — but the transformation execution lives where the data lives.

    For the growing number of organizations that have standardized on Snowflake, the native integration offers something genuinely compelling: one fewer system to run, one fewer vendor to manage, and one fewer boundary between your data and the logic that transforms it.

    That sentence landed for me when I read it. That’s exactly what this is.

    If you’ve been running dbt Core on a server and Airflow alongside it and you’ve been tolerating that overhead long enough that you’ve stopped noticing it — try this weekend rebuild. You might be surprised how much lighter the pipeline feels on the other side.

    And if you do try it and hit something weird, drop it in the comments. I’m still learning this myself.

  • 2026 Guide: Cut dbt Build Time 48% with Snowflake Cortex Code

    2026 Guide: Cut dbt Build Time 48% with Snowflake Cortex Code

    The Moment Everything Changed

    It was a Tuesday morning when I finally snapped. My dbt project had grown to 147 models, and the daily run was taking 2 hours and 47 minutes. Our Airflow DAG was timing out. The business team was complaining about stale dashboards. And I was spending my entire morning investigating why dim_customer alone was taking 45 minutes to build.

    I had tried everything: manual query optimization, clustering keys, switching materializations. Each fix helped a little, but I was basically guessing. Then someone on the data engineering Slack mentioned using Snowflake Cortex Code to analyze their dbt manifest file.

    “Wait, it can do WHAT?” I asked.

    That question changed my entire workflow. Three months later, my dbt runs average 1 hour 23 minutes—a 48% improvement. I spend 90% less time debugging performance. And I actually have time to build new features instead of firefighting slow models.

    This isn’t a tutorial about how Cortex Code might help you. This is the real story of how it actually transformed my day-to-day work as a data engineer, with specific examples, exact prompts I use, and honest numbers about what works and what doesn’t.


    Part 1: What Is Snowflake Cortex Code? (The Simple Truth)

    Before I get into the dbt deep dive, let me explain what Cortex Code actually is—because the marketing doesn’t do it justice.

    Cortex Code is code generation AI built directly into Snowflake. Think ChatGPT, but it:

    • Understands your Snowflake schema automatically
    • Knows dbt best practices
    • Can analyze JSON files (like manifest.json)
    • Generates production-ready SQL, Python, and more
    • Lives where you already work (Snowflake UI, or via API)

    How it’s different from GitHub Copilot or ChatGPT:

    FeatureCortex CodeGitHub CopilotChatGPT
    Knows your Snowflake schema✅ Yes❌ No❌ No
    Can read manifest.json✅ Yes❌ No⚠️ Manual paste
    Snowflake-specific SQL✅ Optimized⚠️ Generic⚠️ Generic
    dbt best practices✅ Built-in⚠️ Learns from code⚠️ General knowledge
    Privacy/Security✅ Snowflake environment⚠️ Code leaves editor❌ Data uploaded

    The key difference for data engineers: Cortex Code actually understands your data warehouse context.


    Part 2: Getting Started (5-Minute Setup)

    Step 1: Enable Cortex Code

    Cortex Code is available in Snowflake (check your edition—Enterprise or higher typically has it).

    Simple interface showing Snowflake Cortex Code prompt for generating dbt models

    Step 1: Enable Cortex Code

    Cortex Code is available in Snowflake (check your edition—Enterprise or higher typically has it).

    -- Check if you have access
    SELECT SYSTEM$GET_CORTEX_FEATURES();
    -- If available, you're good to go
    -- No additional setup needed

    Step 2: First Test

    How to Access Cortex Code:

    1. Open Snowsight (Snowflake UI)
    2. Look for the “AI Assistant” or “Cortex Code” button (usually in the sidebar or bottom-right)
    3. Type your prompt in natural language
    4. Get generated code instantly

    Example first prompt:

    Generate SQL to find top 10 customers by revenue from my customers and orders tables

    Cortex Code responds with:

    SELECT 
        c.customer_id,
        c.customer_name,
        SUM(o.order_amount) as total_revenue
    FROM customers c
    JOIN orders o ON c.customer_id = o.customer_id
    GROUP BY c.customer_id, c.customer_name
    ORDER BY total_revenue DESC
    LIMIT 10;

    That’s it. No installation, no API keys, no configuration. Just natural language prompts.

    My first “wow” moment: I typed “generate a complete dbt model for customer lifetime value with staging, intermediate, and mart layers” and it produced three properly structured models with naming conventions, tests, and documentation. Took 30 seconds.


    Part 3: Core Capabilities (The Quick Tour)

    Before we dive deep into dbt, here’s what Cortex Code can do:

    Before SQL Generation:

    • Complex queries with CTEs, window functions, aggregations
    • Query optimization suggestions
    • Data quality checks

    dbt Development:

    • Model generation (staging, marts, facts, dimensions)
    • Test creation (schema tests, custom tests)
    • Macro writing
    • Performance analysis from manifest.json

    Airflow DAGs:

    • Complete DAG structures
    • Task dependencies and retry logic
    • Custom operators

    Streamlit Dashboards:

    • Layout scaffolding
    • Chart configurations
    • Filter and interactivity code

    Python UDFs:

    • Custom function generation
    • Pandas operations
    • Complex transformations

    Debugging:

    • Code explanation
    • Error analysis
    • Optimization suggestions

    How to Use: Simply open the Cortex Code interface in Snowsight and type what you need in plain English. Examples:

    • “Generate a dbt staging model for my customers table”
    • “Create an Airflow DAG for daily ETL”
    • “Build a Streamlit dashboard with revenue KPIs”

    Now let’s talk about where it really shines: dbt optimization.


    Part 4: dbt + Cortex Code – The Real Game Changer

    4.1: Quick Overview – Beyond Basic Generation

    Yes, Cortex Code can generate dbt models. Ask it for a staging model, it’ll give you:

    -- Example prompt: "Generate dbt staging model for raw_customers"
    
    -- models/staging/stg_customers.sql
    with source as (
        select * from {{ source('raw', 'customers') }}
    ),
    
    renamed as (
        select
            customer_id,
            customer_name,
            email,
            created_at,
            updated_at
        from source
    )
    
    select * from renamed

    And yes, it can write tests:

    # Prompt: "Create dbt tests for stg_customers"
    version: 2
    
    models:
      - name: stg_customers
        columns:
          - name: customer_id
            tests:
              - unique
              - not_null
          - name: email
            tests:
              - unique
              - not_null

    But honestly? That’s the boring stuff. Any code generation tool can do this. Where Cortex Code becomes indispensable is performance optimization using your actual dbt metadata.


    4.2: Performance Optimization – The Killer Feature

    This is where I went from “this is neat” to “I can’t work without this anymore.”

    The Problem I Had

    My dbt project metrics (before Cortex Code):

    • 147 models total
    • Full refresh: 2h 47min
    • Incremental run: 1h 15min
    • Daily Airflow timeout failures: 2-3 times per week
    • Time spent debugging performance: 6-8 hours per week

    I had no systematic way to know:

    • Which models were actually slow?
    • Why were they slow?
    • What should I optimize first?
    • Were my optimizations working?

    I was flying blind, making educated guesses based on gut feeling and manual timing of individual models.ed on gut feeling and manual timing of individual models.


    A) Manifest.json Analysis – The Secret Weapon

    Diagram showing how Cortex Code analyzes dbt manifest.json file to identify performance bottlenecks and optimization opportunities

    Your dbt project generates a manifest.json file in the target/ folder after every run. It contains:

    • Every model’s metadata
    • Dependencies between models
    • Column information
    • Schema details

    I never really looked at it. It’s thousands of lines of JSON. Until Cortex Code.

    How to use it:

    Step 1: Upload manifest.json to Snowflake

    -- Create a stage for your dbt metadata
    CREATE STAGE IF NOT EXISTS dbt_metadata;
    
    -- Upload the file (via SnowSQL or Snowsight UI)
    PUT file://~/dbt_project/target/manifest.json @dbt_metadata/;

    Step 2: Open Cortex Code interface in Snowsight

    • Click on the “AI Assistant” or “Cortex Code” button in Snowsight
    • This opens the natural language interface

    Step 3: Ask Cortex Code to analyze it

    Type this prompt in the Cortex Code interface:

    Analyze the manifest.json file in my dbt_metadata stage and identify the top 10 slowest models with specific optimization recommendations. 
    
    Focus on:
    - Materialization strategies (table vs incremental)
    - Clustering opportunities  
    - Complex CTEs that could be simplified
    - Join patterns that could be optimized
    
    Provide specific code changes and estimated time savings for each recommendation.

    Step 4: Review the analysis

    Cortex Code responds with detailed analysis (example of what I got):

    Top 10 Slowest Models Analysis:
    
    1. dim_customer (45.2 minutes)
       Issues identified:
       - Full table scan on 50M+ rows daily
       - No clustering key on frequently filtered columns
       - Using table materialization when incremental would work
       - 3 CTEs doing similar aggregations (could be deduplicated)
       
       Recommendations:
       - Add clustering key on (customer_id, updated_date)
       - Convert to incremental materialization
       - Use dbt_utils.deduplicate macro
       - Estimated improvement: 35-40 minutes
       
    2. fact_orders_daily (23.7 minutes)
       Issues identified:
       - Window functions not optimized
       - Joining to dim_customer before aggregation
       - Missing partition by date
       
       Recommendations:
       - Aggregate first, then join
       - Add partition_by clause to window functions
       - Consider separate monthly partitions
       - Estimated improvement: 15-18 minutes
       
    3. mart_customer_360 (18.4 minutes)
       ...

    I was stunned. This analysis would have taken me days to do manually—if I even knew where to start. Cortex Code did it in 30 seconds.


    B) Implementing the Recommendations

    Let me show you exactly what I did for dim_customer:

    Before (45 minutes):

    -- models/marts/dim_customer.sql
    {{
        config(
            materialized='table'
        )
    }}
    with customers as (
        select * from {{ ref('stg_customers') }}
    ),
    orders as (
        select * from {{ ref('fct_orders') }}
    ),
    aggregated as (
        select
            c.customer_id,
            c.customer_name,
            c.email,
            c.created_at,
            count(o.order_id) as total_orders,
            sum(o.order_amount) as lifetime_value,
            max(o.order_date) as last_order_date
        from customers c
        left join orders o on c.customer_id = o.customer_id
        group by 1,2,3,4
    )
    select * from aggregated

    After (8 minutes) following Cortex Code suggestions:

    Before and after comparison of dbt model performance: 45 minutes reduced to 8 minutes using Cortex Code optimization suggestions
    -- models/marts/dim_customer.sql
    {{
        config(
            materialized='incremental',
            unique_key='customer_id',
            cluster_by=['customer_id', 'updated_date'],
            on_schema_change='append_new_columns'
        )
    }}
    with customers as (
        select * from {{ ref('stg_customers') }}
        {% if is_incremental() %}
        where updated_date >= (select max(updated_date) from {{ this }})
        {% endif %}
    ),
    orders_aggregated as (
        -- Aggregate BEFORE joining (Cortex suggestion!)
        select
            customer_id,
            count(order_id) as total_orders,
            sum(order_amount) as lifetime_value,
            max(order_date) as last_order_date
        from {{ ref('fct_orders') }}
        {% if is_incremental() %}
        where order_date >= (select max(last_order_date) from {{ this }})
        {% endif %}
        group by customer_id
    ),
    final as (
        select
            c.customer_id,
            c.customer_name,
            c.email,
            c.created_at,
            c.updated_date,
            coalesce(o.total_orders, 0) as total_orders,
            coalesce(o.lifetime_value, 0) as lifetime_value,
            o.last_order_date
        from customers c
        left join orders_aggregated o on c.customer_id = o.customer_id
    )
    select * from final

    Changes made:

    1. ✅ Switched to incremental materialization
    2. ✅ Added clustering keys on customer_id and updated_date
    3. ✅ Aggregated orders before joining (huge win!)
    4. ✅ Added incremental logic to only process new/changed data

    Result: 45 minutes → 8 minutes (first run), 3 minutes (incremental runs)


    C) run_results.json Deep Dive

    The run_results.json file contains actual execution times and metadata from your last dbt run. Even more valuable than manifest for performance debugging.

    My weekly performance review process:

    -- Upload run_results from this week and last week
    PUT file://~/dbt_project/target/run_results.json @my_stage/current/;
    PUT file://~/dbt_project_backup/target/run_results.json @my_stage/previous/;

    Example output:

    Performance Regression Analysis:
    CRITICAL REGRESSIONS (>50% slower):
    1. mart_sales_summary
       - Previous: 4.2 min
       - Current: 9.8 min (+133%)
       - Root cause: Source table fct_sales grew from 10M to 25M rows
       - Recommendation: Add incremental logic with date partitioning
       
    2. dim_product
       - Previous: 2.1 min
       - Current: 5.4 min (+157%)
       - Root cause: New join to external API table (no clustering)
       - Recommendation: Materialize API data first, add clustering key
    MODERATE REGRESSIONS (20-50% slower):
    3. stg_orders
       - Previous: 1.2 min
       - Current: 1.6 min (+33%)
       - Root cause: New data quality test added (full table scan)
       - Recommendation: Convert test to incremental or sampling
    IMPROVEMENTS:
    1. dim_customer: 45 min → 8 min (-82%) ✅ [Your optimization worked!]
    2. fact_orders_daily: 23 min → 12 min (-48%) ✅
    NEW BOTTLENECKS:
    - mart_customer_cohort now takes 14 min (wasn't slow before)
    - Likely due to dim_customer changes propagating downstream
    - Recommendation: Review joins, consider pre-aggregation

    This is gold. I immediately know what broke, why, and how to fix it.


    D) Automated Performance Audits

    I set up a weekly routine every Monday morning using Cortex Code:

    My Monday Morning Workflow:

    Run my standardized audit prompt

    Upload latest manifest and run_results (automated via simple Python script)

    Open Cortex Code interface

    Perform a comprehensive dbt performance audit using the manifest.json and run_results.json in my dbt_metadata stage:
    
    Analysis needed:
    1. Identify slowest 15 models with root cause analysis
    2. Detect performance anti-patterns:
       - Models using full refresh that should be incremental
       - Missing clustering keys on large tables  
       - Inefficient join patterns
       - Unnecessary full table scans
    3. Find models that should be incremental but aren't
    4. Suggest clustering keys based on filter/join patterns in SQL
    5. Recommend materialization strategies (table vs view vs incremental)
    6. Calculate estimated monthly compute time savings for each recommendation
    7. Rank by effort/impact ratio (quick wins vs long-term projects)
    
    Format as prioritized action plan with:
    - Quick wins (high impact, <1 hour effort)
    - Medium effort items (2-4 hours)  
    - Strategic improvements (>4 hours)
    - Estimated ROI for each

    Sample output from last Monday:

    dbt Performance Audit - 2026-01-20
    QUICK WINS (High Impact, Low Effort):
    1. Add clustering to dim_geography on (country_code, region_id)
       - Current: 6.2 min | Estimated after: 1.5 min | Effort: 5 min
       - Impact: Save 4.7 min per run = 33 hours/month
    2. Convert fct_user_sessions to incremental
       - Current: 11.3 min | Estimated after: 2.1 min | Effort: 20 min
       - Impact: Save 9.2 min per run = 65 hours/month
    MEDIUM EFFORT, HIGH IMPACT:
    3. Refactor mart_product_performance (split into 2 models)
       - Current: 18.7 min | Estimated after: 7 min + 4 min | Effort: 2 hours
       - Impact: Save 7.7 min per run = 54 hours/month
       
    ANTI-PATTERNS DETECTED:
    ⚠️ 8 models using table materialization with full refresh daily (should be incremental)
    ⚠️ 4 models missing clustering keys despite large table scans
    ⚠️ 2 models with circular dependency risk
    TOTAL ESTIMATED MONTHLY SAVINGS: 247 hours of compute time
    TOTAL IMPLEMENTATION EFFORT: ~8 hours
    ROI: 30:1

    I tackle 2-3 items from this list each week. In three months, I’ve saved 48% of total dbt runtime.


    4.3: Real Production Examples

    Let me share three actual scenarios where Cortex Code saved my ass:

    Example 1: The Mysterious Slowdown

    Problem: mart_revenue_daily suddenly went from 5 minutes to 32 minutes. No code changes, just suddenly slow.

    What I did:

    Analyze this slow dbt model. It suddenly went from 5 minutes to 32 minutes with no code changes.
    
    Context:
    - Model name: mart_revenue_daily
    - Previous runtime: 5 min
    - Current runtime: 32 min  
    - No changes to model SQL in past month
    - Fact table grew from 50M to 180M rows last week
    
    Here's the compiled SQL from dbt:
    [I pasted the full compiled SQL here]
    
    Please:
    1. Identify the root cause of slowdown
    2. Point to specific problematic lines in the SQL
    3. Provide concrete fix with rewritten SQL
    4. Estimate performance improvement

    Cortex Code’s Response:

    Root Cause Identified:
    
    The model joins fact_transactions (now 180M rows) to dim_date without filtering first.
    
    Line 47-52 in your SQL:
    ```sql
    left join {{ ref('dim_date') }} d
        on t.transaction_date = d.date_day
    ```
    
    With 180M rows, this creates a massive intermediate result set before the WHERE clause on line 68.
    
    Solution:
    1. Move the date filter BEFORE the join (filter fact_transactions first)
    2. Add clustering key on fact_transactions.transaction_date
    3. Consider partitioning by month if queries typically filter by date range
    
    Estimated improvement: 32 min → 6 min

    Implementation:

    -- Changed from this:
    from {{ ref('fact_transactions') }} t
    left join {{ ref('dim_date') }} d on t.transaction_date = d.date_day
    where d.fiscal_year = 2024
    
    -- To this:
    from (
        select * from {{ ref('fact_transactions') }}
        where transaction_date >= '2024-01-01'
    ) t
    left join {{ ref('dim_date') }} d on t.transaction_date = d.date_day

    Plus added clustering:

    {{ config(cluster_by=['transaction_date']) }}

    Result: 32 minutes → 5.2 minutes. Problem solved in 20 minutes total.


    Example 2: Pre-Launch Optimization

    Scenario: Building a new mart_customer_churn_prediction model. Wanted to optimize it BEFORE the first production run.

    I'm about to deploy this new dbt model to production. Please review and suggest optimizations BEFORE I run it for the first time.
    
    Model: mart_customer_churn_prediction
    
    Focus areas:
    - Best materialization strategy (table, view, or incremental)
    - Clustering key recommendations
    - Potential bottlenecks in the SQL
    - Incremental logic if applicable
    - Query optimization opportunities
    
    Here's the model SQL:
    [pasted my model code]
    
    Expected data volume: 50M+ customer records
    Update frequency: Daily
    Query pattern: Filtered by customer_segment and churn_date

    Cortex Code’s Pre-Production Analysis:

    Pre-Production Optimization Analysis:
    
    CRITICAL ISSUES:
    1. ❌ Using table materialization for 50M+ row result
       Recommendation: Use incremental with unique_key on customer_id
       
    2. ❌ No clustering key despite filtering on customer_segment and churn_date
       Recommendation: cluster_by=['customer_segment', 'churn_date']
       
    3. ❌ Complex CTE chain (7 CTEs) doing full table scans
       Recommendation: Break into separate intermediate models
    
    PERFORMANCE PREDICTIONS:
    - As written: Estimated 45-60 minutes first run
    - With optimizations: Estimated 8-12 minutes first run, 2-3 min incremental
    
    SUGGESTED REFACTOR:
    Split into 3 models:
    1. int_customer_metrics (intermediate, incremental)
    2. int_churn_features (intermediate, incremental)  
    3. mart_customer_churn_prediction (mart, incremental)

    I implemented all suggestions. First production run: 9.3 minutes instead of the estimated 45-60.


    Example 3: Monthly Performance Review

    Every month, I do a comprehensive audit:

    Step 1: Collect all metadata files

    # My automation script copies these
    cp ~/dbt_project/target/manifest.json ~/monthly_audits/2026-01/
    cp ~/dbt_project/target/run_results.json ~/monthly_audits/2026-01/

    Step 2: Upload to Snowflake

    PUT file://~/monthly_audits/2026-01/* @dbt_metadata/monthly/2026-01/;

    Step 3: Open Cortex Code and run monthly audit

    Monthly dbt Performance Review - January 2026
    
    Using files in dbt_metadata/monthly/2026-01/:
    - manifest.json 
    - run_results.json
    
    Provide comprehensive analysis:
    
    1. HEALTH METRICS
       - Overall project health score (0-100)
       - Total models and average runtime
       - Percentage using best practices (incremental, clustering)
       - Month-over-month performance trend
    
    2. TOP ISSUES  
       - 10 slowest models with root cause
       - Performance anti-patterns detected
       - Models that grew disproportionately  
       - Technical debt items
    
    3. CLEANUP OPPORTUNITIES
       - Unused or rarely-run models
       - Outdated materializations
       - Redundant transformations
       - Models that can be archived
    
    4. OPTIMIZATION ROADMAP
       - Week-by-week action plan for next month
       - Quick wins vs strategic improvements
       - Estimated time savings and effort required
       - Projected end-of-month performance
    
    5. ROI CALCULATIONS
       - Current monthly compute cost
       - Potential savings from recommendations
       - Effort/impact ratio for each item

    January 2026 Audit Output:

    dbt Project Health Score: 73/100 (Up from 61 last month)
    
    PERFORMANCE SUMMARY:
    - Total models: 147
    - Average model runtime: 3.2 min (down from 5.1 min)
    - Slowest model: dim_customer_360 (14.2 min)
    - Models using incremental: 67% (target: 80%)
    - Models with clustering: 45% (target: 70%)
    
    TOP 10 ISSUES:
    1. dim_customer_360 (14.2 min) - needs incremental + clustering
    2. mart_sales_forecast (12.8 min) - complex window functions, consider simplification
    3. fct_website_sessions (11.4 min) - full refresh daily, should be incremental
    ...
    
    OPTIMIZATION ROADMAP - FEBRUARY 2026:
    Week 1: Add clustering to 8 identified models (est. save 45 min/run)
    Week 2: Convert 6 models to incremental (est. save 67 min/run)
    Week 3: Refactor mart_sales_forecast (est. save 8 min/run)
    Week 4: Remove 4 unused models identified
    
    Projected end-of-month runtime: 58 minutes (current: 83 minutes)

    Following this roadmap, I hit 61 minutes by month-end.


    4.4: My Daily Workflow with Cortex Code

    Here’s how Cortex Code fits into my actual workday:

    Monday Morning (9:00 AM) – Weekly Review:

    1. Upload latest manifest.json and run_results.json
    2. Run performance audit
    3. Create Jira tickets for top 3 optimization opportunities
    4. Prioritize for the week

    Tuesday-Thursday – Development:

    1. Need a new model?
      • Ask Cortex Code to generate boilerplate
      • Review and customize for business logic
      • Ask Cortex to optimize before first run
    2. Model running slow?
      • Share compiled SQL with Cortex
      • Get optimization suggestions
      • Implement and test
    Weekly data engineering workflow integrating Snowflake Cortex Code for dbt optimization and development

    Friday Afternoon – Cleanup:

    1. Review week’s changes in dbt
    2. Ask Cortex to review my new models for anti-patterns
    3. Generate documentation with Cortex assistance
    4. Prepare for Monday’s review

    Time saved per week:

    • Before: 8-10 hours on performance debugging
    • After: 1-2 hours on Cortex-assisted optimization
    • Net savings: 6-8 hours weekly

    4.5: Prompts That Actually Work

    Here are my most-used prompts, copy-paste ready:

    Performance Analysis:

    "Analyze this manifest.json and identify the top 10 slowest models with specific, actionable optimization recommendations ranked by estimated time savings."
    "Compare these two run_results.json files (last week vs this week) and identify performance regressions, improvements, and new bottlenecks. Prioritize by impact."
    "This model runs in X minutes. Here's the compiled SQL: [paste]. Provide optimization suggestions with estimated impact for each."

    Model Optimization:

    "Review this dbt model and suggest: 1) Best materialization strategy, 2) Clustering keys, 3) Incremental logic if applicable, 4) Query optimizations. Model: [paste]"
    "I'm building a new model for [business purpose]. Suggest optimal dbt structure including staging, intermediate, and mart layers with proper materializations."

    Debugging:

    "This dbt model suddenly got slow. Root cause analysis based on: Compiled SQL: [paste], Recent changes: [describe], Data volume changes: [numbers]"
    "Why is this incremental model doing full refreshes? Model config: [paste], Logs: [paste]"

    Ongoing Monitoring:

    "Monthly dbt health audit. Analyze manifest + run_results. Provide: health score, top 10 issues, optimization roadmap. Files: [paste]"
    "Identify unused or rarely-run models in this manifest that could be archived. Criteria: run less than once per week, not referenced by marts."

    4.6: What Works vs. What Doesn’t

    After 3 months of daily use, here’s my honest assessment:

    What Works Exceptionally Well (9-10/10):

    Manifest.json analysis – Unbelievably accurate

    • Finds bottlenecks I’d never spot manually
    • Prioritizes by actual impact
    • Estimates are within 20% of reality
    Visual comparison of Snowflake Cortex Code strengths and limitations for dbt optimization

    Performance regression detection – Catches issues immediately

    • Week-over-week comparisons are spot-on
    • Identifies root causes correctly 90% of the time

    Clustering key recommendations – Based on real query patterns

    • Suggestions almost always improve performance
    • Understands join patterns and filter predicates

    Materialization strategy advice – Knows when to use incremental vs table

    • Factors in data volume, update frequency, query patterns

    Boilerplate generation – Saves tons of typing

    • Staging models, tests, yml files
    • Follows dbt best practices

    What’s Good But Needs Review (7-8/10):

    ⚠️ Macro generation – Often correct but review logic carefully

    • Sometimes over-complicates simple macros
    • Jinja syntax is usually right, logic sometimes questionable

    ⚠️ Incremental logic – Usually good starting point

    • Test thoroughly before production
    • Edge cases might not be covered
    • Deduplication logic needs validation

    ⚠️ Complex transformations – Can over-engineer

    • Tend to add unnecessary CTEs
    • Sometimes creates cleverness over clarity

    What Doesn’t Work Well (4-6/10):

    Understanding specific business context – It’s AI, not a domain expert

    • Doesn’t know your business rules
    • Can’t infer data quality requirements
    • Might suggest technically sound but business-wrong logic

    Data distribution insights – Can’t see actual data

    • Clustering suggestions are pattern-based, not data-based
    • Doesn’t know your data skew or cardinality

    Cost optimization – Focuses on time, not cost

    • Doesn’t factor in warehouse sizing
    • Might suggest compute-expensive solutions

    Complex dependencies – Struggles with very large DAGs

    • Can get confused with 200+ model projects
    • Recommendations might create circular dependencies

    Critical: What You Must Validate:

    🔴 Always manually verify:

    1. Incremental logic (especially deduplication)
    2. Business logic in transformations
    3. Data quality test logic
    4. Macro behavior with edge cases
    5. Performance impact in production (not just estimated)

    4.7: Real Numbers from My Experience

    Let me share the actual metrics that matter:

    Before Cortex Code (December 2025):

    dbt Performance:

    • Full refresh runtime: 2h 47min
    • Incremental runtime: 1h 15min
    • Models with clustering: 12/147 (8%)
    • Models using incremental: 42/147 (29%)
    • Airflow timeout failures: 2-3/week

    My Time Spent:

    • Performance debugging: 8-10 hours/week
    • Manual manifest review: Never (too tedious)
    • Optimization work: Ad-hoc, reactive
    • New model development: 45-60 min per model

    Costs:

    • Snowflake compute (dbt): ~$1,200/month
    • Airflow retries/failures: ~$180/month
    • My time opportunity cost: Unmeasured but significant

    After 3 Months with Cortex Code (March 2026):

    dbt Performance:

    • Full refresh runtime: 1h 23min (-50%)
    • Incremental runtime: 34min (-55%)
    • Models with clustering: 67/147 (46%)
    • Models using incremental: 99/147 (67%)
    • Airflow timeout failures: 1-2/month

    My Time Spent:

    • Performance debugging: 1-2 hours/week (-85%)
    • Weekly manifest review: 15 min (automated with Cortex)
    • Optimization work: Systematic, proactive
    • New model development: 15-20 min per model (-67%)

    Costs:

    • Snowflake compute (dbt): ~$680/month (-43%)
    • Airflow retries/failures: ~$35/month (-81%)
    • My time regained: 6-8 hours/week for feature development

    ROI Calculation:

    Cost of Cortex Code: Included in Snowflake Enterprise+ (no additional cost)

    Monthly savings:

    • Snowflake compute: $520
    • Airflow compute: $145
    • My time (40 hours/month @ $75/hour): $3,000
    • Total: $3,665/month

    Annual ROI: $43,980 in quantifiable savings

    Plus unquantifiable benefits:

    • Faster feature delivery
    • Better data quality (more time for testing)
    • Reduced incidents and pages
    • Improved team morale (less firefighting)

    Part 5: Quick Wins – Other Tools

    While dbt is where I get the most value, Cortex Code is useful for other data engineering tools too:

    5.1: Airflow DAGs

    Quick generation using Cortex Code interface:

    Generate a production-ready Airflow DAG for my daily dbt run:
    
    Requirements:
    - Schedule: Run daily at 2 AM UTC
    - Retry logic: 3 attempts with 5-minute delay between retries
    - Send Slack alert on failure (webhook already configured)
    - Trigger downstream Tableau refresh on success
    - Include SLA monitoring (alert if takes >90 minutes)
    - Use dbt Cloud operator
    - Handle task dependencies properly
    
    Python version: 3.9
    Airflow version: 2.7

    Cortex Code generates:

    from airflow import DAG
    from airflow.operators.bash import BashOperator
    from airflow.operators.python import PythonOperator
    from airflow.providers.slack.notifications.slack import send_slack_notification
    from datetime import datetime, timedelta
    
    default_args = {
        'owner': 'data-engineering',
        'depends_on_past': False,
        'email_on_failure': False,
        'retries': 3,
        'retry_delay': timedelta(minutes=5),
        'sla': timedelta(minutes=90),
    }
    
    dag = DAG(
        'daily_dbt_run',
        default_args=default_args,
        description='Daily dbt production run',
        schedule='0 2 * * *',  # 2 AM UTC daily
        start_date=datetime(2026, 1, 1),
        catchup=False,
        on_failure_callback=send_slack_notification(
            text='❌ dbt DAG failed!',
            channel='#data-alerts'
        ),
    )
    
    dbt_run = BashOperator(
        task_id='dbt_run',
        bash_command='cd /opt/dbt && dbt run --profiles-dir .',
        dag=dag,
    )
    
    dbt_test = BashOperator(
        task_id='dbt_test',  
        bash_command='cd /opt/dbt && dbt test --profiles-dir .',
        dag=dag,
    )
    
    refresh_tableau = BashOperator(
        task_id='refresh_tableau',
        bash_command='curl -X POST https://tableau-server/api/refresh',
        dag=dag,
    )
    
    dbt_run >> dbt_test >> refresh_tableau

    Gets you 80% of the way there in 30 seconds vs. 30 minutes of boilerplate typing.

    5.2: Streamlit Dashboards

    Dashboard scaffolding with Cortex Code:

    Create a Streamlit dashboard for sales analytics:
    
    Features needed:
    - Date range filter (last 7, 30, 90 days or custom)
    - KPI cards showing: Total Revenue, Total Orders, Average Order Value
    - Line chart: Daily revenue trend over selected period
    - Bar chart: Top 10 products by revenue
    - Data table: Recent orders with filters
    - Connect to Snowflake table: analytics.mart_daily_sales
    - Use Streamlit's modern layout with columns
    - Include data refresh timestamp
    - Add export to CSV button
    
    Make it production-ready with proper error handling.

    Cortex Code generates:

    import streamlit as st
    import snowflake.connector
    import pandas as pd
    import plotly.express as px
    from datetime import datetime, timedelta
    
    st.set_page_config(page_title="Sales Analytics", layout="wide")
    
    # Snowflake connection
    @st.cache_resource
    def get_connection():
        return snowflake.connector.connect(
            user=st.secrets["snowflake"]["user"],
            password=st.secrets["snowflake"]["password"],
            account=st.secrets["snowflake"]["account"],
            warehouse='ANALYTICS_WH',
            database='ANALYTICS',
            schema='MART'
        )
    
    # Main dashboard
    st.title("📊 Sales Analytics Dashboard")
    
    # Date filter
    col1, col2 = st.columns([3, 1])
    with col1:
        date_range = st.selectbox(
            "Select Period",
            ["Last 7 Days", "Last 30 Days", "Last 90 Days", "Custom"]
        )
    
    # ... [rest of dashboard code]

    Generates working dashboard layout. Still need to refine styling and business logic, but saves 45 minutes of setup.

    (Keeping these brief since the focus is dbt – but both are genuinely useful)


    Part 6: Practical Tips for Data Engineers

    The Learning Curve

    Week 1: Feels magical but you don’t trust it

    • Generate code, read every line carefully
    • Validate everything in dev
    • Probably slower than writing manually

    Week 2-4: Building confidence

    • Start recognizing patterns in good vs. questionable output
    • Develop your own prompt templates
    • 20-30% faster than before

    Month 2+: It’s part of your workflow

    • Know when to use it vs. when to write manually
    • Can spot hallucinations immediately
    • 50-70% faster on routine tasks

    My Validation Checklist

    Before deploying Cortex-generated code:

    ✅ Logic review: Does this make business sense?
    ✅ Performance check: Run EXPLAIN on generated SQL
    ✅ Edge cases: Test with null values, duplicates, empty sets
    ✅ Incremental logic: Validate deduplication and update logic
    ✅ Dependencies: Check for circular references
    ✅ Tests: Generated code needs generated tests
    ✅ Peer review: Treat AI code like any other PR

    When I Don’t Use Cortex Code

    Never use for:

    • Financial calculations (too critical, audit requirements)
    • Security/access control logic (review manually)
    • One-off analyses (faster to write myself)
    • Learning new concepts (defeats the learning purpose)

    Sometimes use for:

    • Debugging (helpful but verify root cause)
    • Refactoring (good starting point, heavy review)
    • Documentation (generates good drafts)

    Always use for:

    • Boilerplate (staging models, tests, yml)
    • Performance analysis (manifest reviews)
    • Exploration (trying new patterns)

    Part 7: The Honest Verdict

    For dbt Specifically:

    Model Generation: 8/10

    • Great for standard patterns
    • Saves typing, enforces conventions
    • Still need to add business logic

    Test Creation: 9/10

    • Covers standard tests well
    • Good at identifying what to test
    • Custom tests need review

    Manifest Analysis: 10/10 ⭐⭐⭐

    • This alone justifies using Cortex Code
    • Finds issues I’d never spot manually
    • Actionable, prioritized recommendations

    Performance Optimization: 9/10

    • Suggestions are usually right
    • Massive time savings
    • Estimates are reasonably accurate

    Macro Writing: 7/10

    • Good starting point
    • Logic sometimes over-complicated
    • Requires Jinja knowledge to review properly

    Documentation: 8/10

    • Generates good yml drafts
    • Descriptions are generic but fixable
    • Saves tons of tedious typing

    Overall Assessment:

    Is Cortex Code worth it for data engineers?

    Absolutely yes, with caveats:

    Use it if you:

    • Work with dbt daily
    • Have performance challenges
    • Want to spend less time on boilerplate
    • Value systematic optimization over guesswork
    • Are comfortable reviewing and validating AI output

    ⚠️ Be cautious if you:

    • Are still learning dbt (use it, but understand what it generates)
    • Have highly specialized/unusual patterns
    • Work in heavily regulated industry (extra validation needed)
    • Have very small dbt projects (<20 models – manual is fine)

    Skip it if you:

    • Don’t have Snowflake Enterprise+
    • Rarely write dbt code
    • Prefer full manual control (totally valid!)

    The Real Value Proposition

    It’s not about writing code faster (though that’s nice).

    It’s about:

    1. Systematic performance optimization instead of guesswork
    2. Proactive monitoring instead of reactive firefighting
    3. Data-driven decisions about what to optimize
    4. Consistent code quality through enforced best practices
    5. More time for high-value work instead of debugging

    My Recommendation

    Start small:

    1. Week 1: Try manifest analysis only
    2. Week 2: Generate a few staging models
    3. Week 3: Use for performance debugging
    4. Week 4: Incorporate into daily workflow

    By month 2, you’ll wonder how you lived without it.


    Conclusion: The Tool That Changed My Workflow

    Three months ago, I was drowning in performance issues, spending my days debugging slow dbt models and my nights fixing Airflow timeouts.

    Today, my dbt runs 48% faster, I spend 85% less time on performance debugging, and I actually have time to build new features instead of constantly firefighting.

    Cortex Code didn’t just make me faster—it made me smarter about optimization. The manifest analysis taught me patterns I now recognize manually. The performance suggestions showed me best practices I’d never considered.

    Is it perfect? No. Does it replace data engineering expertise? Definitely not. But used correctly, with proper validation and critical thinking, it’s become as essential to my workflow as dbt itself.

    If you’re a data engineer using Snowflake and dbt, try the manifest analysis feature today. Upload your manifest.json, ask for performance recommendations, and see what it finds. I bet you’ll be shocked—I was.

    And if you do try it, let me know what you discover. I’m always curious what performance wins other engineers are finding.

    Now go optimize something. Your Airflow DAG will thank you.


    Additional Resources

    Snowflake Documentation:


    FAQ

    Q: Does Cortex Code work with dbt Cloud or just dbt Core? A: Works with both! It analyzes manifest.json regardless of how dbt runs.

    Q: How much does Cortex Code cost? A: Included with Snowflake Enterprise Edition and higher. No additional charge.

    Q: Can it analyze very large dbt projects (500+ models)? A: Yes, though response time increases. I’ve tested up to 300 models successfully.

    Q: Does it send my code/data to external APIs? A: No. Cortex Code runs entirely within Snowflake’s environment.

    Q: How often should I run performance audits? A: I do weekly quick checks, monthly comprehensive audits.

  • Snowflake Native dbt Integration: Complete 2025 Guide

    Snowflake Native dbt Integration: Complete 2025 Guide

    Run dbt Core Directly in Snowflake Without Infrastructure

    Snowflake native dbt integration announced at Summit 2025 eliminates the need for separate containers or VMs to run dbt Core. Data teams can now execute dbt transformations directly within Snowflake, with built-in lineage tracking, logging, and job scheduling through Snowsight. This breakthrough simplifies data pipeline architecture and reduces operational overhead significantly.

    For years, running dbt meant managing separate infrastructure—deploying containers, configuring CI/CD pipelines, and maintaining compute resources outside your data warehouse. The Snowflake native dbt integration changes everything by bringing dbt Core execution inside Snowflake’s secure environment.


    What Is Snowflake Native dbt Integration?

    Snowflake native dbt integration allows data teams to run dbt Core transformations directly within Snowflake without external orchestration tools. The integration provides a managed environment where dbt projects execute using Snowflake’s compute resources, with full visibility through Snowsight.

    Key Benefits

    The native integration delivers:

    • Zero infrastructure management – No containers, VMs, or separate compute
    • Built-in lineage tracking – Automatic data flow visualization
    • Native job scheduling – Schedule dbt runs using Snowflake Tasks
    • Integrated logging – Debug pipelines directly in Snowsight
    • No licensing costs – dbt Core runs free within Snowflake

    Organizations using Snowflake Dynamic Tables can now complement those automated refreshes with sophisticated dbt transformations, creating comprehensive data pipeline solutions entirely within the Snowflake ecosystem.


    How Native dbt Integration Works

    Execution Architecture

    When you deploy a dbt project to Snowflake native dbt integration, the platform:

    1. Stores project files in Snowflake’s internal stage
    2. Compiles dbt models using Snowflake’s compute
    3. Executes SQL transformations against your data
    4. Captures lineage automatically for all dependencies
    5. Logs results to Snowsight for debugging

    Similar to how real-time data pipeline architectures require proper orchestration, dbt projects benefit from Snowflake’s native task scheduling and dependency management.

    -- Create a dbt job in Snowflake
    CREATE OR REPLACE TASK run_dbt_models
      WAREHOUSE = transform_wh
      SCHEDULE = 'USING CRON 0 2 * * * America/Los_Angeles'
    AS
      CALL DBT.RUN_DBT_PROJECT('my_analytics_project');
    
    -- Enable the task
    ALTER TASK run_dbt_models RESUME;

    Setting Up Native dbt Integration

    Prerequisites

    Before deploying dbt projects natively:

    • Snowflake account with ACCOUNTADMIN or appropriate role
    • Existing dbt project with proper structure
    • Git repository containing dbt code (optional but recommended)
    A flowchart showing dbt Project Files leading to Snowflake Stage, then dbt Core Execution, Data Transformation, and finally Output Tables, with SQL noted below dbt Core Execution.

    Step-by-Step Implementation

    1: Prepare Your dbt Project

    Ensure your project follows standard dbt structure:

    my_dbt_project/
    ├── models/
    ├── macros/
    ├── tests/
    ├── dbt_project.yml
    └── profiles.yml

    2: Upload to Snowflake

    -- Create stage for dbt files
    CREATE STAGE dbt_projects
      DIRECTORY = (ENABLE = true);
    
    -- Upload project files
    PUT file://my_dbt_project/* @dbt_projects/my_project/;

    3: Configure Execution

    -- Set up dbt execution environment
    CREATE OR REPLACE PROCEDURE run_my_dbt()
      RETURNS STRING
      LANGUAGE PYTHON
      RUNTIME_VERSION = 3.8
      PACKAGES = ('dbt-core', 'dbt-snowflake')
      HANDLER = 'run_dbt'
    AS
    $$
    def run_dbt(session):
        import dbt.main
        results = dbt.main.run(['run'])
        return f"dbt run completed with {results} models"
    $$;

    4: Schedule with Tasks

    Link dbt execution to data quality validation processes by scheduling regular runs:

    CREATE TASK daily_dbt_refresh
      WAREHOUSE = analytics_wh
      SCHEDULE = 'USING CRON 0 3 * * * UTC'
    AS
      CALL run_my_dbt();

    Lineage and Observability

    Built-in Lineage Tracking

    Snowflake native dbt integration automatically captures data lineage across:

    • Source tables referenced in models
    • Intermediate transformation layers
    • Final output tables and views
    • Test dependencies and validations

    Access lineage through Snowsight’s graphical interface, similar to monitoring API integration workflows in modern data architectures.

    Debugging Capabilities

    The platform provides:

    • Real-time execution logs showing compilation and run details
    • Error stack traces pointing to specific model failures
    • Performance metrics for each transformation step
    • Query history for all generated SQL

    Best Practices for Native dbt

    Optimize Warehouse Sizing

    Match warehouse sizes to transformation complexity:

    -- Small warehouse for lightweight models
    CREATE WAREHOUSE dbt_small_wh
      WAREHOUSE_SIZE = 'SMALL'
      AUTO_SUSPEND = 60
      AUTO_RESUME = TRUE;
    
    -- Large warehouse for heavy aggregations
    CREATE WAREHOUSE dbt_large_wh
      WAREHOUSE_SIZE = 'LARGE'
      AUTO_SUSPEND = 60;

    Implement Incremental Strategies

    Leverage dbt’s incremental models for efficiency:

    -- models/incremental_sales.sql
    {{ config(
        materialized='incremental',
        unique_key='sale_id'
    ) }}
    
    SELECT *
    FROM {{ source('raw', 'sales') }}
    {% if is_incremental() %}
    WHERE sale_date > (SELECT MAX(sale_date) FROM {{ this }})
    {% endif %}

    Use Snowflake-Specific Features

    Take advantage of native capabilities when using machine learning integrations or advanced analytics:

    -- Use Snowflake clustering for large tables
    {{ config(
        materialized='table',
        cluster_by=['sale_date', 'region']
    ) }}

    Migration from External dbt

    Moving from dbt Cloud

    Organizations migrating from dbt Cloud to Snowflake native dbt integration should:

    1. Export existing projects from dbt Cloud repositories
    2. Review connection profiles and update for Snowflake native execution
    3. Migrate schedules to Snowflake Tasks
    4. Update CI/CD pipelines to trigger native execution
    5. Train teams on Snowsight-based monitoring

    Moving from Self-Hosted dbt

    Teams running dbt in containers or VMs benefit from:

    • Eliminated infrastructure costs (no more EC2 instances or containers)
    • Reduced maintenance burden (Snowflake manages runtime)
    • Improved security (execution stays within Snowflake perimeter)
    • Better integration with Snowflake features

    Cost Considerations

    Compute Consumption

    Snowflake native dbt integration uses standard warehouse compute:

    • Charged per second of active execution
    • Auto-suspend reduces idle costs
    • Share warehouses across multiple jobs for efficiency

    Comparison with External Solutions

    Aspect External dbt Native dbt Integration
    Infrastructure EC2/VM costs Only Snowflake compute
    Maintenance Manual updates Managed by Snowflake
    Licensing dbt Cloud fees Free (dbt Core)
    Integration External APIs Native Snowflake

    Organizations using automation strategies across their data stack can consolidate tools and reduce total cost of ownership.

    Real-World Use Cases

    Use Case 1: Financial Services Reporting

    A fintech company moved 200+ dbt models from AWS containers to Snowflake native dbt integration, achieving:

    • 60% reduction in infrastructure costs
    • 40% faster transformation execution
    • Zero downtime migrations using blue-green deployment

    Use Case 2: E-commerce Analytics

    An online retailer consolidated their data pipeline by combining native dbt with Dynamic Tables:

    • dbt handles complex business logic transformations
    • Dynamic Tables maintain real-time aggregations
    • Both execute entirely within Snowflake

    Use Case 3: Healthcare Data Warehousing

    A healthcare provider simplified compliance by keeping all transformations inside Snowflake’s secure perimeter:

    • HIPAA compliance maintained without data egress
    • Audit logs automatically captured
    • PHI never leaves Snowflake environment

    Advanced Features

    Git Integration

    Connect dbt projects directly to repositories:

    CREATE GIT REPOSITORY dbt_repo
      ORIGIN = 'https://github.com/myorg/dbt-project.git'
      API_INTEGRATION = github_integration;
    
    -- Run dbt from specific branch
    CALL run_dbt_from_git('dbt_repo', 'production');

    Testing and Validation

    Native integration supports full dbt testing:

    • Schema tests validate data structure
    • Data tests check business rules
    • Custom tests enforce specific requirements

    Multi-Environment Support

    Manage dev, staging, and production through Snowflake databases:

    sql

    -- Development environment
    USE DATABASE dev_analytics;
    CALL run_dbt('dev_project');
    
    -- Production environment
    USE DATABASE prod_analytics;
    CALL run_dbt('prod_project');

    Troubleshooting Common Issues

    Issue 1: Slow Model Compilation

    Solution: Pre-compile dbt projects and cache results:

    sql

    -- Cache compiled SQL for faster execution
    ALTER TASK dbt_refresh SET
      SUSPEND_TASK_AFTER_NUM_FAILURES = 3;

    Issue 2: Dependency Conflicts

    Solution: Use Snowflake’s Python environment isolation:

    sql

    -- Specify exact package versions
    PACKAGES = ('dbt-core==1.7.0', 'dbt-snowflake==1.7.0')

    Future Roadmap

    Snowflake plans to enhance native dbt integration with:

    • Visual dbt model builder for low-code transformations
    • Automatic optimization suggestions using AI
    • Enhanced collaboration features for team workflows
    • Deeper integration with Snowflake’s AI capabilities

    Organizations exploring autonomous AI agents in other platforms will find similar intelligence coming to dbt optimization.

    Conclusion: Simplified Data Transformation

    Snowflake native dbt integration represents a significant evolution in data transformation architecture. By eliminating external infrastructure and bringing dbt Core inside Snowflake, data teams achieve simplified operations, reduced costs, and enhanced security.

    The integration is production-ready today, with thousands of organizations already migrating their dbt workloads. Teams should evaluate their current dbt architecture and plan migrations to take advantage of this native capability.

    Start with non-critical projects, validate performance, and progressively move production workloads. The combination of zero infrastructure overhead, built-in observability, and seamless Snowflake integration makes native dbt integration the future of transformation pipelines.


    🔗 External Resources

    1. Official Snowflake dbt Integration Documentation
    2. Snowflake Summit 2025 dbt Announcement
    3. dbt Core Best Practices Guide
    4. Snowflake Tasks Scheduling Reference
    5. dbt Incremental Models Documentation
    6. Snowflake Python UDF Documentation

  • Structuring dbt Projects in Snowflake: The Definitive Guide

    Structuring dbt Projects in Snowflake: The Definitive Guide

    If you’ve ever inherited a dbt project, you know there are two kinds: the clean, logical, and easy-to-navigate project, and the other kind—a tangled mess of models that makes you question every life choice that led you to that moment. The difference between the two isn’t talent; it’s structure. For high-performing data teams, a well-defined structure for dbt projects in Snowflake isn’t just a nice-to-have, it’s the very foundation of a scalable, maintainable, and trustworthy analytics workflow.

    While dbt and Snowflake are a technical match made in heaven, simply putting them together doesn’t guarantee success. Without a clear and consistent project structure, even the most powerful tools can lead to chaos. Dependencies become circular, model names become ambiguous, and new team members spend weeks just trying to understand the data flow.

    This guide provides a battle-tested blueprint for structuring dbt projects in Snowflake. We’ll move beyond the basics and dive into a scalable, multi-layered framework that will save you and your team countless hours of rework and debugging.

    Why dbt and Snowflake Are a Perfect Match

    Before we dive into project structure, it’s crucial to understand why this combination has become the gold standard for the modern data stack. Their synergy comes from a shared philosophy of decoupling, scalability, and performance.

    • Snowflake’s Decoupled Architecture: Its separation of storage and compute is revolutionary. This means you can run massive dbt transformations using a dedicated, powerful virtual warehouse without slowing down your BI tools.
    • dbt’s Transformation Power: dbt focuses on the “T” in ELT—transformation. It allows you to build, test, and document your data models using simple SQL, which it then compiles and runs directly inside Snowflake’s powerful engine.
    • Cost and Performance Synergy: Running dbt models in Snowflake is incredibly efficient. You can spin up a warehouse for a dbt run and spin it down the second it’s finished, meaning you only pay for the exact compute you use.
    • Zero-Copy Cloning for Development: Instantly create a zero-copy clone of your entire production database for development. This allows you to test your dbt project against production-scale data without incurring storage costs or impacting the production environment.

    In short, Snowflake provides the powerful, elastic engine, while dbt provides the organized, version-controlled, and testable framework to harness that engine.

    The Layered Approach: From Raw Data to Actionable Insights

    A scalable dbt project is like a well-organized factory. Raw materials come in one end, go through a series of refined production stages, and emerge as a finished product. We achieve this by structuring our models into distinct layers, each with a specific job.

    Our structure will follow this flow: Sources -> Staging -> Intermediate -> Marts.

    Layer 1: Declaring Your Sources (The Contract with Raw Data)

    Before you write a single line of transformation SQL, you must tell dbt where your raw data lives in Snowflake. This is done in a .yml file. Think of this file as a formal contract that declares your raw tables, allows you to add data quality tests, and serves as a foundation for your data lineage graph.

    Example: models/staging/sources.yml

    Let’s assume we have a RAW_DATA database in Snowflake with schemas from a jaffle_shop and stripe.

    YAML

    version: 2
    
    sources:
      - name: jaffle_shop
        database: raw_data 
        schema: jaffle_shop
        description: "Raw data from the primary application database."
        tables:
          - name: customers
            columns:
              - name: id
                tests:
                  - unique
                  - not_null
          - name: orders
            loaded_at_field: _etl_loaded_at
            freshness:
              warn_after: {count: 12, period: hour}
    
      - name: stripe
        database: raw_data
        schema: stripe
        tables:
          - name: payment
            columns:
              - name: orderid
                tests:
                  - relationships:
                      to: source('jaffle_shop', 'orders')
                      field: id
    

    Layer 2: Staging Models (Clean and Standardize)

    Staging models are the first line of transformation. They should have a 1:1 relationship with your source tables. The goal here is strict and simple:

    • DO: Rename columns, cast data types, and perform very light cleaning.
    • DO NOT: Join to other tables.

    This creates a clean, standardized version of each source table, forming a reliable foundation for the rest of your project.

    Example: models/staging/stg_customers.sql

    SQL

    -- models/staging/stg_customers.sql
    with source as (
        select * from {{ source('jaffle_shop', 'customers') }}
    ),
    
    renamed as (
        select
            id as customer_id,
            first_name,
            last_name
        from source
    )
    
    select * from renamed
    

    Layer 3: Intermediate Models (Build, Join, and Aggregate)

    This is where the real business logic begins. Intermediate models are the “workhorses” of your dbt project. They take the clean data from your staging models and start combining them.

    • DO: Join different staging models together.
    • DO: Perform complex calculations, aggregations, and business-specific logic.
    • Materialize them as tables if they are slow to run or used by many downstream models.

    These models are not typically exposed to business users. They are building blocks for your final data marts.

    Example: models/intermediate/int_orders_with_payments.sql

    SQL

    -- models/intermediate/int_orders_with_payments.sql
    with orders as (
        select * from {{ ref('stg_orders') }}
    ),
    
    payments as (
        select * from {{ ref('stg_payments') }}
    ),
    
    order_payments as (
        select
            order_id,
            sum(case when payment_status = 'success' then amount else 0 end) as total_amount
        from payments
        group by 1
    ),
    
    final as (
        select
            orders.order_id,
            orders.customer_id,
            orders.order_date,
            coalesce(order_payments.total_amount, 0) as amount
        from orders
        left join order_payments 
          on orders.order_id = order_payments.order_id
    )
    
    select * from final
    

    Layer 4: Data Marts (Ready for Analysis)

    Finally, we arrive at the data marts. These are the polished, final models that power your dashboards, reports, and analytics. They should be clean, easy to understand, and built for a specific business purpose (e.g., finance, marketing, product).

    • DO: Join intermediate models.
    • DO: Have clear, business-friendly column names.
    • DO NOT: Contain complex, nested logic. All the heavy lifting should have been done in the intermediate layer.

    These models are the “products” of your data factory, ready for consumption by BI tools like Tableau, Looker, or Power BI.

    Example: models/marts/fct_customer_orders.sql

    SQL

    -- models/marts/fct_customer_orders.sql
    with customers as (
        select * from {{ ref('stg_customers') }}
    ),
    
    orders as (
        select * from {{ ref('int_orders_with_payments') }}
    ),
    
    customer_orders as (
        select
            customers.customer_id,
            min(orders.order_date) as first_order_date,
            max(orders.order_date) as most_recent_order_date,
            count(orders.order_id) as number_of_orders,
            sum(orders.amount) as lifetime_value
        from customers
        left join orders 
          on customers.customer_id = orders.customer_id
        group by 1
    )
    
    select * from customer_orders
    

    Conclusion: Structure is Freedom

    By adopting a layered approach to your dbt projects in Snowflake, you move from a chaotic, hard-to-maintain process to a scalable, modular, and efficient analytics factory. This structure gives you:

    • Maintainability: When logic needs to change, you know exactly which model to edit.
    • Scalability: Onboarding new data sources or team members becomes a clear, repeatable process.
    • Trust: With testing at every layer, you build confidence in your data and empower the entire organization to make better, faster decisions.

    This framework isn’t just about writing cleaner code—it’s about building a foundation for a mature and reliable data culture.