Tag: dbt

  • dbt State: Stop Rebuilding What Hasn’t Changed

    dbt State: Stop Rebuilding What Hasn’t Changed

    Every hour, on a schedule, your dbt job rebuilds every model in your project. The staging models run because they always run. The intermediate joins run because they always run. The mart that powers the executive dashboard runs because, well, it always runs — even when not a single row in any upstream table changed since the last execution.

    This is the pattern most teams have lived with for years. It is simple, predictable, and expensive. And as of September 16, 2026 — announced at dbt Summit in Las Vegas — there is a better way.

    dbt State went generally available this week. It checks your warehouse metadata and compares model SQL fingerprints to your previous run’s manifest, then decides for each model: BUILD it (logic or data changed), CLONE it (data is fresh enough to reuse), or SKIP it (nothing changed, mark success instantly). The result, in production at companies like RxBenefits and Virgin Media O2, is a 25–59% cut in warehouse compute and significantly faster job run times — without changing a single line of your transformation SQL.

    TL;DR

    • dbt State evaluates each model on every run and assigns one of four actions: REBUILD (logic or upstream changed), CLONE (use last result — data fresh enough), or SKIP (nothing changed). The decision logic runs before any warehouse query fires.
    • It works with dbt v1.7 through v2, across Snowflake, BigQuery, Databricks, and Redshift. You don’t need dbt v2 to start — but v2’s Rust engine makes the evaluation dramatically faster on large projects.
    • lag_tolerance is the key new model-level config: it declares how stale a model is allowed to be before it must be rebuilt. A mart that’s fine with 24h-old data can run on a once-daily schedule automatically, while a fct_orders with a 5-minute tolerance gets rebuilt the moment upstream data changes.
    • dbt State is a paid add-on to the dbt platform (dbt Cloud). It is not available as a standalone open-source feature in dbt-oss. The --defer and state:modified selector in dbt Core remain free and open-source, but they require you to manage the state artifact yourself and don’t include the warehouse metadata check.
    • The MCP Server integration announced at dbt Summit 2026 means AI agents querying your dbt project’s lineage can now see freshness status per model — which makes lag_tolerance a governance contract, not just a cost lever.

    How dbt State Evaluates Every Model

    The evaluation logic runs before any warehouse query fires. dbt State pulls two things: the current manifest (your project’s compiled SQL and config fingerprints) and warehouse metadata for each model’s underlying table. It compares both against the previous run’s state artifact. The result is a per-model action, decided before the job touches compute.

    📷 three checks, four outcomes — all evaluated before a single warehouse query fires

    The CLONE action is the one most teams don’t expect. When upstream data hasn’t changed and the model is outside its lag_tolerance window (meaning it’s already stale but not yet due for a rebuild), dbt State clones the last successful result — copying the table metadata rather than re-executing the SQL. The downstream model sees a fresh table reference, the scheduler marks the task successful, and no compute was consumed. This is what accounts for the bulk of the cost savings in the production numbers above.

    What Actually Changes Per Run

    📷 only the models whose sql or upstream data changed get rebuilt — the rest are skipped or cloned without touching compute

    The example above shows a realistic scenario: one staging model changed (an engineer added a new column), which cascades to the intermediate model that joins it, which cascades to the mart. Three models rebuild. Five skip. If those five models typically consume 62% of your job’s warehouse credit spend, that’s 62% saved on that run — every run where this pattern holds.

    The cascade detection is automatic. dbt State uses your DAG’s lineage to trace which downstream models depend on a changed model, and flags those for rebuild too. You don’t configure cascades manually. The same lineage your DAG tracks for documentation is what dbt State uses to propagate change detection.

    Setting Up dbt State

    Configuration is two steps: enable the feature in your job settings and add lag_tolerance to the models that should define their own freshness contract.

    Enable dbt State on a job

    # In dbt Cloud: Job Settings → Advanced → Enable dbt State
    # Or via the dbt platform UI under the job's configuration panel
    
    # For local / self-hosted runs with dbt v1.7+:
    # Set the state artifact path — dbt State compares against this
    dbt run --state path/to/previous/run/artifacts --defer
    

    dbt State (GA, paid) vs –defer (free, open-source): the --defer flag and state:modified selector have existed in dbt Core since v1.1 and are fully open-source under Apache 2.0. They require you to store and pass the state artifact manually and don’t include warehouse metadata checks. dbt State (the GA product) automates the artifact lifecycle, adds the warehouse metadata evaluation step, and includes the CLONE action. If you’re on dbt Core and not on the dbt platform, --defer is still a meaningful free starting point.

    Add lag_tolerance to your models

    lag_tolerance is a model-level config that tells dbt State how stale this model’s data is allowed to be. It’s declared in your model’s YAML config and becomes part of the compiled manifest.

    # models/marts/fct_orders.yml
    models:
      - name: fct_orders
        description: "Order-level fact table, refreshed near-real-time."
        config:
          lag_tolerance: "5 minutes"   # rebuild if upstream data is > 5min old
    
      - name: dim_products
        description: "Product dimension, refreshes once daily."
        config:
          lag_tolerance: "24 hours"    # skip up to 24h before forcing rebuild
    
      - name: dim_customers
        description: "Customer dimension — changes are rare, 6h tolerance fine."
        config:
          lag_tolerance: "6 hours"
    

    Once lag_tolerance is set, dbt State uses it to decide the SKIP vs CLONE vs REBUILD outcome for that model on every run. A model with no lag_tolerance set defaults to always rebuilding if upstream data changed — the safe, conservative behaviour.

    📷 lag_tolerance moves freshness logic from the scheduler into the model — the right place for it to live

    The before/after here is architectural, not just cosmetic. When freshness is a scheduler setting, it’s invisible to anyone reading your model code — and it’s invisible to AI agents querying your project’s lineage through the dbt MCP Server. When it’s lag_tolerance in the model YAML, it’s part of the model’s contract: auditable, version-controlled, and queryable by anything that reads your dbt manifest.

    The Four Actions: BUILD, CLONE, SKIP, DEFER

    ActionWhen it firesWarehouse compute?Result
    BUILDSQL or config changed, OR upstream data refreshed and model is past lag_toleranceYes — full model runNew table written
    CLONEUpstream data refreshed but model is within lag_toleranceNo — metadata copy onlyLast result reused via zero-copy clone
    SKIPNothing changed — SQL, config, and upstream data all identical to last runNoTask marked success, no compute touched
    DEFERModel not in scope for this run (e.g. CI run scoped to modified models only)No — references upstream environmentUses production artifact, not dev rebuild

    DEFER is worth calling out separately because it predates dbt State and is often confused with it. DEFER is the --defer flag used in CI: when running a slim CI job scoped to state:modified+, models outside the scope defer to the production environment rather than rebuilding from scratch. dbt State’s BUILD/CLONE/SKIP logic sits on top of DEFER — they solve different problems and work together.

    dbt State and AI Agents

    One of the less-discussed implications of dbt State is what it means for agentic workflows. If you’re running LLM-driven pipelines in Airflow that trigger dbt jobs, or using the dbt MCP Server to let AI agents query your project — lag_tolerance becomes a governance guardrail, not just a cost setting.

    Before dbt State, an AI agent with access to trigger a dbt run could rebuild your entire DAG on every invocation. That’s expensive and often unnecessary. With lag_tolerance set per model, dbt State enforces the freshness contract regardless of what triggered the run — a human, a scheduler, or an agent. The agent cannot force a rebuild of dim_products that’s only 2 hours old when the model declares a 24-hour tolerance. The infrastructure enforces it.

    This also matters for the dbt MCP Server announced GA at dbt Summit 2026: agents querying your dbt project’s lineage and metrics can now see lag_tolerance as part of model metadata, letting them make informed decisions about whether a model’s data is fresh enough for a given question without triggering an unnecessary rebuild.

    The Gotchas

    dbt State (the GA product) is a paid dbt platform feature — not open-source.The confusion comes from dbt Core’s long-standing --defer and state:modified selectors, which are free and Apache 2.0. dbt State (GA) is a separate product that automates state artifact management, adds warehouse metadata evaluation, and includes CLONE. It requires a dbt Cloud account. If you’re self-hosting dbt Core, you can get partial coverage with --defer and a CI artifact pipeline, but the warehouse metadata check and automatic CLONE action are not available without the platform.

    lag_tolerance with no upstream change monitoring is half the feature.lag_tolerance tells dbt State how stale a model is allowed to be. But dbt State also needs to know when upstream data actually changed — which it determines from warehouse metadata. On Snowflake and BigQuery this works out of the box. On Redshift and Databricks, table-level metadata freshness signals vary by storage format and configuration. Test the upstream detection on your specific adapter before assuming it works correctly.

    The state artifact must match the target environment.dbt State compares your current run against a previous run’s artifact. If you run dev builds against a dev schema and then compare against a production artifact, the comparison may see false-positive changes (different schemas, different table names). Always compare like-for-like: production artifacts against production runs, dev artifacts against dev runs. The dbt platform manages this automatically; on self-hosted setups this is your responsibility.

    Models with Python logic can’t be CLONED on all adapters.Python models in dbt (dbt-py) use a different execution path than SQL models. On Snowflake, Python model results can be cloned. On BigQuery and Databricks, Python model CLONE support is adapter-dependent and may fall back to SKIP or BUILD depending on your version. Check the dbt State setup docs for your adapter’s current support matrix before setting lag_tolerance on Python models.

    Incremental models interact with dbt State differently than table models.For an incremental model, dbt State still evaluates whether SQL or config changed. But if the model is configured as incremental and upstream data refreshed, dbt State rebuilds using the incremental strategy — it does not CLONE the last full result. This is correct behaviour, but it means incremental models with frequent upstream updates may not see the same CLONE savings as table models. If you’ve been working through the antipatterns we covered in our incremental model guide, dbt State respects all those strategies exactly as configured.

    The One Principle

    “Put freshness in the model, not the scheduler. lag_tolerance is a contract — version-controlled, auditable, and enforceable by infrastructure, not by whoever remembers to update the cron.”

    FAQ

    What is dbt State and when did it go GA?

    dbt State is a dbt platform feature that evaluates each model on every run and assigns an action — BUILD, CLONE, or SKIP — based on whether the model’s SQL changed, whether upstream data refreshed, and whether the model is within its declared lag_tolerance freshness window. It went generally available on September 16, 2026, announced at dbt Summit in Las Vegas. It runs on dbt v1.7 through v2, across Snowflake, BigQuery, Databricks, and Redshift.

    Is dbt State free or a paid feature?

    dbt State (the GA product with automatic warehouse metadata evaluation and CLONE) is a paid add-on to the dbt platform (dbt Cloud). The related open-source features — the –defer flag and state:modified selectors — remain free and Apache 2.0 licensed in dbt Core. They require manual state artifact management and don’t include the warehouse metadata check or automatic CLONE action.

    What does lag_tolerance do in dbt?

    lag_tolerance is a model-level config that declares how stale a model’s data is allowed to be before it must be rebuilt. Set it in your model’s YAML config — for example, “24 hours” for a slowly-changing dimension or “5 minutes” for a near-real-time fact table. dbt State reads this at runtime and uses it to decide whether to CLONE (reuse last result — data is fresh enough) or SKIP (nothing changed at all) rather than running a full rebuild.

    What’s the difference between CLONE and SKIP in dbt State?

    SKIP fires when nothing changed at all — SQL, config, and upstream data are all identical to the last run. CLONE fires when upstream data refreshed but the model is still within its lag_tolerance window, meaning it doesn’t need to rebuild yet. CLONE copies the table metadata (a zero-copy operation on Snowflake) so downstream models see a valid reference without any compute. Both actions consume no warehouse compute; CLONE is the one that handles partial staleness gracefully.

    Do I need dbt v2 to use dbt State?

    No. dbt State works with dbt v1.7 through v2. You don’t need to migrate to v2 to start saving on compute. That said, dbt v2’s Rust-based engine evaluates the state logic significantly faster on large projects — so teams with hundreds of models will see evaluation-time improvements by upgrading, on top of the compute savings from State itself.

    How does dbt State interact with incremental models?

    For incremental models, dbt State still evaluates SQL and config changes. If the model needs to run, it uses your configured incremental strategy (merge, insert-overwrite, etc.) as normal. Incremental models do not get CLONE — if upstream data refreshed, dbt State rebuilds them incrementally rather than reusing the last result. Models configured as table or view materializations benefit most from CLONE.

    Related reading: dbt data lineage and DAG guide · dbt incremental model antipatterns · Running LLM tasks in Airflow with common.ai · Using MCP Servers with Snowflake · Snowflake Cortex AI token monitoring · dbt State product page (official) · dbt State setup docs (official) · dbt Summit 2026 announcements (official)

  • 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), CAST, UPPER, COALESCE 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_null, unique, 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_NUMBER, RANK, LAG, LEAD, NTILE, 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: