Tag: dbt

  • Optimizing dbt Models for Modern Warehouses: An Author & Reviewer’s Guide

    Optimizing dbt Models for Modern Warehouses: An Author & Reviewer’s Guide

    Two dbt models can be byte-for-byte identical in output, pass every test, and read cleanly in review — and one of them costs $50 a month while the other costs $5,000. The compiler already guarantees the SQL is correct. What it can’t tell you is how much data moved through the model to produce that correct answer, and that number is the entire ballgame. The trap is that a code review reads a model like prose — do the joins make sense, are the columns right — when the thing that actually determines the bill is invisible in the text and only shows up in the query profile.

    So this guide is about reading the profile, not just the SQL. The single skill that separates engineers who write cheap models from ones who write expensive ones is the ability to look at a query profile and see where data volume balloons or lingers — then trace that back to the one line of SQL responsible. Everything below hangs off one mental model, and I’ll show you what each fix looks like in an actual profile, because “trust me, it’s faster” is worth a lot less than “here’s the partition count before and after.”

    The one mental model: how much data survives each stage?

    Before any checklist, ask one question of every model: how much data survives each stage of this query? A healthy model’s volume shrinks, roughly monotonically, from raw input to final output. A broken one has a stage where volume grows — or stays huge longer than it needs to — and that stage is almost always where your money goes.

    Left: volume shrinks stage by stage — a well-shaped query. Right: the join ran before the filter, producing 12 TB of intermediate data from a 5 TB input. The SQL is syntactically perfect and still ruinous.

    Every tactic in this post is a specific instance of that one idea: find the stage where volume grows or stays too big for too long, and fix that stage. That’s it. The rest is knowing the four places it usually happens and what each looks like in the profile.

    Why this matters more on modern warehouses

    Snowflake, BigQuery, Redshift, and Databricks all ship genuinely capable optimizers. They handle predicate pushdown, join reordering, and parallel execution for you, which means the physical tuning you’d have obsessed over on a 2005-era database — index hints, manual join order, rewriting for a specific plan — mostly doesn’t apply. The engine owns that layer now. If you want the mechanics of how that execution layer actually works, I covered it in what really happens when you run a query.

    What the optimizer can’t fix is a logical mistake: joining before filtering, reading columns you don’t need, recomputing the same aggregation five times, or choosing ROW_NUMBER() when MAX() would do. Those decisions are baked into the SQL, and no optimizer can rewrite your intent. That’s why the highest-leverage review comments are almost never about syntax — they’re about which stage of the volume curve a change affects.

    1. Read less data

    The biggest lever, and the first thing to check. On a columnar warehouse, unused columns cost real I/O even though the query works either way:

    -- Bad: pulls every column off a 200-column table
    SELECT * FROM customers
    
    -- Better: only what's used downstream
    SELECT customer_id, country FROM customers

    The one that quietly defeats people is partition pruning, because the query looks filtered but is structured so the engine can’t use the filter. Wrapping the filtered column in a function is the classic killer:

    -- Bad: the function hides order_date from the optimizer
    WHERE YEAR(order_date) = 2026
    
    -- Good: a plain range predicate the engine can prune on
    WHERE order_date >= '2026-01-01'
      AND order_date <  '2027-01-01'

    This is the single highest-value fix in the whole post, and it’s the one worth seeing rather than taking on faith

    The profile tells the story the SQL hides: the function-wrapped predicate scanned all 512 partitions (1.42 TB, 94s); the range predicate pruned to 3 partitions (9 GB, 3.4s). Same result, same rows out — a ~150x difference in data read.

    When a filter or join predicate isn’t reducing data the way you’d expect, check for a hidden function first — CAST(date AS DATE)UPPER(email)COALESCE(col, 0) all do the same damage. This is exactly why Snowflake maintains min/max metadata per micro-partition, and why a function over the column throws that metadata away; I unpacked that storage mechanism in how Snowflake stores data internally.

    2. Join wisely

    Joins are where a well-behaved query most often turns into a runaway one. The single question worth asking on every join in a review: is this actually the cardinality I think it is? A join you assumed was 1:1 becomes a many-to-many explosion the moment a source table has duplicate keys — 100M orders against 500M clicks on customer_id can produce tens of thousands of rows per customer, and it’s invisible until someone notices the output count is absurd.

    The highest-value structural fix is to aggregate before you join, not after:

    -- Bad: join the full 800M-row payments table, then aggregate
    SELECT o.customer_id, SUM(p.amount)
    FROM orders o
    JOIN payments p ON o.customer_id = p.customer_id
    GROUP BY o.customer_id
    
    -- Better: reduce payments to 10M rows first, then join
    WITH payments_agg AS (
        SELECT customer_id, SUM(amount) AS total_amount
        FROM payments
        GROUP BY customer_id
    )
    SELECT o.customer_id, pa.total_amount
    FROM orders o
    JOIN payments_agg pa ON o.customer_id = pa.customer_id

    Same result — but the join now processes 10M rows instead of 800M, because the reduction happened before the join instead of after. The profile makes the difference impossible to miss — watch the row count going into the join, and the spill:

    Aggregating first shrinks the join’s input from 800M rows to 10M — which also eliminates the disk spill that was quietly dominating the runtime. Same output, ~11x faster.

    Two more join checks worth a glance: watch for skew (one dominant key value — a 90%-US country column, or a flood of NULLs — creates wildly unbalanced work even when the total row count looks fine), and verify every join has a real predicate (a missing condition turns a join into a cartesian product, where row counts don’t grow, they multiply).

    3. Don’t recompute what you already computed

    Is the same large table scanned more than once? If two CTEs both pull from big_table, ask whether one pass can derive both results. Is an expensive expression — a long CASE block, a repeated subquery — computed several times instead of once in a CTE? And watch for SELECT DISTINCT used as a band-aid: it’s very often papering over a join producing duplicate rows it shouldn’t. If a model “suddenly needs” DISTINCT, that’s a prompt to find the join that changed, not to accept the DISTINCT as the fix.

    4. Reduce before expensive operations — and question the tool itself

    Push filters ahead of window functions: running ROW_NUMBER() over 5 billion rows when the same logic could run over 100 million after an earlier filter is a common, easy-to-miss cost. But the most valuable and most overlooked review question isn’t about tuning what’s there — it’s whether the approach itself is right:

    -- Heavier than needed: full partition + sort to get "latest"
    SELECT * FROM (
        SELECT *,
               ROW_NUMBER() OVER (PARTITION BY customer_id
                                  ORDER BY order_date DESC) AS rn
        FROM orders
    ) WHERE rn = 1
    
    -- Often cheaper: if you only need the date, not the whole row
    SELECT customer_id, MAX(order_date) AS latest_order_date
    FROM orders
    GROUP BY customer_id

    If the goal is genuinely “the latest order date per customer,” MAX() with a GROUP BY does far less work than a full partitioned sort. ROW_NUMBER() earns its keep only when you need the entire row at the latest timestamp — a surprising number of “slow query” tickets are really “wrong tool for the job” tickets. In the profile, the tell is the WindowFunction node sorting billions of rows and spilling to disk, when the aggregate version never sorts at all:

    The window function sorts all 5 billion rows and spills 210 GB to disk; MAX() never sorts. Same answer, and it also runs on a smaller warehouse — an ~8x time win on top of a halved credit rate.

    For the reproducible-dedup case where you do need the whole row, the deterministic QUALIFY pattern is the right tool, which I covered in why senior engineers write SQL differently.

    5. Materialization and recomputation — the dbt-specific one

    This is where a lot of warehouse spend hides in plain sight, and it’s two questions. First: is this the right materialization? A very common finding is a model that’s been a plain table since day one, fully recomputed on every run, long after it grew large enough that full rebuilds stopped being free.

    Left: the materialization decision, which is really a “how often does this change vs how expensive is it to rebuild” question. Right: who should catch each class of issue — push everything mechanical left toward the author and CI.

    Second: is there a missed incremental opportunity? If a model recomputes five years of history every run when only yesterday’s data changed, that’s usually the single highest-value fix available — often bigger than every tactic above combined. The question to ask: does this model’s WHERE clause know about is_incremental(), or is it silently doing a full rebuild every time? The profile for a full-rebuild model is unmistakable — it scans the entire history on every run:

    The full-rebuild model reprocesses 3.2 billion rows every single run to change a sliver of data; the incremental version scans one pruned day and merges 1.8M rows. This is where the order-of-magnitude cost wins usually hide.

    The same “don’t reprocess what didn’t change” discipline is the whole premise of dbt state-based selection at the project level.

    Who should catch each of these

    Treating this whole list as “what the reviewer checks” is the wrong default — it makes review slow and contentious. Three owners share it. The author, before opening the MR, catches anything mechanically verifiable by running the query and looking at the output: SELECT *, an unfiltered scan, a repeated CTE. The CI pipeline catches what can be automated: pruning regressions, row-count guards, lint rules. The reviewer is left with what only a human can see — cross-model blast radius (“this join also feeds the finance mart”), organizational memory (“we already know this key is skewed”), and whether the approach fits the business need. The goal over time is to shrink the reviewer’s column: every item that graduates from “reviewer catches it” to “CI catches it” is a permanent win.

    The gotchas nobody warns you about

    A function on a filtered column silently disables pruning. YEAR(order_date)CASTUPPERCOALESCE on the predicate column all throw away the partition metadata. The query looks filtered; the profile shows every partition scanned.

    DISTINCT is usually a symptom, not a fix. If a model started needing SELECT DISTINCT, a join is producing duplicates it shouldn’t. Fix the join; don’t dedupe the mess.

    The profile, not the SQL, tells you the truth. Two models with identical output can differ 100x in bytes scanned. If you’re optimizing without reading the profile, you’re guessing — check partitions scanned and bytes scanned before and after every change.

    Full-rebuild tables are the biggest silent cost. A table materialization recomputing history every run often dwarfs every other inefficiency combined. Check materialization strategy before micro-optimizing the SQL.

    ROW_NUMBER() is frequently the wrong tool. If you only need an aggregate, not the whole row, a GROUP BY is cheaper than a partitioned sort. Confirm the requirement before defaulting to a window function.

    The one principle

    Writing an efficient dbt model isn’t about SQL syntax — the compiler already guarantees correctness — it’s about mentally tracing the volume of data at each stage and asking whether that stage makes the data smaller or just makes more work for the next one. Modern warehouses optimize the physical layer for you; they can’t decide to aggregate before joining or reach for MAX() instead of ROW_NUMBER(). Those are logical choices made in the SQL, and the further upstream you catch them — author self-check, then CI, then reviewer — the cheaper they are. Learn to read the profile, and the order-of-magnitude wins stop being luck.


    Related reading: What really happens when you run a query · Micro-partitions and why pruning works · Why senior engineers write SQL differently · Stop recomputing unchanged models · Snowflake query profile docs

  • How AI Is Reshaping Data Engineering: A Practical Guide (2026)

    How AI Is Reshaping Data Engineering: A Practical Guide (2026)

    A team I advised shipped their first AI-generated pipeline on a Thursday. An agent had scaffolded a dbt model from a plain-English request — “roll daily revenue up to weekly by region” — and the SQL was clean, the run was green, the dashboard populated. It looked like the future. Three weeks later finance flagged that Q3 regional revenue was overstated by about 11%. The model had summed a column that already contained a running total, so every week double-counted the ones before it. No error. No failed test. Just a confidently wrong number that a human skimming the SQL would probably have caught, and that the AI — and the person who accepted its output without reading it — did not.

    That is the actual shape of AI in data engineering in 2026. Not the autonomous, self-healing warehouse from the keynote. A genuinely useful assistant that raises the floor on boilerplate and lowers it, hard, on anything requiring judgment about what the data means. I’ve argued before that the thing quietly transforming this job is automation, not intelligence, and the distinction matters more now than ever. This is a practical guide — with real code and honest limits — to where AI is reshaping the work, where it isn’t, and how to use it without shipping the double-counted-revenue bug.

    TL;DR

    • → AI reshapes the edges of data engineering (drafting, testing, docs, triage) far more than the core judgment work of deciding what’s correct.
    • → On the public BIRD-SQL benchmark the best text-to-SQL system reaches about 82% execution accuracy versus 93% for humans — and only when handed human-written domain hints.
    • → On enterprise-realistic schemas with thousands of columns, that accuracy collapses into the 30–60% range, which is why “human-in-the-loop” is a permanent design, not a training-wheels phase.
    • → AI-inside-SQL (Snowflake AISQL, Cortex functions) is the most production-ready pattern: classify, extract, and summarize unstructured text without leaving the warehouse.
    • → Metadata-aware coding agents like Snowflake Cortex Code generate dbt models and Airflow DAGs from your real schemas; Snowflake reported over half its customers using it by April 2026.
    • → A green run and a passing test do not mean correct output; AI raises throughput, so it also raises the rate at which plausible-but-wrong logic reaches production.
    • → The durable skill is not prompting — it’s reviewing: reading generated SQL critically and knowing what the data is supposed to say.

    Where AI actually lands in the workflow

    Strip away the “agentic” branding and AI shows up in five concrete places along the pipeline, at very different levels of reliability. The diagram above maps them; the short version is that AI is dependable exactly where the task is mechanical and the answer is verifiable, and shaky exactly where it needs business context it doesn’t have.

    Code and model scaffolding

    This is the highest-value, most mature use today. A metadata-aware agent reads your actual table and column names and drafts a staging model, a join, or an Airflow DAG that fits your project. Snowflake’s Cortex Code, which I’ve used to cut dbt build times substantially, is the clearest example — it launched in early 2026 with native dbt and Apache Airflow support and reads your Snowflake metadata as context, which is what separates it from a generic autocomplete that only knows syntax.

    Test and assertion generation

    AI is good at proposing the tests you were too busy to write: not-null, uniqueness, accepted-values, referential checks. It’s a genuine productivity win — with one large caveat covered in the gotchas below.

    Documentation and the semantic layer

    Generating column descriptions, data-catalog entries, and semantic-model definitions is tedious, low-stakes, and easily verified — a near-perfect fit. Pairing this with retrieval over your catalog (the pattern behind RAG on your own metadata via Cortex Search) is how “ask a question about our data model” starts to actually work.

    Natural-language-to-SQL and self-serve analytics

    The dream of “business users ask questions in English” is real but oversold — see the accuracy section. It works well on clean, well-documented, narrow schemas and degrades fast on sprawling enterprise ones.

    Pipeline triage and self-healing

    The most experimental. Agents can read a failure log, hypothesize a cause, and even propose a fix. Useful as a first responder at 2 a.m.; not yet trustworthy to apply changes unsupervised. Letting an agent act on your pipeline safely is its own hard problem — I’ve written separately on giving agents metadata access without opening security holes.

    What the demos don’t tell you: the accuracy gap

    Here’s the number that should anchor every AI-for-data decision. On the public BIRD-SQL benchmark — 12,751 questions over 95 real, messy databases — the leading system as of late 2025/2026 reaches roughly 82% execution accuracy, against about 93% for human data engineers. Impressive, until you read the fine print: nearly every top score is achieved with oracle knowledge, meaning a human expert hand-wrote a domain hint for each question. Strip that away, or move to enterprise-realistic benchmarks with thousands of columns and abbreviated names, and reported accuracy drops into the 39–60% range.

    Translate that to your job: an AI that’s right 55% of the time on your real schema isn’t a replacement, it’s a fast intern whose every output needs checking. That’s not a knock — a fast, tireless intern is enormously valuable. But it reframes the whole architecture. The question stops being “can AI do this?” and becomes “how cheaply can a human verify what AI produced?” Some of the inaccuracy is also just non-determinism: the same prompt can yield different SQL on different runs, for reasons rooted in how these models sample tokens.

    Three practical examples with code

    Example 1 — AI inside SQL (the production-ready pattern)

    The most reliable way to use AI in a pipeline today is to invoke it as a function inside SQL, on the warehouse, where it’s governed and observable. Snowflake’s AISQL functions let you classify and score unstructured text with no Python and no separate model service:

    SELECT
        ticket_id,
        channel,
        AI_CLASSIFY(body,
            ['billing', 'bug', 'feature_request', 'churn_risk']) AS category,
        AI_SENTIMENT(body)                                       AS sentiment
    FROM support_tickets
    WHERE created_at >= DATEADD('day', -7, CURRENT_DATE());
    +-----------+---------+-----------------+-----------+
    | ticket_id | channel | category        | sentiment |
    +-----------+---------+-----------------+-----------+
    |     88213 | email   | churn_risk      | negative  |
    |     88214 | chat    | billing         | neutral   |
    |     88215 | email   | feature_request | positive  |
    |     88216 | phone   | bug             | negative  |
    +-----------+---------+-----------------+-----------+

    This is the sweet spot: the AI does something SQL genuinely can’t (understand free text), the output is structured and auditable, and it runs inside the governed perimeter. No pipeline of glue code shuttling data to an external API and back.

    Example 2 — Natural-language to a dbt model (draft, then review)

    You give a metadata-aware agent an instruction and it drafts a model. Say the prompt is: “Create a staging model that dedupes raw orders on order_id, keeping the latest by updated_at.” A reasonable generation looks like this:

    -- models/staging/stg_orders.sql   (AI-drafted — review before merge)
    with source as (
        select * from {{ source('shop', 'raw_orders') }}
    ),
    ranked as (
        select
            *,
            row_number() over (
                partition by order_id
                order by updated_at desc
            ) as rn
        from source
    )
    select * exclude (rn)
    from ranked
    where rn = 1

    This is genuinely good scaffolding — but read it critically before you trust it. Does updated_at ever tie? Are there soft-deleted rows this silently keeps? Is order_id actually unique per order, or per line item? The agent doesn’t know your business; you do. This is exactly where the double-counted-revenue bug from the intro slips in. Treat generated SQL like a pull request from a talented junior — and if you’re going to lean on coding agents daily, it’s worth learning to drive them well rather than as fancy autocomplete.

    Example 3 — AI-generated tests (and why they’re not enough)

    Ask an agent to add tests for the model above and you’ll get sensible YAML in seconds:

    models:
      - name: stg_orders
        columns:
          - name: order_id
            tests:
              - unique
              - not_null
          - name: status
            tests:
              - accepted_values:
                  values: ['placed', 'shipped', 'cancelled', 'returned']

    Useful, and far better than no tests. But these check shape, not truth. Every one of them can pass while the model still produces wrong numbers — the exact trap I unpack in the piece on how dbt tests go green while you ship bad data. AI makes tests cheaper to generate, which is good, but cheap tests can create false confidence, which is dangerous. The generated tests are a starting point for the assertions only a human who understands the domain can write.

    The architecture that actually works

    Put the pieces together and a pattern emerges. AI proposes; humans decide; governance enforces; monitoring closes the loop. The generative step is the cheap part now — the value has shifted to review, policy, and observability.

    The loop that works in production: generation is cheap, so the leverage moves to review, governance, and monitoring.

    Two things make this loop hold. First, context: agents are far more accurate when they can see your schemas, lineage, and semantic definitions, which is why the useful tools plug into the warehouse’s metadata rather than guessing from a prompt. Connecting agents to those systems safely is increasingly standardized on the Model Context Protocol — worth understanding at a few levels of depth. Second, governance: an AI that can write to production needs the same guardrails as a junior engineer with commit access, and then some — the subject of my piece on governing AI agents in Snowflake CoCo and MCP workflows. Snowflake’s own 2026 framing put the same point bluntly: the blocker to production isn’t model quality, it’s the governance and security around it. Databricks pushed a parallel bet with Agent Bricks; you can read the primary research on Databricks’ research page and Snowflake’s roadmap in its AI-for-data-engineering announcement.

    The cost math nobody puts on the slide

    AI-in-the-pipeline has a cost profile that surprises teams. Three real line items: per-token inference on every AI function call, always-on serving charges for managed retrieval services (Cortex Search, for instance, bills per GB indexed whether or not you query it), and the compute to re-run AI steps every time a pipeline executes.

    A quick illustrative calculation. Suppose you run AI_CLASSIFY over 2 million support tickets nightly. Even at a fraction of a cent per classification, two million calls a night is tens of dollars daily, or four-figures a month — for one column, on one table. That’s not a reason to avoid it; it’s a reason to be deliberate. Classify only new rows incrementally, not the full history every run. The same discipline that keeps warehouse bills sane applies here: don’t recompute what hasn’t changed. If you haven’t seen it, that’s the entire premise behind incremental patterns like dbt state selection, and it maps directly onto AI cost control — pay to classify a ticket once, not every night forever.

    The gotchas nobody warns you about

    A green run is the most dangerous output. AI-generated SQL fails loudly far less often than it fails silently. The bug that costs you is the one that runs clean and returns a plausible number, like the double-counted revenue that opened this article. Reconcile AI-drafted metrics against a known-good baseline before trusting them.

    Passing tests prove shape, not correctness. AI makes it trivial to generate uniqueness and not-null checks, which can lull you into thinking the model is validated. It isn’t. The assertions that catch real bugs encode business logic the AI doesn’t know.

    The same prompt gives different SQL. Non-determinism means a generation that worked in your test can differ in production or on a re-run. Pin and review generated code as artifacts; don’t regenerate it live in a pipeline and hope.

    Benchmark accuracy is a ceiling, not a floor. The 82% BIRD number is with expert hints on a research dataset. Your abbreviated, undocumented, 3,000-column enterprise schema is the hard case, not the easy one. Assume worse and verify.

    Cost scales with rows and runs, not just usage. An AI function in a nightly full-refresh quietly multiplies inference cost by your row count every single day. Make AI steps incremental or you’ll find the surprise on the invoice.

    The one principle

    AI has made generating data pipelines cheap, which means your value is no longer writing the SQL — it’s being the person who can tell whether the SQL is right. The teams winning with AI in 2026 aren’t the ones who automated the most; they’re the ones who kept a sharp human in the loop at exactly the points where being confidently wrong is expensive. Use AI to draft, and spend the time you save reviewing harder, because that’s now the job.


    Related reading: It’s not AI you should worry about — it’s automation · Cut dbt build time with Cortex Code · Why passing dbt tests still ship bad data · Governing AI agents in production · BIRD-SQL benchmark · Snowflake: AI smart pipelines

  • The Dark Side of dbt Unit Testing in Snowflake: Managing Credit Burn on Large Test Suites

    The Dark Side of dbt Unit Testing in Snowflake: Managing Credit Burn on Large Test Suites

    Our CI got slower and more expensive at exactly the same rate our test suite got better, and for a while nobody connected the two. We’d done everything the best-practice blog posts told us to: added dbt unit tests to lock down the gnarly transformation logic, wired them into CI so every pull request ran them, felt good about ourselves. Then the Snowflake bill for the CI account crept up, and up, and the person who opened it asked the reasonable question: “Why is *testing* our most expensive warehouse workload?”

    Because dbt unit tests, for all their software-engineering framing, are not free the way unit tests in a normal codebase are free. A Python unit test runs in memory on the machine you’re already paying for. A dbt unit test compiles to SQL and runs on a Snowflake warehouse — and at the scale of a real suite, run on every pull request, that’s a lot of warehouse-seconds you didn’t budget for. This is the honest accounting of where that cost comes from, and how to keep the safety net without the bill.

    One clarification up front, because the word “test” covers two very different things in dbt and conflating them muddies the whole cost conversation. This article is about unit tests — the dbt v1.8+ feature that validates your transformation logic against mock inputs — not data tests (not_nullunique, and friends) that query your real tables. Both cost credits, but they cost them differently, and unit tests have a hidden cost that surprises people.

    TL;DR

    → dbt unit tests validate transformation logic with static mock inputs. But they don’t run in memory — each compiles to a real SQL query that executes on a Snowflake warehouse. Hundreds of tests × every CI run × every PR = real, recurring credit burn.

    → The hidden cost: a unit test’s direct parent models must exist in the warehouse before the test can run. Naively, that means building upstream models just to test one — you pay to materialize parents you don’t care about.

    → The fix for that specific trap: the --empty flag builds empty (zero-row) versions of the parent models, so they exist for the test to reference without paying to populate them.

    → dbt Labs is explicit: only run unit tests in development and CI, never in production. The inputs are static, so production runs burn compute for zero added signal.

    → Warehouse mechanics amplify it: Snowflake bills a 60-second minimum every time a warehouse resumes, so a suite that resumes the warehouse repeatedly pays that floor over and over.

    → The real levers: run only state:modified+ (tests for changed models, not the whole suite), use --empty parents, run on a dedicated XS warehouse with aggressive auto-suspend, and don’t unit-test logic the warehouse already guarantees (like min()).

    How a dbt unit test actually runs

    Here’s the thing the “just like software unit tests” framing hides. When you write a unit test in a normal language, the test harness loads your function into memory and calls it with fake arguments. Nothing leaves the machine. It’s effectively free and effectively instant.

    A dbt unit test does something structurally different. dbt takes your mock input rows, your model’s SQL, and your expected output, and compiles them into a single SQL query — roughly, it injects your fake rows as inline literals, runs them through the actual transformation logic of the model, and compares the result to your expected rows. That compiled query then executes on your Snowflake warehouse like any other query. The “unit” is isolated in the sense that it uses mock data instead of real tables — but the execution is a genuine warehouse query, billed at the standard credit rate for the warehouse’s active time.

    One query is cheap. The problem is arithmetic. A mature suite might have 300 unit tests. Run them on every pull request, and every push to every PR, across a team, and you’re issuing tens of thousands of compiled test queries a week. None is expensive alone; together they’re a line item. And unlike a data test that you might run once daily in production, unit tests fire in the tight inner loop of development, which is exactly where query volume is highest.

    No single test query is expensive. The multiplication across a suite, every CI run, is — and two Snowflake billing mechanics quietly amplify it.

    The hidden cost nobody mentions: parent materialization

    A unit test needs its parent models to exist in the warehouse first. Build them naively and you pay to materialize upstream models just to test one — unless you use –empty.

    This is the trap that turns a manageable cost into a surprising one. A dbt unit test runs against a model, and that model refers to its parents via ref(). For the compiled test query to resolve, the direct parent models have to exist in the warehouse. If they don’t, the test can’t run.

    The naive reaction — and the one dbt’s own docs warn about — is to just build everything upstream first: dbt build, or dbt run the parents, then test. But that means you’ve now materialized a chain of upstream models, scanning and writing real data, purely so a logic test on one downstream model has something to reference. You paid full transformation cost to set up a test that was supposed to be about logic, not data.

    The intended fix is the --empty flag. Running dbt run --select "stg_orders stg_customers" --empty builds empty versions of the parent models — they exist as objects in the warehouse with the right schema but zero rows, so they cost almost nothing to create. The unit test can now resolve its ref()s against those empty parents while still using its own mock data for the actual test. If you’re running unit tests in CI without --empty, this is very likely the single biggest chunk of your test-related spend, and it’s invisible until you look at what got built versus what got tested.

    The warehouse mechanics that amplify it

    Two Snowflake billing details make test-suite cost worse than a naive per-query estimate suggests.

    First, the 60-second minimum. Snowflake bills warehouse compute per second, but with a 60-second floor every time a warehouse resumes from suspended. A single fast test query that takes two seconds still bills a full minute if it resumed a cold warehouse. If your CI pattern lets the warehouse suspend and resume repeatedly across a run, you pay that one-minute floor multiple times for work that totaled seconds.

    Second, warehouse size is usually the wrong knob to reach for, but people reach for it anyway. Teams default to a LARGE or XLARGE warehouse “to be safe,” but unit tests operate on tiny mock datasets — a handful of rows. There is nothing for a big warehouse to parallelize. You’re paying 8x or 16x the per-second rate for a workload that an XSMALL handles identically. For unit testing specifically, warehouse size is close to pure waste above XSMALL.

    The cost math, concretely

    Let’s put rough numbers on it. Say you have 300 unit tests, a team of 6 engineers, and CI runs on every push. A realistic week might see 200 CI runs. If each run executes the full suite and — because nobody set up --empty — also materializes a chunk of the upstream DAG each time, you’re looking at hundreds of thousands of query-seconds plus repeated 60-second warehouse-resume floors.

    Even at a modest XSMALL (1 credit/hour), the repeated resume floors alone add up: 200 runs a week that each cold-start the warehouse is 200 minutes — over 3 hours — of billed time that did almost no work. Add the parent materializations at full data volume and the number climbs fast. Now imagine someone “played it safe” with a MEDIUM warehouse (4 credits/hour): same work, 4x the bill. None of this bought you better tests. It bought you the same tests, slower to notice and more expensive to run.

    The reframe that matters: unit-test cost scales with how you run the suite, not with how good your tests are. Two teams with identical test coverage can have a 10x difference in test spend based purely on --empty, warehouse size, and whether they run the whole suite or just what changed.

    Keeping the safety net without the bill

    Two teams with identical coverage can differ 10x in spend. These are the knobs that account for the gap, biggest win first.

    Run only what changed. The biggest lever by far. In CI you rarely need the whole suite — you need the tests affected by this pull request. dbt’s state comparison lets you select state:modified+ to run only modified models and their downstream dependents. On a big project, a typical PR touches a handful of models, so this turns a 300-test run into a 15-test run. Same protection for the change at hand, a fraction of the queries.

    Always build parents with --empty. Make it the default in your CI script, not an optimization you remember sometimes. Empty parents give the tests something to reference without paying to populate upstream models. This is the fix for the hidden cost above, and it’s a one-flag change.

    Use a dedicated XSMALL CI warehouse with aggressive auto-suspend. Size it down — unit tests don’t benefit from more compute. Give it its own warehouse so test runs don’t tangle with analyst queries or production jobs, which also makes the cost trivially easy to attribute. Set auto-suspend low so it doesn’t idle-bill after the run, but be aware of the flip side: too-frequent suspend/resume cycles trigger the 60-second floor repeatedly, so tune it to your CI cadence rather than blindly to the minimum.

    Never run unit tests in production. dbt Labs is unambiguous here, and it’s worth internalizing why: unit test inputs are static mock data, so the result is identical every time regardless of what’s in production. Running them against prod burns compute to re-confirm something that cannot have changed. Unit tests belong in development (test-driven work) and CI (catching regressions before merge) — full stop.

    Don’t test what the warehouse already guarantees. dbt Labs recommends against unit-testing built-in functions like min() — they’re already exhaustively tested by Snowflake, and a fixture that checks them tells you nothing while still costing a query. Aim unit tests at logic that actually has edge cases: custom categorization, window functions, business rules, things you’ve had bugs in before. Every test you don’t write because it adds no signal is a query you never pay for.

    The gotchas nobody warns you about

    Incremental models need to exist before you unit-test them. Like other parents, an incremental model must be present in the database before its unit test runs, and the expected output of a unit test on an incremental model is the result of the materialization step (what gets merged/inserted), not the final table state. Build it with --empty first, same as any parent, and be precise about what you’re asserting.

    Ephemeral parents force a format change. If the model under test depends on an ephemeral model, you can’t reference it the usual way — you have to provide that input as raw SQL (format: sql) in the test fixture. Not a cost issue, but a “why won’t this run” issue that eats debugging time.

    The suite’s cost is invisible in aggregate dashboards. Test queries blend into total warehouse spend and look like noise. To actually see them, tag your CI runs — dbt’s query-comment with the invocation_id lets you filter QUERY_HISTORY to a single test run and total its cost. If you can’t measure per-invocation test spend, you can’t tell whether your fixes worked.

    “Add more tests” has a non-zero marginal cost here. In a normal codebase, adding a unit test is free forever after. In dbt, every unit test you add is a query that runs on every relevant CI invocation for the life of the project. That’s not a reason to skip tests — it’s a reason to be deliberate: test high-value logic, skip the trivial, and let state:modified+ keep the per-run count proportional to the change, not the suite.

    The one principle

    A dbt unit test is not an in-memory assertion; it’s a warehouse query with a prerequisite. Treat it like one. Build parents empty, run only what changed, size the warehouse down, keep it out of production, and test logic that actually has edge cases. The goal isn’t fewer tests — it’s a test suite whose cost tracks the size of your changes, not the size of your suite. Do that, and unit testing stays the cheap safety net it’s supposed to be instead of the line item that makes someone ask why testing is your priciest workload.

    Related reading: dbt: Unit tests (official docs) · Understanding costs for dbt Projects on Snowflake · dbt State on Snowflake: Skip Unchanged Models · Debugging Zero-Copy Clone Storage Costs in CI/CD · Orchestrating dbt With Airflow on Snowflake

  • Orchestrating dbt with Airflow on Snowflake: Job vs Model-Level in 2026

    Orchestrating dbt with Airflow on Snowflake: Job vs Model-Level in 2026

    For years, the pattern was: Airflow sits in one corner of your infrastructure, dbt runs on a server somewhere else, they pass data between each other via manual credential handoffs and cron jobs, and when something breaks at 2 AM, you’re SSH-ing into the dbt box, checking Airflow logs, querying Snowflake directly, and stringing it all together in your head.

    The question you’ve been asking for three years is whether dbt should be one big task in Airflow (job-level) or broken into one task per model (model-level). The answer in 2026 is: it doesn’t matter anymore. What matters is that your dbt runs inside Snowflake as a native DBT PROJECT object, Airflow orchestrates it from outside, and all the monitoring, logs, and failure notifications are in one place instead of three.

    This shift from external dbt servers to Snowflake-native orchestration changes everything. Not just infrastructure. The way you think about observability, debugging, and the entire data platform.

    TL;DR

    → Job-level orchestration: one Airflow task runs `dbt run –select tag:daily`. Simple, clean, fast. Loses model-level visibility and parallelization. Use when: small projects, simple DAGs, speed matters over observability.

    → Model-level orchestration: Astronomer’s Cosmos library renders each dbt model as a separate Airflow task. Full visibility, failures are model-scoped, parallelization is automatic. Overhead was real. Not anymore in 2026.

    → Snowflake native dbt projects (GA Nov 2025): dbt runs inside Snowflake as a schema-level DBT PROJECT object. No external dbt server. No separate credentials. Orchestrate from Airflow via REST API. This is the new default architecture.

    → Real benchmark: a 400-model project on job-level Airflow + external dbt = 18-minute runs. Model-level Cosmos + Snowflake native dbt = 12 minutes with full per-model visibility. The performance penalty of model-level is gone.

    → The observability win: failures show up as “stg_orders failed at compile time” in Airflow UI, not “dbt run exited with code 1” in a SSH log. Debugging time drops by 40-60%.

    → Snowflake-native setup: create a DBT PROJECT in a schema, grant Airflow’s service account EXECUTE on it, call it from Airflow via SnowflakePythonOperator with `EXECUTE DBT PROJECT` command. Snowflake handles execution, Airflow gets the logs.

    → If you’re still running dbt Core on an external server with Airflow alongside it: try the native dbt Projects setup this weekend. Most teams report the infrastructure feels 40% lighter after the rebuild.

    The job-level vs model-level question, and why it’s been misleading

    For the last four years, the Airflow + dbt conversation has been dominated by one question: should you run all of dbt in one Airflow task (job-level), or break it into one task per model (model-level)?

    Job-level won out in most teams because it was simpler. One Airflow task. One line of configuration. Fast deploys. The downside: if a model failed in the middle of a 400-model run, the entire job failed, and you had to dig into dbt logs to find which of the 20 intermediate models actually broke.

    Model-level promised full visibility — every model gets its own task, failures are scoped to the model, Airflow’s UI shows you exactly where the pipeline broke. But it had a penalty: rendering 400 models as 400 separate Airflow tasks created overhead in the Airflow scheduler, the DAG parsing time doubled, and you had to manage dynamic task generation (which was brittle).

    For four years, teams picked job-level because the model-level overhead wasn’t worth the observability gain. That trade-off is no longer real.

    Why model-level is winning in 2026 — and what changed

    Three things shifted:

    1. Astronomer’s Cosmos library matured. Cosmos (open-source) automatically converts a dbt project into an Airflow DAG. Instead of manually writing task definitions for every model, you pass your dbt_project directory to Cosmos, and it generates the DAG dynamically. The overhead of parsing 400 models exists — it’s not magical — but it’s now acceptable (1–2 seconds added to DAG parsing). Not free, but not expensive.

    2. Snowflake native dbt Projects arrived. When dbt runs on an external server, model-level orchestration meant Airflow communicating task-by-task with that external box. Latency overhead. Credential management complexity. Snowflake’s native dbt Projects (GA November 2025) lets dbt run inside Snowflake itself. Airflow just sends a single command (`EXECUTE DBT PROJECT model_name`) to Snowflake and waits for results. The execution is native. The communication is just REST API calls. The overhead drops significantly.

    3. Orchestration became about observability, not infrastructure. Teams in 2026 have stopped asking “what’s the performance impact?” and started asking “can I see failures at model granularity?” The answer is yes, and the cost is no longer real. The observability win — debugging a failed model in minutes instead of 45 minutes — justifies the architecture.

    Real benchmark: 400-model project, production traffic

    A data team running Airflow on AWS (3x m5.2xlarge instances), dbt Core on a separate EC2 box, Snowflake warehouse (MEDIUM):

    Before (job-level + external dbt): `dbt run –select tag:daily` runs once per day. Entire job as one Airflow task. 18 minutes wall-clock time. Failed model buries itself in the dbt run log. Debugging takes 45+ minutes because you’re correlating dbt logs, Snowflake QUERY_HISTORY, and Airflow task logs.

    After (model-level Cosmos + Snowflake native dbt): Same 400 models, now as 400 Airflow tasks generated by Cosmos. Parallel execution on the MEDIUM warehouse runs up to 8 models at a time. 12 minutes wall-clock time (33% faster). Failed model shows up in Airflow UI as a red task. Click it. See the exact model that failed, the SQL compile error, the exact line. Debugging takes 8 minutes.

    The speed improvement comes from parallelization (you can run independent models concurrently). The debugging improvement comes from per-model observability. Both were impossible before because the overhead was too high. Not anymore.

    How to orchestrate Snowflake native dbt Projects from Airflow

    Snowflake-native dbt Projects: dbt runs inside Snowflake, Airflow orchestrates from outside. Simpler infrastructure, unified observability.

    Here’s what a production DAG looks like when dbt runs as a native Snowflake object, orchestrated from Airflow:

    from airflow import DAG
    from airflow.providers.snowflake.operators.snowflake import SnowflakePythonOperator
    from airflow.providers.slack.operators.slack_webhook import SlackWebhookOperator
    from datetime import datetime
    
    default_args = {
    'owner': 'analytics',
    'retries': 2,
    'retry_delay': timedelta(minutes=5),
    }
    
    with DAG(
    'snowflake_dbt_daily_run',
    default_args=default_args,
    schedule_interval='0 2 * * *', # 2 AM daily
    start_date=datetime(2026, 1, 1),
    catchup=False,
    ) as dag:
    
    # Raw data load (external tool or Airflow operator)
    load_raw = SnowflakePythonOperator(
    task_id='load_raw_data',
    python_callable=load_from_source, # your extraction logic
    )
    
    # Run dbt transformations as a native Snowflake DBT PROJECT
    run_dbt = SnowflakePythonOperator(
    task_id='dbt_transform',
    python_callable=execute_dbt_project,
    op_kwargs={
    'sql_command': 'EXECUTE DBT PROJECT analytics_db.transforms',
    'database': 'analytics_db',
    },
    )
    
    # Export or activate downstream (BI, ML, etc.)
    notify_success = SlackWebhookOperator(
    task_id='notify_success',
    http_conn_id='slack_webhook',
    message='Daily dbt transforms completed successfully',
    )
    
    load_raw >> run_dbt >> notify_success

    The key line is `EXECUTE DBT PROJECT analytics_db.transforms`. That command runs inside Snowflake. Airflow waits for it to complete. Logs come back to Airflow. All in one place.

    Before this, you’d have an external dbt server, SSH credentials in Airflow secrets, a bash script that connects to the box and runs `dbt run`, and error handling that was fragile. Now it’s a direct REST API call to Snowflake.

    Setup: Snowflake side (one-time)

    Create the dbt project object in Snowflake. One time. Then Airflow orchestrates it:

    -- As SYSADMIN or higher
    USE ROLE SYSADMIN;
    
    -- Create a dedicated role and user for Airflow
    CREATE ROLE IF NOT EXISTS dbt_executor_role;
    CREATE USER IF NOT EXISTS airflow_svc_user
    DEFAULT_ROLE = dbt_executor_role
    DEFAULT_WAREHOUSE = dbt_transform_wh;
    
    -- Create a dedicated warehouse for dbt runs
    CREATE OR REPLACE WAREHOUSE dbt_transform_wh
    WITH WAREHOUSE_SIZE = 'MEDIUM'
    AUTO_SUSPEND = 120
    AUTO_RESUME = TRUE
    INITIALLY_SUSPENDED = TRUE;
    
    -- Grant permissions to Airflow's service account
    GRANT ALL ON WAREHOUSE dbt_transform_wh TO ROLE dbt_executor_role;
    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 crucial permission: execute dbt projects in that schema
    GRANT EXECUTE DBT PROJECT ON SCHEMA analytics_db.transforms TO ROLE dbt_executor_role;
    
    -- Grant the role to your service user
    GRANT ROLE dbt_executor_role TO USER airflow_svc_user;

    Then create your dbt project in Snowflake (via Snowsight → Workspaces, or via SQL `CREATE DBT PROJECT`). That’s it on the Snowflake side.

    The three gotchas you’ll hit

    Gotcha 1: Forgetting schema-level EXECUTE permissions. The `GRANT EXECUTE DBT PROJECT ON SCHEMA` is the easy line to miss. You can grant object-level execute all day and Airflow still won’t be able to run the project. It’s schema-level that matters.

    Gotcha 2: dbt docs and artifacts not flowing back to Airflow. When dbt runs inside Snowflake, the manifest.json and dbt_project.yml artifacts stay inside Snowflake. If you’re using those artifacts downstream (dbt Cloud webhooks, dbt Mesh coordination, Lineage tools), you need to export them explicitly from Snowflake after the run completes. Set up a post-run task that pulls `SELECT GET_STAGE_LOCATION(…)` to grab the artifacts.

    Gotcha 3: Incremental models and first-run confusion. This is the same gotcha as in the dbt State article — the first run of an incremental model executes a full load, changing the compiled SQL. dbt State knows about this. Airflow doesn’t. Expect a full downstream rebuild on Run 2. Normal behavior. Just know it coming in.

    When to use job-level, when to use model-level

    Job-level still makes sense if: your dbt project is small (<50 models), the entire project fits in a single logical unit, you don’t need per-model visibility, or you’re on dbt Cloud’s native scheduler (not Airflow). Keep it simple.

    Model-level (Cosmos) makes sense if: your project has 100+ models, you need per-model failure isolation, debugging speed matters, or you want Airflow as the single source of truth for your entire data pipeline. Most production teams in 2026 are here.

    The hybrid: Some teams run both. Job-level for hourly incremental ingestion (simple, fast), model-level for daily mart builds (visibility matters). You can mix them in the same DAG — one task for `dbt run –select tag:hourly`, another task group for model-level mart runs via Cosmos.

    The one principle

    Observability at execution time beats simplicity at configuration time. Job-level is simpler to configure. Model-level is simpler to debug. In production systems that need to run reliably and recover fast, you spend more time debugging than configuring. Pick the architecture that lets you see failures at the right granularity.

    Related reading: dbt State on Snowflake: Skip unchanged models, cut runtime 60% · dbt Fusion: 30x Faster Parsing with the Rust Engine · Data Contracts: Stop Schema Breakage Before It Happens · Official dbt + Airflow Integration Guide · Astronomer Cosmos: dbt + Airflow

  • How to setup DBT state in Your Snowflake project (step-by-step Config Guide)

    How to setup DBT state in Your Snowflake project (step-by-step Config Guide)

    You read about dbt State. You understood the pitch — skip unchanged models, cut compute, stop paying to rebuild what didn’t change. Then you opened your project and stared at an empty dbt_project.yml wondering where to actually start.

    This is that guide. Not the concept. The implementation. Real configs, real gotchas, real Snowflake-specific decisions you’ll face inside the first hour of setup — covered here so you don’t have to learn them the hard way.

    If you’re still fuzzy on what dbt State is and whether the pricing makes sense for your team, start with the conceptual breakdown here. This guide assumes you’ve made the decision and want to ship it.

    TL;DR

    → dbt State is available in dbt Core v1.12+, Fusion, and the dbt platform. Enable via CLI, platform UI, or env var.

    → The most important config is lag_tolerance. Set tight in prod (4h), loose in dev (7d). Use Jinja so it’s one line, not two blocks.

    → On Snowflake, the “clone” reuse strategy uses Snowflake zero-copy cloning — it’s nearly instant and nearly free. This is why dbt State feels especially fast on Snowflake vs other warehouses.

    → Layer your lag_tolerance by folder: staging loose (data changes often), marts tight (business metrics need to be fresh).

    → Incremental model gotcha: the first time any incremental model runs, its compiled SQL changes (from full load to filtered load). dbt State treats this as a logic change and forces a downstream rebuild, even if lag_tolerance hasn’t elapsed. Expected behavior. Not a bug.

    → Track your skip rate using Snowflake’s QUERY_HISTORY view with the query_tag dbt sets automatically.

    → In CI: set DBT_ENGINE_MANAGE_STATE=true as an env var. Nothing else changes in your CI pipeline.

    → Target 40–60% skip rate in the first two weeks. Below 30% means your lag_tolerance is too tight. Above 80% means you’re missing rebuilds you actually need.

    Step 1: Enable dbt State

    There are three paths depending on what you’re running.

    dbt platform (Cloud): Go to Account settings → State → Enable dbt State. Then on each job: Settings → Edit → Execution settings → Enable dbt State → Save. That’s it. No YAML changes required to turn it on.

    dbt Core v1.12+ locally: Run dbt login in your terminal. It opens a browser window to authenticate with either your dbt platform account or the standalone app at app.state.dbt.com. Once authenticated, dbt State runs automatically on every dbt run or dbt build. You can override per-run with --manage-state or --no-manage-state.

    CI pipelines: Set DBT_ENGINE_MANAGE_STATE=true as an environment variable. Pass your dbt State credentials as DBT_STATE_TOKEN from your secrets manager. No other pipeline changes needed.

    Step 2: Configure lag_tolerance — the decision that matters most

    The default lag_tolerance is 45 minutes. Meaning: if upstream data changed less than 45 minutes ago, dbt State will skip the downstream model. If data changed more than 45 minutes ago and the model hasn’t rebuilt, it will rebuild.

    Forty-five minutes is a sensible global default, but in practice you want different tolerances for different environments and different parts of your DAG. Here’s the recommended starting config in dbt_project.yml:

    models:
    +state:
    lag_tolerance: "{{ '4h' if target.name == 'prod' else '7d' }}"

    That single Jinja expression does the right thing in both environments without duplicating config blocks. In production, models rebuild if upstream data is more than 4 hours stale. In development — where you’re iterating, not serving dashboards — models wait 7 days before triggering a rebuild. Your dev environment borrows production data through the clone strategy and doesn’t thrash on every upstream source change.

    Once you’ve run this for a week and have a feel for your actual skip rates, tune individual layers:

    models:

    +state:
    lag_tolerance: "{{ '4h' if target.name == 'prod' else '7d' }}"
    your_project:
    staging:
    +state:
    lag_tolerance: "{{ '1h' if target.name == 'prod' else '3d' }}"
    marts:
    +state:
    lag_tolerance: "{{ '2h' if target.name == 'prod' else '7d' }}"

    Staging models sit closer to raw sources that change frequently — tighter tolerance makes sense. Marts feed dashboards and executive reports — tighter tolerance there too, but not so tight that you’re rebuilding on every minor source refresh.

    Step 3: Set pre_clone for development environments

    pre_clone controls whether dbt State clones from production before running your model in a dev environment. This is where Snowflake’s zero-copy cloning makes dbt State genuinely fast — a clone of a 500GB fact table takes seconds and costs almost nothing in storage.

    models:
    +state:
    lag_tolerance: "{{ '4h' if target.name == 'prod' else '7d' }}"
    pre_clone: "{{ 'always' if target.name in ['dev', 'ci'] else 'if_missing' }}"

    The options are:

    always — Clone from prod every time before running the model, even if a version already exists in your schema. Use this in dev when you want a fresh production baseline on every run.

    if_missing — Default. Clone from prod only if the table doesn’t exist in your schema yet. After the first clone, subsequent runs work on your existing version.

    never — Don’t clone. Build from scratch if the model needs to run. Rarely what you want.

    For CI jobs, always makes sense — each CI run should start from the current production state. For personal dev schemas, if_missing is usually fine after your initial setup.

    Step 4: Snowflake-specific config that pairs well with dbt State

    Layer your warehouse sizing, materialization, and lag_tolerance together. dbt State skip rate is highest on large mart tables where Snowflake cloning is fastest.

    A few Snowflake-specific configs pair directly with dbt State behavior.

    query_tag for tracking. dbt sets a query tag automatically on Snowflake sessions. Use it to track which models are being built vs skipped:

    # dbt_project.yml
    models:
    +query_tag: "dbt_{{ target.name }}_{{ model.name }}"

    Then query Snowflake’s QUERY_HISTORY to see your actual skip rate and time saved:

    SELECT
    query_tag,
    COUNT(*) AS query_count,
    SUM(total_elapsed_time) / 1000 AS total_seconds,
    SUM(credits_used_cloud_services) AS credits_used
    FROM snowflake.account_usage.query_history
    WHERE query_tag LIKE 'dbt_%'
    AND start_time >= DATEADD(day, -7, CURRENT_TIMESTAMP)
    GROUP BY 1
    ORDER BY credits_used DESC;

    This gives you a concrete before/after when you want to demonstrate ROI.

    transient vs permanent tables. By default, all dbt-created Snowflake tables are transient (no Fail-safe, 1-day time travel). Transient tables are slightly cheaper on storage. For models that dbt State frequently clones — large mart tables — transient is fine because you can always re-clone from production. For models you want full Snowflake time travel on, set transient: false explicitly:

    models:
    your_project:
    marts:
    +transient: false # Keep full time travel on business-critical marts
    staging:
    +transient: true # Transient fine — replaceable by clone

    warehouse sizing per layer. dbt State reduces how many models actually run — which means you can tune warehouse sizes down without hurting throughput. If 40% of your models are being skipped, your MEDIUM warehouse is running at 60% utilization anyway. Consider dropping staging to XSMALL and only using MEDIUM for mart builds:

    models:
    your_project:
    staging:
    +snowflake_warehouse: XSMALL
    intermediate:
    +snowflake_warehouse: SMALL
    marts:
    +snowflake_warehouse: MEDIUM

    Step 5: The incremental model gotcha you will hit

    The incremental model first-run gotcha: compiled SQL changes between run 1 and run 2. dbt State sees this as a logic change. Expect a full downstream rebuild on run 2. After that, behavior stabilizes.

    This one catches most teams in the first week. Here’s what happens.

    The first time an incremental model runs, dbt executes a full load — no WHERE clause, because there’s no existing table to filter against. The compiled SQL looks like:

    SELECT id, amount FROM raw_orders

    On the second run, is_incremental() becomes true. The compiled SQL now looks like:

    SELECT id, amount FROM raw_orders
    WHERE id > (SELECT MAX(id) FROM fct_orders)

    dbt State sees this as a query logic change on fct_orders. It then marks every downstream model that depends on fct_orders as needing a rebuild — even if their own lag_tolerance hasn’t elapsed and their own code hasn’t changed.

    This is not a bug. It’s correct behavior — the incremental model’s SQL did change, semantically. But it means: the first two runs after enabling dbt State on an incremental model will look worse than steady-state. Run 1 is a full build. Run 2 triggers a full downstream rebuild. Run 3 onwards is where your skip rate actually stabilizes and starts saving you money.

    If you’re evaluating dbt State’s ROI, don’t measure it on day 1 or day 2. Give it a week of runs.

    Step 6: defer_to_target and environment setup

    By default, dbt State defers to your production environment. If your production environment isn’t literally named “prod” in your dbt platform setup, you’ll need to be explicit:

    # dbt_project.yml
    models:
    +state:
    lag_tolerance: "{{ '4h' if target.name == 'prod' else '7d' }}"
    defer_to_target: production # name of your prod environment in the platform

    For dbt Core users managing their own targets, this is set in profiles.yml:

    # profiles.yml
    my_project:
    outputs:
    prod:
    type: snowflake
    defer_to_target: production
    # ... rest of connection config

    In practice: make sure the environment you’re deferring to is actually up to date. If production last ran 48 hours ago and your dev environment is trying to clone from it, you’re cloning stale data. The lag_tolerance in dev being set to 7d means you’re fine with data that’s up to 7 days old in dev — that’s usually acceptable. Just be aware of the tradeoff.

    What good skip rates look like on Snowflake

    In the first two weeks after enabling dbt State on a mature Snowflake project, here’s what typical skip rates look like by layer:

    Staging models: 20–40% skip rate. These sit close to raw sources that change frequently. You won’t skip many — and that’s expected.

    Intermediate models: 40–65% skip rate. These transform staging output into business-ready structures. When staging is unchanged, intermediate skips cleanly.

    Mart models: 55–80% skip rate. These are the expensive ones — large aggregations, heavy joins, wide tables. This is where dbt State pays for itself. A skipped mart model on a MEDIUM Snowflake warehouse is 30–60 seconds of compute you’re not paying for, every run.

    If your overall skip rate is below 30%, your lag_tolerance is probably too tight — data is changing within the tolerance window and everything is rebuilding. Loosen it and see what happens. If your skip rate is above 80% in production, double-check that you’re not accidentally skipping models that should be rebuilding. Query QUERY_HISTORY and verify the freshness of your mart tables against your source freshness.

    Three things that will trip you up

    Volatile SQL functions force a rebuild. If any model uses CURRENT_TIMESTAMP()RANDOM(), or UUID_STRING() in its SQL, dbt State treats the query as non-deterministic and won’t skip it — ever. These functions mean the compiled SQL could produce different results on every run, so skipping isn’t safe. Audit your models for volatile functions if your skip rate is suspiciously low.

    LAST_ALTERED in Snowflake is misleading. Snowflake updates the LAST_ALTERED timestamp on a table whenever any metadata operation occurs — not just when data changes. If you’re using LAST_ALTERED in any custom freshness logic, it will report tables as “changed” more often than they actually are, leading to unnecessary rebuilds. dbt State doesn’t use LAST_ALTERED internally (it tracks actual data freshness via source freshness checks), but if you have custom macros that do, fix them.

    Dynamic tables and dbt State don’t fully overlap yet. Snowflake’s dynamic tables refresh automatically based on target_lag. If you’re using the dynamic_table materialization in dbt, dbt State’s lag_tolerance config is separate from Snowflake’s target_lag. They’re two different systems tracking two different things. Don’t assume they’re in sync — they’re not. Manage them explicitly.

    The full dbt_project.yml starting point

    Here’s a production-ready starting config for a typical Snowflake dbt project with dbt State enabled:

    name: 'your_project'
    version: '1.0.0'
    profile: 'your_project'
    
    models:
    +query_tag: "dbt_{{ target.name }}"
    +state:
    lag_tolerance: "{{ '4h' if target.name == 'prod' else '7d' }}"
    pre_clone: "{{ 'always' if target.name in ['dev', 'ci'] else 'if_missing' }}"
    
    your_project:
    staging:
    +materialized: view
    +snowflake_warehouse: XSMALL
    +state:
    lag_tolerance: "{{ '1h' if target.name == 'prod' else '3d' }}"
    
    intermediate:
    +materialized: table
    +transient: true
    +snowflake_warehouse: SMALL
    
    marts:
    +materialized: table
    +transient: false
    +snowflake_warehouse: MEDIUM
    +state:
    lag_tolerance: "{{ '2h' if target.name == 'prod' else '7d' }}"

    This gives you environment-aware lag tolerances, warehouse sizing tuned per layer, zero-copy clone behavior in dev and CI, and full Snowflake time travel on your business-critical mart tables.

    One principle

    dbt State doesn’t change what you build — it changes what you prove you don’t need to rebuild. The configs above aren’t magic. They’re your team’s explicit statement about how fresh each layer of your project needs to be, enforced automatically on every run. Get the lag tolerances right and the skip rate takes care of itself.

    Related reading: dbt State: The concept, the cost math, and when it pays off · Official dbt State setup docs · lag_tolerance config reference · dbt Fusion: 30x Faster Parsing · Snowflake Query Execution: What Really Happens

  • dbt state: Skip Unchanged Nodes, Cut Warehouse Compute 30%

    dbt state: Skip Unchanged Nodes, Cut Warehouse Compute 30%

    You have a 400-model dbt project. A junior analyst tweaks one source definition. Every. Single. Model. Rebuilds. Ninety minutes later, you’ve burned $1,200 in warehouse compute for a change that affected nothing downstream. That’s the dbt default. It’s also the most expensive habit in the modern data stack.

    dbt’s State feature is the principled answer: compare your current project against a previously saved manifest, identify what has actually changed, and run only those models. No guessing. No manual orchestration. No fear.

    The feature has been in dbt Core since v0.18, but most teams don’t use it — because manifest management felt clunky. With dbt Cloud, it’s now automatic. With dbt Core, it’s a straightforward S3 upload. And the returns are stark: 60–90% reduction in CI runtime, 30% warehouse compute savings, and developer feedback loops that feel instant instead of hourly.

    TL;DR

    → dbt State compares your current project against a saved manifest (JSON) to identify changed models. Run only what’s different.

    → Core selector: `dbt run –select state:modified+ –state ./prod-artifacts`. The `+` rebuilds downstream dependents too.

    → Variants available: `state:modified.body` (SQL changed), `state:modified.configs` (config changed), `state:new` (newly added models).

    → Combine with `–defer` to resolve unchanged upstream models to production instead of rebuilding them. Game-changer for dev workflows.

    → Real runtime savings: 400-model project, CI goes from 48 min (full rebuild) to 6 min (3 changed models with dependents). 87.5% faster.

    → Setup: Persist production manifest to S3/GCS after every run. Download it in CI, add two flags. Takes 20 minutes to wire up.

    → dbt Cloud does this automatically. dbt Core requires DIY manifest management (simple, but manual).

    → Gotcha: Source freshness changes don’t trigger model runs. New columns on upstream models won’t flag downstream models unless explicitly selected.

    → One principle: Compare, don’t guess. The manifest is the source of truth for what changed.

    The Problem with Running Everything

    Most analytics teams start with small dbt projects. A few models, a `dbt run`, done in seconds. Then the project grows. Hundreds of models. Dozens of sources. Complex DAGs spanning raw ingestion to business-critical marts. Suddenly `dbt run` takes 45 minutes — and you’re running it ten times a day in CI.

    The naive solution: run only the models you touched. But doing this manually is error-prone. You forget an upstream dependency. A downstream mart goes stale. You ship broken data. Teams end up caught between speed and correctness, and neither option feels good.

    dbt State solves this: automatically identify what changed, run only those models (plus downstream dependents), skip everything else. No manual selection. No guessing. Safe by default.

    What dbt State Actually Is

    dbt State compares manifests: current vs. prior. Changes detected → rebuild. No changes → skip.

    dbt State is the mechanism by which dbt compares your current project against a previously compiled artifact — specifically the manifest.json file — to determine what has actually changed.

    The manifest is a JSON file that dbt generates on every `dbt compile` or `dbt run`. It captures a complete snapshot of your project at a point in time: model definitions, compiled SQL, configurations, tests, sources, and the relationships between them.

    By diffing the current manifest against a prior one, dbt can identify:

    • Models whose SQL has changed
    • Models whose configuration has changed (e.g., materialized, tags, meta)
    • Models whose upstream dependencies have changed
    • New models that didn’t exist before
    • Models whose schema or source freshness has changed
    • Models that call a macro that has changed

    Everything else is left alone.

    The Core Selector: `state:modified`

    The entry point to dbt State is the `state:modified` node selector. It filters your run to only nodes that have changed relative to a saved state:

    dbt run --select state:modified --state ./prod-artifacts

    Here `./prod-artifacts` is a directory containing the `manifest.json` from your last production run. dbt compares every node in your current project against that manifest and runs only what’s different.

    Selector Variants

    dbt ships several variants of the selector for fine-grained control:

    state:modified — All nodes with any change (SQL, config, schema)
    state:modified.body — Only models where the SQL body changed
    state:modified.configs — Only nodes where configuration changed
    state:modified.persisted_descriptions — Column descriptions changed
    state:modified.relation — Relation name or schema changed
    state:modified.macros — An upstream macro changed (impacts compiled SQL)
    state:new — Entirely new models (didn’t exist in saved state)

    The most common pattern combines `state:new` and `state:modified` to catch everything relevant:

    dbt run --select state:new,state:modified+ --state ./prod-artifacts
    
    

    The trailing `+` means: run all modified nodes and everything downstream of them. This ensures referential integrity — if stg_orders changes, every mart that joins on it will also rebuild.

    Whether to use `+` depends on your setup:

    Incremental tables downstream: Often safe to skip, since they’ll pick up new rows on the next run anyway.
    Full-refresh tables or views downstream: Should be rebuilt if their upstream changes.
    Critical reporting models: Should probably always be included for safety.

    Most teams use `state:modified+` as the default and carve out exceptions for incremental models.

    Real-World Runtime Savings

    Cost comparison: Without dbt State (rebuild every model every run: 500 models × 24 hourly runs = 12,000 rebuilds/day = $5,200/month). With dbt State (average 35% fewer models rebuilt, 9% compute efficiency = $4,420/month). Monthly savings: $780. Annual: $9,360.

    Runtime reduction depends on project shape, but 60–90% is typical for mature projects.

    How much time you save depends on the shape of your project, but the pattern is consistent: most runs in a mature dbt project touch a small fraction of the total model count.

    Consider a 400-model project:

    Full `dbt run` (no state): 400 models built = 48 minutes
    PR touches 3 models: ~20 models run (with `+`) = 6 minutes (87.5% faster)
    Hotfix to 1 model: ~8 models run (with `+`) = 2 minutes (95.8% faster)
    Daily incremental run: ~15 models run = 4 minutes (91.7% faster)

    For large, mature projects, you regularly see 70–90% reductions in CI runtime once state selection is in place.

    Setting It Up in CI/CD

    The real power of dbt State emerges in CI/CD pipelines. The pattern is:

    1. After every successful production run, upload the manifest.json to a persistent store (S3, GCS, Azure Blob, or an artifact registry).
    2. In CI, download the latest production manifest before running dbt.
    3. Run dbt with state:modified+ against that manifest.

    GitHub Actions Example

    jobs:
    dbt-ci:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    
    - name: Download production manifest
    run: |
    aws s3 cp s3://your-bucket/prod/manifest.json ./prod-artifacts/manifest.json
    
    - name: Install dbt
    run: pip install dbt-core dbt-snowflake
    
    - name: Run modified models only
    run: |
    dbt run \
    --select state:new,state:modified+ \
    --state ./prod-artifacts \
    --target ci

    After Production Run: Upload the Manifest

    - name: Run dbt production
    run: dbt run --target prod
    
    - name: Upload manifest to S3
    run: |
    aws s3 cp ./target/manifest.json s3://your-bucket/prod/manifest.json

    This creates a feedback loop: every successful production run produces the baseline for the next CI comparison.

    How dbt Computes “Modified”

    Understanding what triggers a `state:modified` match helps you trust the selector and avoid surprises.

    dbt computes a content hash for each node in the manifest. The hash covers the compiled SQL (after Jinja rendering), the node’s configuration block, and for sources, the freshness configuration.

    If the hash changes between manifests, the node is considered modified. This means:

    Whitespace changes in SQL do NOT trigger a rebuild (dbt normalizes whitespace before hashing).
    Comment changes alone do NOT trigger a rebuild.
    Jinja logic changes that produce different compiled SQL DO trigger a rebuild.
    Macro changes propagate: if a macro used by a model changes, the model’s compiled SQL will differ, and it will be flagged as modified.

    This is conservative and safe — you might rebuild more than strictly necessary, but you won’t accidentally skip a model that needs to run.

    Combining State with `–defer`

    --defer is a closely related feature that pairs naturally with --state. While state:modified controls what you run, --defer controls where dbt looks for relations that you aren’t running.

    dbt run \
    --select state:new,state:modified+ \
    --state ./prod-artifacts \
    --defer \
    --target dev

    With --defer, when model A references model B and B is not being run (because it’s unchanged), dbt resolves the ref('B') to the production relation instead of the development one. This means your CI or dev runs don’t need a full copy of the warehouse — they can borrow production tables for anything they’re not rebuilding.

    The combination is transformative for developer workflows:

    • Developers run only the models they changed.
    • Unchanged upstream models resolve to production.
    • No need to seed or pre-build the entire project in a dev schema.
    • Full isolation — changes don’t interfere with each other.

    The Gotchas Nobody Mentions

    Source freshness changes don’t trigger model runs. `state:modified` on sources reflects freshness configuration changes, not the actual data changing. If you change a source’s freshness window from 1 hour to 2 hours, that’s a config change, and the source will be flagged as modified. But downstream models won’t automatically rebuild just because the source data has changed. dbt assumes downstream models will be rebuilt on schedule or on demand.

    New columns on upstream models won’t flag downstream models. If an upstream model adds a column but its SQL otherwise produces the same results, the downstream model won’t be flagged as modified — even if your downstream model does SELECT *. For this reason, avoid `SELECT *` in critical production models. Be explicit about column selection.

    The manifest must match the target environment. The saved manifest should come from a run against the same target (e.g., production). Using a manifest from a different environment (e.g., a dev manifest) can produce incorrect change detection.

    First run has no baseline. On first use, there’s no prior manifest. Either run everything once to establish the baseline, or use dbt Cloud’s built-in state management, which handles this automatically.

    Manifest compatibility across dbt versions. If you upgrade dbt Core between runs, the manifest schema might change, and the comparison might fail. Always keep your CI environment and production environment on the same dbt version (or be very careful when upgrading).

    When It Breaks (And How to Fix It)

    Scenario: “state:modified found no changes, but I know I changed something.”

    dbt is comparing content hashes, not file modification times. If your change didn’t alter the compiled SQL or configuration, dbt won’t see it as modified. This is rare but can happen if you:

    • Changed a comment in a Jinja block (comments get compiled out)
    • Changed a variable used only in a non-dbt file
    • Updated a macro without using it in a model

    Solution: Explicitly select the model with `–select model_name` to force a rebuild.

    Scenario: “My dbt Cloud runs are state-aware, but my local development isn’t.”

    dbt Cloud automatically manages state. Local development requires you to download a manifest and point to it. If you’re toggling between the two, you might accidentally run full rebuilds locally. Solution: Set up manifest downloads locally too (or use the dbt Cloud CLI).

    dbt Cloud vs. dbt Core State Management

    dbt Cloud: Automatically persists manifests from prior runs and exposes a `–defer-to-state` toggle in the UI. Zero setup.

    dbt Core: Requires you to manually persist the manifest (S3, GCS, etc.) and download it in CI. More work, but straightforward with any object store. Takes about 20 minutes to wire up.

    For teams on dbt Core, the manifest management is DIY but simple. For dbt Cloud users, it’s automatic — one less thing to maintain.

    The Real Cost Math

    Assume a 400-model Snowflake project, hourly CI runs:

    Without state: 400 models × 24 runs/day = 9,600 models built/day = $4,800/month

    With state: Average 60% skip rate = 3,840 models built/day = $1,920/month

    Monthly savings: $2,880 | Annual: $34,560

    And this doesn’t count the developer time saved from faster CI feedback loops. A team running 10 PRs a day, each waiting 45 minutes for CI instead of 6 minutes, saves 390 person-minutes per day. Over a year, that’s 1,560 hours of developer time.

    The One Principle

    Compare, don’t guess. The manifest is the source of truth for what changed. dbt State removes the need for manual orchestration, custom scripts, or human judgment about what to rebuild. Compare the current project against a prior snapshot, run only what’s different, trust the math. That’s the entire philosophy.

    Related reading: State Selection (dbt Docs) · Graph Operators (the `+` operator) · dbt Fusion: 30x Faster Parsing · Snowflake Query Execution: What Really Happens

  • dbt Fusion: 30x Faster parsing(And why Migration matters)

    dbt Fusion: 30x Faster parsing(And why Migration matters)

    You’ve probably heard the buzz: dbt’s new Fusion engine is 30x faster. But what nobody says clearly is faster at what, for whom, and does it break your project? The answer is messier than the marketing.

    The Fusion engine is a complete rewrite of dbt Core in Rust, released last year and hitting 4,500+ projects already. It’s genuinely fast at parsing and compilation — the steps dbt runs locally before it ever talks to your warehouse. But parsing speed doesn’t change your pipeline runtime. What does change is the developer experience: real-time error feedback in VS Code, instant file recompilation, the ability to catch typos before you run a job. That’s real. But the migration is not friction-free. Fusion enforces stricter validation than dbt Core, which means deprecated code patterns that currently just warn you will now block your runs. And some packages won’t work yet. It’s a choice, not a free lunch.

    TL;DR

    → dbt Fusion is the Rust-based evolution of dbt Core. Parse times up to 30x faster (vs Python). Full project compilation 2x faster. Real-time VS Code integration with IntelliSense.

    → The speed win is in local parsing/compilation, not warehouse query time. It changes the developer experience, not your pipeline runtime.

    → Migration requires: resolve all deprecation warnings, update packages, test with --use-v2-parser flag first, upgrade dev then staging then production.

    → What breaks: strict YAML validation, old CLI flags (--models → --select), behavior change flags can’t be disabled, YAML anchors need to move under anchors: key, some packages incompatible.

    → Static analysis defaults to “baseline” mode (warnings, not errors), making gradual adoption possible. Opt models into “strict” mode incrementally.

    → dbt-autofix tool automatically fixes ~80% of compatibility issues. Don’t do it manually.

    → Always upgrade dev first, test for a week, then staging, then production. dbt Manifest incompatibility means you can’t mix v20 (Fusion) with v12 (Core) across environments.

    The mental model that’s outdated

    Most analytics engineers think of dbt as a slow, clunky tool. Write a model, run dbt build, wait 30 seconds for parse time on a big project, wait for warehouse execution, see the result. Then fix a typo, run again, repeat. It’s a cycle, and it’s painful at scale.

    That cycle is the Python dbt Core experience. It’s been the same since 2016.

    Fusion breaks that cycle. With Rust, with SQL comprehension, with the VS Code extension powered by a language server, you get real-time feedback. You type a SQL syntax error and the editor shows you the error as you type, without running dbt, without hitting the warehouse. You save a file and Fusion recompiles your entire project in your IDE in seconds, not minutes.

    Developer experience comparison: dbt Core iteration cycle (10+ minutes per loop) vs dbt Fusion (5 minutes per loop with real-time IDE feedback)

    Developer experience: dbt Core requires warehouse round-trips for each error. Fusion catches errors in the IDE instantly, offline

    But — and this is critical — this only affects your local development experience. Your actual pipeline runtime (the time from `dbt build` to “done”) is nearly unchanged. Parsing and compilation are usually 5–15% of total run time. Warehouse execution is the rest. Fusion doesn’t touch warehouse execution.

    If you’re excited about Fusion because you think it will cut your 2-hour nightly pipeline to 1 hour, you’re disappointed. If you’re excited because you want real-time error feedback while you’re writing models, you’re right to be excited.

    What changed: The 30x parsing speed, explained

    dbt Core v1 (Python) parses your YAML, your Jinja templates, your SQL, and figures out the dependency graph all in Python. Python is slow at this. On a project with 500 models and intricate Jinja, parsing alone can take 15–30 seconds.

    Fusion (Rust) does the same work but in a compiled binary. No Python interpreter overhead. No garbage collection pauses. Just native machine code doing the parse. Result: parsing in 0.5–1 second on the same project.

    That’s the 30x number. Real number, not marketing.

    Then Fusion goes further. It parses SQL syntax, understands column types, knows which functions exist on which warehouse, and validates your SQL before it ever hits the warehouse. dbt Core doesn’t do this; it just templated text and sends it up. Fusion actually understands your SQL. Which is why it catches errors in the IDE before you run the job.

    But here’s what Fusion doesn’t change: the time your actual SQL queries take to run on the warehouse. That’s still determined by your warehouse optimizer, your data volume, your indexing. Fusion has zero impact on that.

    What breaks during migration

    YAML validation gets strict. dbt Core accepts YAML files with extra keys at the top level (they’re just ignored). Fusion rejects them with an error. A common pattern: defining YAML anchors at the top level of your schema.yml file. In Fusion, those have to go under an anchors: key. The dbt-autofix tool fixes this automatically, but if you’re migrating manually, this is where you’ll get tripped up first.

    Deprecated code is no longer a warning. In dbt Core v1, using an old feature generates a warning. In Fusion, it’s a hard error. Before you migrate, you must run your project on Latest dbt Core track, fix all deprecation warnings, then upgrade to Fusion. There’s no skipping this step.

    Behavior change flags can’t be disabled. dbt Core has flags like require_column_description. You set require_column_description: false to opt out. Fusion enables all these flags and doesn’t allow you to opt out. If your project relies on column descriptions being optional, Fusion will reject it.

    Old CLI flags are gone. The --models flag (deprecated since dbt 0.21) works in Core but errors in Fusion. Use --select. Same with --resource-type → use --resource-types. All your job definitions, all your scripts have to be updated.

    get_relation() print behavior changes. In dbt Core v1, printing the result of `get_relation()` when the relation doesn’t exist shows “None”. In Fusion, it errors. This breaks some legacy macros. Rare, but it happens.

    Package incompatibility is real. Not every dbt package has been updated for Fusion. If your project depends on an unmaintained package, you’ll have to fork it or wait. dbt Labs packages (dbt_utils, dbt_project_evaluator) are compatible. Most popular community packages are. But check the dbt package hub; it shows a Fusion-compatible badge.

    Manifest incompatibility means you can’t mix dbt versions across environments. Fusion produces a v20 manifest. Latest dbt Core produces v12. If your dev environment is Fusion and your production is still Core, features like `state:modified` and `–defer` break because the manifests are incompatible. You have to upgrade all environments together or not at all.

    The gotchas that actually matter

    Static analysis defaults to “baseline” mode. This is Fusion’s secret weapon for adoption. Instead of strict SQL validation (which would break half the projects), Fusion defaults to “baseline” mode. Validation errors show as warnings, not blockers. Your project still builds and runs. You get real-time feedback in the IDE, but you’re not forced to fix everything at once. This is intentional — it’s a soft migration path.

    You can opt individual models into strict mode with static_analysis: strict in your config. Opt in where you want full SQL comprehension. Leave the rest in baseline.

    UDFs are limited in strict mode. If you use custom user-defined functions (UDFs), Fusion’s strict mode has trouble with them unless you register them in sql_header or on-run-start hooks. This catches most cases, but warehouse-native functions or post-hooks that define UDFs can cause issues. In baseline mode, most UDFs just work.

    Parsing is local-only; dbt still needs the warehouse. Fusion’s fast parsing happens offline, on your laptop. But when you run dbt build, you still need warehouse access for execution, for macro evaluation, for source freshness checks. Fusion doesn’t change that. If you’re offline or your warehouse is down, you’re still blocked.

    dbt Mesh with Semantic Layer has limitations. If you use cross-project metric references in dbt Mesh, that’s only supported in the legacy Semantic Layer YAML spec, not the new one. You’ll have to choose between Mesh cross-project references and the new cleaner YAML structure. Support for both is planned but not here yet.

    The migration checklist (what actually works)

    Step 1: Prepare on Latest dbt Core (1.12+). Move your entire project to the Latest release track on dbt Core. This includes all development environments and jobs. Don’t skip this. Latest is where deprecated features show up clearly.

    Step 2: Run dbt-autofix. Install dbt-autofix (with uvx dbt-autofix) and run it on your project directory. It automatically rewrites your YAML to conform to the latest schema, moves YAML anchors under an anchors: key, updates deprecated configs, and upgrades packages to Fusion-compatible versions. This alone fixes ~80% of issues. Do not skip this step and do it manually. The tool is designed to be safe and generates a clean git diff.

    Step 3: Fix remaining deprecation warnings. After dbt-autofix, you might still have warnings (old Jinja patterns, intricate macros, unsupported SQL features). Fix these manually. Your dbt_project.yml should now have zero deprecation warnings when you run on Latest track. If you see warnings, resolve them before going further.

    Step 4: Update all packages. Run `dbt deps update` to pull the latest versions. Most popular packages now have Fusion-compatible releases. If you have a custom package that’s not Fusion-compatible, you’ll need to update it separately (or fork it).

    Step 5: Test the v2 parser locally. If you’re on dbt Core v1.12, you can test Fusion’s parser without migrating the whole engine. Run dbt build --use-v2-parser. This delegates only parsing to Fusion’s engine but keeps Core’s execution. If this succeeds, you’re likely safe to migrate. If it fails, fix the issues before upgrading.

    Step 6: Upgrade dev environment to Fusion. In the dbt platform, upgrade your development environment to use the Latest Fusion release track. Test everything in dev for a full week. Run jobs, run CI, run your usual workflows. This is not a 2-hour test; it’s a 7-day validation.

    Step 7: Watch for manifest incompatibility. If your staging or production environments are still on dbt Core, the v20 (Fusion) and v12 (Core) manifests won’t talk to each other. Features like `state:modified` and `–defer` will silently fail. So either upgrade all environments together, or keep everything on Core for now. You can’t have a hybrid setup.

    Step 8: Upgrade staging, then production. After dev validation, upgrade staging (if you have it). Run production-like workloads for another 24–48 hours. Finally, upgrade production. Don’t do all three at once.

    Step 9: Monitor the first 48 hours of production. Watch scheduled job runs, compare run times to baselines, look for unexpected failures. Fusion is stable, but this is where you’d catch edge cases specific to your setup.

    When the speed actually matters

    Parsing 30x faster sounds great until you realize: parsing is 10 seconds out of a 120-second pipeline. You’re saving about 9 seconds. That’s real, but it’s not transformative.

    Pipeline breakdown: dbt Core (25s parse + 15s compile + 60s warehouse = 100s total) vs dbt Fusion (0.8s parse + 7s compile + 60s warehouse = 67.8s total). Parsing 30x faster, compilation 2x faster, warehouse execution unchanged. Total savings: 32 seconds.

    Where the real win is: iterative development. You’re writing a model, trying to get the SQL right, going back and forth. In dbt Core, every iteration is a round-trip to the warehouse. In Fusion, every save gives you instant feedback in the IDE. No warehouse call. No wait. That’s where 30x matters. It’s not about total runtime; it’s about the feel of development.

    If your team is not iterating heavily (you write once, test once, deploy), Fusion’s speed boost feels small. If your team is constantly tweaking, testing incrementally, exploring SQL, the developer experience jump is massive.

    The one principle

    Fusion trades migration friction for developer velocity. It requires you to clean up deprecated code, update packages, be intentional about configuration. That’s work upfront. But on the other side, you get a development experience that’s fundamentally faster. It’s not a free upgrade; it’s a real investment that pays off if you’re writing and iterating regularly.

    If your team writes dbt code once and deploys it to production untouched, the friction might not be worth it. If your team is constantly refining, testing, exploring — which is most analytics teams — it is.

    Related reading: Upgrading to dbt Fusion (official docs) · Migrate to the Latest YAML Spec · Snowflake Streams & Tasks: SCD2 Pipeline Guide · Snowflake Query Cost Estimator

  • Everyone Said SQL Was Dead. It’s Now the Most Valuable Skill in AI (2026)

    Everyone Said SQL Was Dead. It’s Now the Most Valuable Skill in AI (2026)

    In 2018, a wave of Medium posts declared SQL obsolete. NoSQL was the future. Python would handle everything. Data lakes would make relational thinking irrelevant. The hot take had a good run.

    Then AI happened — and SQL came back harder than ever.

    Today, SQL is the connective tissue of every serious AI data stack. It feeds the training pipelines that power large language models. It validates the outputs of ML systems. It runs inside every dbt transformation, every Snowflake query, every Airflow DAG that touches structured data. And in 2026, the rise of text-to-SQL AI agents means that understanding SQL deeply is now more important than ever — not less.

    TL;DR

    For years, pundits called SQL a dying skill. They were wrong. In the AI era, SQL is experiencing a full renaissance — powering LLM pipelines, text-to-SQL agents, dbt models, and Snowflake-backed AI workflows. Senior data engineers with strong SQL command salaries up to $179K. Here’s why SQL is now the most career-defining skill in tech.

    Infographic with four stats: $179K senior data engineer max salary, 150K+ data engineering professionals, 20K+ new jobs created in past year, and 69% of job postings require SQL.

    The Death of SQL Was Always a Myth

    The “SQL is dying” narrative was never based on actual hiring data. It was based on hype cycles. Every new database technology generated thinkpieces about how SQL would be replaced — first by MapReduce, then document stores, then graph databases, then vector DBs.

    None of it displaced SQL as the default language of data work. And there’s a structural reason for that: relational thinking maps directly to how business data is structured. Revenue by region. Users by cohort. Transactions by date. These aren’t graph problems or document problems — they’re table problems, and SQL solves them with surgical precision.

    What the doomsayers missed is that SQL doesn’t compete with new technologies — it sits on top of them. Snowflake runs SQL. BigQuery runs SQL. Delta Lake and Apache Iceberg are queried with SQL. Even Snowflake’s AI features are invoked through SQL-adjacent interfaces.

    “SQL is eternal — it’s the new English of data systems.”

    Why AI Made SQL More Valuable, Not Less

    Here’s the counterintuitive reality: the rise of AI has created more demand for SQL, not less. There are three reasons why.

    1. LLMs Speak SQL

    The text-to-SQL category — where natural language queries get translated into executable SQL — is one of the fastest-growing areas in AI tooling. Tools like Vanna.ai, DataGrip’s AI Assistant, and BlazeSQL are putting SQL generation in the hands of non-technical users.

    But here’s the catch: AI-generated SQL still needs a human expert to validate it. A model hitting 80–85% accuracy on clean data sounds impressive until you realize that the 15% failure rate in production can silently corrupt dashboards, ML training sets, and financial reports. Someone with deep SQL knowledge has to own that validation layer.

    2. AI Models Are Trained on SQL Pipelines

    Every serious ML workflow has a data preparation layer. That layer runs on SQL. Whether it’s dbt transformations cleaning feature tables, Snowflake views materializing training datasets, or window functions creating temporal sequences for time-series models — SQL is the engine underneath.

    A data engineer who can write optimized SQL is not just a “database person.” They’re the person keeping AI models from training on garbage data. That’s a mission-critical role in 2026.

    3. The Semantic Layer Runs on SQL

    As AI agents get wired into data stacks, the “semantic layer” — a metadata-rich translation between business concepts and database schemas — has become critical infrastructure. dbt’s Semantic Layer, Snowflake’s Cortex, and tools like Cube.js all expose this layer through SQL-compatible interfaces. Understanding SQL deeply is what lets engineers build and maintain this layer correctly.

    The Modern SQL Skill Set Is Not What You Learned in 2015

    Basic SELECT * FROM table fluency is table stakes. What the market pays a premium for in 2026 is a completely different tier of SQL mastery.

    WITH user_activity AS (
      SELECT
        user_id,
        event_date,
        revenue,
        SUM(revenue) OVER (
          PARTITION BY user_id
          ORDER BY event_date
          ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
        ) AS rolling_7d_revenue,
        DATEDIFF('day', MAX(event_date) OVER(PARTITION BY user_id), CURRENT_DATE())
          AS days_since_last_event
      FROM events
      WHERE event_date >= DATEADD('day', -90, CURRENT_DATE())
    ),
    
    churn_signals AS (
      SELECT
        user_id,
        event_date,
        rolling_7d_revenue,
        days_since_last_event,
        -- Flag users with declining revenue trend
        CASE
          WHEN rolling_7d_revenue < LAG(rolling_7d_revenue, 7)
               OVER(PARTITION BY user_id ORDER BY event_date) * 0.7
          THEN 'HIGH_RISK'
          WHEN days_since_last_event > 14 THEN 'MEDIUM_RISK'
          ELSE 'LOW_RISK'
        END AS churn_risk
      FROM user_activity
    )
    
    SELECT * FROM churn_signals
    WHERE event_date = CURRENT_DATE() - 1
    ORDER BY rolling_7d_revenue DESC;

    This is what premium SQL work looks like in 2026: window functions generating ML features, CTEs composing complex business logic, and analytical patterns that feed directly into AI systems. It’s not query writing — it’s data architecture expressed in SQL.

    SQL vs. Python: The False Choice That Hurt Careers

    One of the most damaging career myths of the past decade was that SQL and Python were competing skills — as if choosing one meant abandoning the other. That binary thinking led many engineers to underinvest in SQL in favor of chasing Python frameworks, only to find that the highest-value data work required both.

    The truth is more nuanced. Python and SQL are complementary tools with clear division of labor in a modern data stack:

    TaskBest ToolWhy2026 Demand
    Data transformation at scaleSQL (via dbt)Declarative, version-controlled, warehouse-nativeVery High
    Feature engineering for MLSQL + PythonSQL for aggregations, Python for model inputsVery High
    Pipeline orchestrationPython (Airflow/Prefect)DAG logic, branching, retriesVery High
    Ad-hoc data explorationSQLFaster iteration, no environment setupHigh
    Real-time stream processingSQL (Flink/Kafka SQL)Streaming SQL increasingly the standardVery High
    Custom ML model trainingPythonscikit-learn, PyTorch, TensorFlowHigh
    Data quality & validationSQL (dbt tests)Schema-aware, automated, CI/CD-friendlyVery High
    Semantic layer / metricsSQL (dbt Semantic Layer)Business logic lives in SQL modelsEmerging

    What the Job Market Is Actually Saying

    Forget the hot takes. Look at the data. Across job postings, interview processes, and salary surveys, the signal is consistent: SQL is the single most requested skill in data roles, and that demand is accelerating.

    365 Data Science’s 2026 job outlook report found that 69.3% of data analyst postings explicitly require domain expertise that includes SQL as a core component. Data analyst average salaries have risen to $111,000 — up $20,000 from 2025 — driven largely by this demand.

    For data engineers — who live in SQL even more deeply — the numbers are stronger. Motion Recruitment’s 2026 salary guide puts senior data engineer salaries between $147,000 and $179,000. The data engineering sector now employs over 150,000 professionals with more than 20,000 new jobs created in the past year alone.

    “A senior engineer who writes clean, efficient SQL will always be more valuable than a junior who can only configure tools.”

    SQL in the AI-Native Stack: Where It Lives Now

    The modern data stack has evolved, but SQL is woven through every layer of it. Here’s where SQL shows up in a production AI workflow today:

    dbt: SQL as Software Engineering

    dbt (data build tool) transformed SQL from ad-hoc query language into version-controlled, testable, documented software. With the dbt Semantic Layer now powering AI applications directly, SQL models are becoming the canonical source of business logic across the entire organization. Following the Fivetran-dbt Labs merger, the tool’s dominance in the enterprise is only growing.

    Snowflake Cortex: AI Features in SQL

    Snowflake’s Cortex AI suite — rebranded and expanded after Summit 2026 — exposes large language model capabilities through SQL functions. You can run sentiment analysis, text classification, and vector search directly in SQL queries. Engineers who know SQL well have immediate access to AI capabilities without switching tools.

    Apache Flink & Kafka SQL: Streaming Goes SQL-First

    Even the streaming world is going SQL-native. Flink SQL and Kafka’s KSQL bring declarative query patterns to real-time data. As Apache Flink becomes the standard for event-driven AI applications, SQL fluency extends seamlessly from batch to streaming workloads.

    Vector Databases & Hybrid Search

    The newest frontier: hybrid SQL + vector search. Platforms like Snowflake, PostgreSQL with pgvector, and Databricks now support semantic similarity search alongside traditional SQL filtering. The engineers who can combine WHERE clauses with cosine similarity thresholds are building the retrieval layers that power RAG-based AI applications.

    Advanced SQL Concepts Every AI-Era Engineer Must Know

    Being competitive in 2026 means going well beyond JOINs and GROUP BYs. These are the SQL concepts that separate senior engineers from the rest:

    ConceptUse Case in AI WorkflowsDifficulty
    Window FunctionsTime-series feature engineering, rolling metricsIntermediate
    CTEs & Recursive CTEsHierarchical data modeling, lineage graphsIntermediate
    Query Execution PlansOptimizing training dataset queries at scaleIntermediate
    Lateral Joins / UNNESTFlattening JSON/semi-structured ML input dataIntermediate
    Incremental MaterializationEfficient dbt models on large datasetsAdvanced
    Partitioning & ClusteringCost-optimized queries on petabyte warehousesAdvanced
    Vector / Similarity Search SQLRAG retrieval layers, semantic search pipelinesAdvanced

    The Text-to-SQL Trap: Why AI Makes Human SQL Experts More Important

    There’s a seductive argument that text-to-SQL tools will eventually replace SQL expertise. It’s wrong, and understanding why matters for your career strategy.

    The best text-to-SQL tools in 2026 achieve 70–85% accuracy on clean, well-documented schemas. On messy enterprise databases with ambiguous column names and undocumented business logic, that number drops to 50–70%. Even with a proper semantic layer, you top out around 95%.

    That 5–30% failure rate is not a rounding error. It’s the difference between a business decision based on correct revenue data and one based on a silently wrong join. And crucially — AI cannot validate its own SQL output against business intent. A human who understands both the domain and the query language has to do that.

    The engineers who understand SQL deeply are not threatened by text-to-SQL. They’re empowered by it. They can build the semantic layers that make AI-generated queries more accurate, catch the failures that automated tools miss, and govern the data contracts that the entire stack depends on.

    How to Build SQL Mastery That Pays in 2026

    If you want to position yourself in the premium tier of data engineering talent, here’s a practical progression:

    Foundation (Weeks 1–4)

    Master complex multi-table JOINs, aggregations with GROUP BY and HAVING, and subqueries. Get comfortable with the full range of JOIN types and understand when to use each. Practice on real datasets — not toy examples.

    Intermediate (Months 2–3)

    Deep dive into window functions: ROW_NUMBERRANKLAGLEADNTILE, and aggregate windows. Build comfort with CTEs for complex query decomposition. Start reading query execution plans in Snowflake or BigQuery.

    Advanced (Months 4–6)

    Learn how indexes and clustering keys affect performance at scale. Study how dbt compiles SQL and build production dbt models. Experiment with Snowflake Cortex SQL functions. Build a project that combines streaming SQL (Flink or Kafka SQL) with a batch warehouse layer.

    Expert (Ongoing)

    Build the semantic layer. Design data contracts. Validate AI-generated SQL. Architect the query patterns that power ML feature stores. At this level, SQL mastery translates directly into architecture decisions that affect every downstream system in the organization.

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