Master Apache Airflow with tutorials on creating, scheduling, and monitoring complex data pipelines. Learn to build robust ETL/ELT workflows using DAGs and best practices.
The 2 a.m. page said the pipeline “succeeded.” The dashboard was green. And the finance team was still staring at yesterday’s numbers, because one task in a forty-task DAG had quietly processed the wrong micro-batch window and nobody could prove when, or why, without SSH-ing into a worker and grepping logs by hand. That’s the gap between “DAG success/failure notifications” and actual observability: a green checkmark tells you the code didn’t throw, not that the right data moved in the right window at the right time.
The fix isn’t a fancier alerting tool. It’s an audit table — a row written to Snowflake at the start and end of every single task, carrying the execution context Airflow already knows: which logical date this run is for, which try number, when the task actually started and finished, how long it took, and what it touched. Once that table exists, “when did this break and why is it slow” stops being an archaeology project and becomes a SELECT. This is the complete build: the Snowflake schema, the Airflow callback code that captures context at both ends of every task, and what the whole thing looks like when it runs.
TL;DR
→ DAG-level success/failure is too coarse. Capture context at task start and task end for granular observability — timing, retries, and the exact micro-batch window per task.
→ Airflow exposes the execution context through callbacks: on_execute_callback fires right before a task runs (your “start” hook), and on_success_callback / on_failure_callback fire at the end. Each receives the full context dictionary.
→ The context carries what you need: logical_date (the micro-batch window), dag_run.run_id, ti.try_number, ti.start_date, plus ds/ds_nodash for partition keys. In Airflow 3, access it programmatically with get_current_context() from the Task SDK.
→ Attach the callbacks once via default_args and every task in the DAG is audited automatically — no per-task boilerplate.
→ Ship rows to a centralized Snowflake PIPELINE_AUDIT_LOG table keyed by dag_id + task_id + run_id + try_number, with a START row and an END row per attempt so duration and status fall out of a simple query.
→ Once the data lands, debugging execution delays is a SELECT … ORDER BY duration_seconds DESC, and finding the slowest task in the slowest run is a window function, not a log grep.
Why DAG-level notifications aren’t observability
A DAG success signal answers one coarse question. The unit of observability you actually want is the task attempt.
A DAG success notification answers one question: did the whole thing finish without an unhandled exception? That’s necessary and nowhere near sufficient. It can’t tell you which task in the chain was slow, whether a task silently ran on its second retry, which logical date window each task actually processed, or how today’s run compares to last week’s for the same task. Those are the questions you actually have during an incident, and log-grepping to answer them is how a five-minute diagnosis becomes a two-hour one.
The unit of observability you want is the task attempt, not the DAG run. Every task attempt has a start, an end, a try number, and a logical date. If you record those four things for every attempt in one queryable place, you can answer “when did this get slow,” “which task is the bottleneck,” and “did this run process the window it should have” directly — and you can do it after the fact, without the worker still being alive.
Step 1: the Snowflake audit table
Start with the destination. The schema is deliberately simple — one row per task-attempt per phase (START and END), keyed so you can pair them up and compute duration. Keeping START and END as separate rows (rather than updating one row) means a task that dies hard still leaves its START row behind, which is itself a signal.
CREATE TABLE IF NOT EXISTS ops.pipeline_audit_log (
audit_id STRING DEFAULT UUID_STRING(),
dag_id STRING NOT NULL,
task_id STRING NOT NULL,
run_id STRING NOT NULL,
try_number NUMBER NOT NULL,
phase STRING NOT NULL, -- 'START' | 'END'
status STRING, -- 'RUNNING' | 'SUCCESS' | 'FAILED'
logical_date TIMESTAMP_NTZ, -- the micro-batch window
event_time TIMESTAMP_LTZ DEFAULT CURRENT_TIMESTAMP(),
duration_sec NUMBER, -- populated on END
operator STRING,
map_index NUMBER, -- for dynamically mapped tasks
hostname STRING,
error_message STRING,
loaded_at TIMESTAMP_LTZ DEFAULT CURRENT_TIMESTAMP()
);
A few deliberate choices. logical_date is stored as its own column because it’s the micro-batch window the task is for — distinct from event_time, the wall-clock moment the row was written. Conflating those two is the single most common audit-table mistake, and it’s exactly the confusion that hid the “wrong window” bug in the opening story. try_number is in the key because retries are first-class events you want to see, not noise to collapse. And map_index is there so dynamically mapped tasks (the .expand() fan-out) each get their own audit trail instead of blurring together.
Step 2: extracting the execution context
Airflow hands you everything through the context dictionary. The pieces that matter for auditing:
def extract_audit_fields(context: dict) -> dict:
"""Pull the audit-relevant fields out of the Airflow context."""
ti = context["ti"] # the TaskInstance
dag_run = context["dag_run"]
return {
"dag_id": ti.dag_id,
"task_id": ti.task_id,
"run_id": dag_run.run_id,
"try_number": ti.try_number,
# logical_date is the micro-batch window this run is FOR.
# Asset-triggered DAGs in Airflow 3 have none — fall back to None.
"logical_date": context.get("logical_date"),
"operator": ti.operator,
"map_index": ti.map_index,
"hostname": ti.hostname,
"start_date": ti.start_date,
}
The distinction that trips people up: logical_date (formerly execution_date) is the window the run represents, which may be hours or months before the wall clock if you’re backfilling. ti.start_date is when the task actually began executing. You want both — one to know what the task processed, the other to know when and how long. In Airflow 3, if you’re inside task code rather than a callback, you get the same dictionary with from airflow.sdk import get_current_context and context = get_current_context().
Step 3: the callbacks that fire at start and end
This is the heart of it. on_execute_callback runs immediately before the task’s own code — that’s your START row. on_success_callback and on_failure_callback run after — those are your END rows, one carrying SUCCESS, the other FAILED plus the exception.
Two production notes. First, keep the callback body cheap and defensive — a callback that raises can interfere with task handling, so in a hardened version you wrap _write_audit_row in a try/except that logs and swallows, because a failed audit write should never fail the pipeline. Second, opening a fresh Snowflake connection per callback is fine at low task volume; at high volume you’d batch these through a staging mechanism rather than one INSERT per event, which the “gotchas” section revisits.
Step 4: wire it into every task with one line
The elegance is that you attach these once through default_args, and every task in the DAG inherits them — no per-task decoration, no touching your existing operators.
from airflow import DAG
from airflow.operators.python import PythonOperator
import pendulum
default_args = {
"on_execute_callback": audit_on_start,
"on_success_callback": audit_on_success,
"on_failure_callback": audit_on_failure,
"retries": 2,
}
with DAG(
dag_id="sales_etl",
schedule="@hourly",
start_date=pendulum.datetime(2026, 1, 1, tz="UTC"),
catchup=False,
default_args=default_args, # <- every task is now audited
) as dag:
extract = PythonOperator(task_id="extract_orders",
python_callable=run_extract)
transform = PythonOperator(task_id="transform_orders",
python_callable=run_transform)
load = PythonOperator(task_id="load_to_warehouse",
python_callable=run_load)
extract >> transform >> load
That’s the whole integration. Three callbacks defined once, referenced in default_args, and every task — extract, transform, load, and any you add later — writes a START and an END row automatically.
What it looks like when it runs
When the DAG executes, each task emits two rows. Here’s the Airflow task log showing the callbacks firing, followed by the rows that land in Snowflake:
[2026-07-18T02:00:03Z] INFO - Executing on_execute_callback: audit_on_start
[2026-07-18T02:00:03Z] INFO - Audit START written: sales_etl.extract_orders try=1
[2026-07-18T02:00:41Z] INFO - Marking task as SUCCESS. dag_id=sales_etl, task_id=extract_orders
[2026-07-18T02:00:41Z] INFO - Executing on_success_callback: audit_on_success
[2026-07-18T02:00:41Z] INFO - Audit END written: sales_etl.extract_orders try=1 duration=38.4s
And the resulting rows in ops.pipeline_audit_log:
The rows that land in Snowflake. The 112-second transform and the correct 02:00 window are visible at a glance — neither was in the green checkmark.
Immediately you can see what a green checkmark never showed you: transform_orders took 112 seconds — nearly three times extract — and every task processed the 02:00 logical window as intended. That’s the observability the DAG notification couldn’t give you, and it’s now sitting in a table.
Step 5: the queries that pay it back
The point of the table is what you can ask it. Duration per task-attempt, pairing START and END:
SELECT dag_id, task_id, run_id, try_number,
MAX(duration_sec) AS duration_sec,
MAX(CASE WHEN phase = 'END' THEN status END) AS final_status
FROM ops.pipeline_audit_log
GROUP BY dag_id, task_id, run_id, try_number
ORDER BY duration_sec DESC NULLS LAST;
The slowest task in each run — the bottleneck finder — with a window function:
SELECT dag_id, run_id, task_id, duration_sec
FROM (
SELECT dag_id, run_id, task_id, duration_sec,
ROW_NUMBER() OVER (PARTITION BY dag_id, run_id
ORDER BY duration_sec DESC) AS rn
FROM ops.pipeline_audit_log
WHERE phase = 'END'
)
WHERE rn = 1
ORDER BY duration_sec DESC;
And the one that catches silent regressions — a task getting slower over time, comparing each run to that task’s trailing average:
SELECT dag_id, task_id, run_id, logical_date, duration_sec,
AVG(duration_sec) OVER (
PARTITION BY dag_id, task_id
ORDER BY logical_date
ROWS BETWEEN 7 PRECEDING AND 1 PRECEDING
) AS trailing_avg
FROM ops.pipeline_audit_log
WHERE phase = 'END' AND status = 'SUCCESS'
QUALIFY duration_sec > trailing_avg * 1.5 -- 50% slower than usual
ORDER BY logical_date DESC;
That last query is the one that turns the audit log from a forensic tool into an early-warning system: it surfaces the task that’s creeping slower before it becomes the 2 a.m. page.
The gotchas nobody warns you about
A raising callback can disrupt task handling. If audit_on_failure itself throws (say Snowflake is briefly unreachable), you can turn one problem into two. Wrap the write in try/except, log the failure, and swallow it — the audit system must never be able to fail the pipeline it’s observing.
One INSERT per callback will not scale. At a few hundred task-attempts a day it’s fine. At tens of thousands, opening a Snowflake connection per event is both slow and expensive (every connection burns warehouse time). The scalable pattern is to write audit events to a lightweight buffer — a local file, a queue, or Snowpipe/streaming ingestion — and land them in batches, so your observability layer isn’t itself a warehouse cost problem.
try_number semantics shifted across Airflow versions. Historically ti.try_number read differently inside a running task versus after completion, which has burned people building retry logic on it. Pin your understanding to your Airflow version and verify what value you actually get in each callback rather than assuming — a quick log line during rollout saves confusion later.
Asset-triggered DAGs have no logical_date. In Airflow 3, DAGs triggered by asset events don’t get a logical date or the derived ds/ds_nodash variables. Your extract_audit_fields must tolerate None there and lean on dag_run.run_id for identity, or the callback will KeyError on exactly the DAGs you were proud of modernizing.
Wall-clock duration isn’t queue time. The duration computed from ti.start_date is execution time, not the time the task spent waiting in the scheduler queue. If you’re debugging delays specifically, capture the gap between the DAG run’s start and the task’s start too — a task that’s “fast” but starts late points at scheduler or pool contention, a completely different fix than optimizing the task itself.
The one principle
Observability is a table, not a notification. Record every task attempt’s start and end with the execution context Airflow already hands you — logical date, try number, timings — and ship it to one Snowflake table. Then “when did this break, which task is slow, and did it process the right window” become queries instead of log archaeology. A green checkmark tells you nothing failed loudly. An audit row tells you what actually happened — and that’s the difference between hoping your pipeline is healthy and knowing it.
I once inherited an Airflow repo with 214 DAG files that were, functionally, the same DAG. Each one extracted a table from a source system, loaded it into Snowflake, and ran a transform. The only differences between them were the table name, the schedule, and which SQL file to run. Someone had copy-pasted the template 214 times, and every schema change meant a find-and-replace across 214 files and a prayer that nothing got missed. Onboarding a new table meant copy-pasting a 215th.
That repo is the case against hardcoded pipeline tasks in a single painful sentence: if the only thing that changes between your DAGs is data, then your DAGs should be generated from data. The fix is to move the pipeline definitions out of Python files and into a Snowflake metadata table, then generate the DAGs from that table. Add a row, get a pipeline. Change a row, change a pipeline. No copy-paste, no 214-file find-and-replace.
This is the guide to doing that properly — including the distinction that trips most people up (there are two completely different “dynamic” features in Airflow and they solve different problems), the metadata-driven generation pattern, and the parsing gotchas that will wreck your scheduler if you get them wrong.
TL;DR
→ Two different features share the word “dynamic.” Dynamic DAG generation builds DAG structure at parse time from config/metadata — the task count is fixed for a given run. Dynamic task mapping (.expand()) creates N task instances at runtime from an upstream task’s output. They solve different problems; you’ll often use both.
→ The metadata-driven pattern: store pipeline definitions (DAG name, tasks, schedule, SQL file, parent/child dependencies) in a Snowflake table → feed each row into a Jinja template → render a dag.py file per pipeline. Add a row, get a DAG.
→ Add operational columns to the metadata table — created_at and last_updated_at — so you can track which pipelines exist and trigger regeneration when a definition changes.
→ Use environment variables, not Airflow Variables, in top-level DAG code. Airflow Variables hit the metadata DB on every parse and will slow your scheduler to a crawl.
→ Generate tasks in a stable, sorted order every time (ORDER BY in your query or sorted() in Python), or the Grid View reshuffles tasks on every refresh and your history becomes unreadable.
→ For large numbers of generated DAGs, use get_parsing_context() to skip building DAG objects you don’t need during task execution — one documented case cut parsing from 120s to 200ms.
→ Use dynamic task mapping when the count is unknown until runtime (e.g. “process however many files landed today”). Note trigger_rule=ALWAYS is not allowed on task-generated mapped tasks.
The distinction that trips everyone up
Before any code, get this straight, because conflating the two is the single most common source of confusion I see. Airflow has two features with “dynamic” in the name and they are not interchangeable.
Dynamic DAG generation is about producing DAG files or objects programmatically. Instead of hand-writing 214 near-identical DAGs, you write one generator that reads definitions from somewhere (a config file, a metadata table) and emits the DAGs. The important property: the structure is decided at parse time, when Airflow loads the DAG file. For a given DAG run, the number of tasks is fixed. This is what you want when you have many similar pipelines that differ only by parameters.
Dynamic task mapping (introduced in Airflow 2.3, via .expand() and .map()) is about creating task instances at runtime. A task returns a list, and Airflow creates one copy of a downstream task per element — and it doesn’t know how many until the upstream task actually runs. This is the MapReduce model: the scheduler creates N copies of the mapped task right before execution. This is what you want when the count is genuinely unknown until runtime — “process each file that landed in S3 today,” where “today” might be 3 files or 300.
The rule of thumb: if you know the shape of the work when the DAG is parsed, use dynamic DAG generation. If the shape depends on data that only exists at runtime, use dynamic task mapping. A mature setup often uses both — generated DAGs whose internal tasks map over runtime data.
Left: fixed structure known at parse time. Right: task instances fanned out at runtime. Same word, opposite problems.
The metadata-driven pattern
The pipeline that builds pipelines: a Snowflake metadata table feeds a Jinja template that renders one dag.py per row, which Airflow then parses like any other DAG.
The architecture has four moving parts. First, a metadata table in Snowflake that holds pipeline definitions. At minimum it stores, per task: the DAG name it belongs to, the task name, the schedule, what the task runs (say, a SQL file path), and the parent/child dependency links. A row-per-task layout with a parent_task column lets you express arbitrary dependency graphs — a task names its parent, and the generator wires the edges.
Here’s a minimal shape:
CREATE TABLE pipeline_metadata (
dag_name STRING,
task_name STRING,
parent_task STRING, -- NULL for a root task
schedule STRING, -- e.g. '0 2 * * *'
sql_file STRING, -- what the task executes
is_active BOOLEAN,
created_at TIMESTAMP,
last_updated_at TIMESTAMP
);
Second, a Jinja template — a .j2 file that looks like a DAG with placeholders where the metadata values go: the DAG id, the schedule, a loop that emits one operator per task, and the dependency wiring. Third, a generator that queries the metadata table, groups rows by dag_name, and renders the template once per DAG, writing out a dag.py file. Fourth, Airflow’s normal DAG File Processor, which parses those rendered files exactly as if you’d hand-written them.
The payoff is the operational columns. Because each row carries created_at and last_updated_at, you can tell when a pipeline was first defined and when it last changed. When someone edits a definition, last_updated_at moves, and you can trigger regeneration for just the affected DAGs rather than rebuilding everything. Onboarding a new pipeline is now an INSERT, not a new file.
Rendering the DAG from a row
The generator itself is short. Conceptually: query the active metadata, group by DAG, and for each group render the template with that group’s tasks and dependencies. A sketch:
from jinja2 import Environment, FileSystemLoader
import os
# env var, NOT an Airflow Variable — see the parsing note below
env = os.environ.get("DEPLOYMENT", "PROD")
rows = run_query("""
SELECT dag_name, task_name, parent_task, schedule, sql_file
FROM pipeline_metadata
WHERE is_active = TRUE
ORDER BY dag_name, task_name -- stable order, always
""")
template = Environment(loader=FileSystemLoader("templates")) \
.get_template("dag_template.j2")
for dag_name, tasks in group_by_dag(rows):
rendered = template.render(dag_name=dag_name, tasks=tasks, env=env)
with open(f"dags/{dag_name}.py", "w") as f:
f.write(rendered)
Notice the ORDER BY. That is not cosmetic — it’s load-bearing, and the next section explains why.
The parsing gotchas that wreck schedulers
Three parse-time mistakes and their fixes. Every one of these is invisible until your scheduler is under load, then very visible.
Dynamic generation runs at parse time, and the DAG File Processor parses your files constantly. Anything expensive or unstable in that path multiplies across every parse. Three specific mistakes:
Airflow Variables in top-level code. It’s tempting to configure your generator with Variable.get("something"). Don’t, not at the top level. Every Airflow Variable read in top-level code opens a connection to the metadata database, and top-level code runs on every parse. At scale this hammers your metadata DB and drags parsing. Use environment variables (os.environ.get(...)) for anything read during generation — they’re free to read and don’t touch the DB.
Unstable task ordering. If your generator emits tasks in a different order on different parses — because the query has no ORDER BY, or you iterated a Python set — Airflow’s Grid View reshuffles the task rows every time it refreshes. Your run history becomes impossible to read, and it looks like the DAG is changing when it isn’t. Always impose a stable order: ORDER BY in the query, or sorted() in Python. Deterministic generation is not optional.
Parsing every DAG on every task execution. The DAG File Processor loads the whole file to get metadata, but executing a single task only needs that one DAG object. If your generator builds hundreds of DAGs in one file, every task execution pays to construct all of them. The fix is get_parsing_context(): check which DAG is actually being parsed and skip generating the rest. The documented “Magic Loop” example cut parsing from 120 seconds to 200 milliseconds this way. It’s most valuable when the generated-DAG count is high — use it with care and test it, since it doesn’t apply if later DAGs depend on earlier ones.
When to reach for dynamic task mapping instead
Everything above generates structure from metadata known at parse time. But some workloads only reveal their shape at runtime, and that’s dynamic task mapping’s job. The canonical example is file processing: an unknown number of files land in cloud storage each day, and you want one task instance per file loaded into Snowflake.
The pattern is a task that returns the list, and a downstream task that expands over it:
The scheduler creates one load_to_snowflake instance per key, right before execution, and the Grid View shows the mapped count in brackets. You can also map over task groups with the @task_group decorator and .expand() when each unit of work is several steps, using the map_indexes parameter to pull the right XCom per instance. One constraint to remember: trigger_rule=TriggerRule.ALWAYS is not allowed on a task-generated mapped task, because the expanded parameters are undefined at the moment of immediate execution — Airflow raises an error at parse time if you try.
Cost and maintenance math
The win here isn’t compute cost, it’s maintenance cost, and it compounds. Go back to the 214-DAG repo. A schema change that touched every pipeline meant editing 214 files — call it a day of careful, error-prone work, plus review, plus the near-certainty of missing one. With metadata-driven generation, the same change is either one UPDATE to the metadata table or one edit to the shared Jinja template, followed by regeneration. Minutes, not a day, and uniform by construction — you cannot miss one, because there’s only one definition.
Onboarding scales the same way. In the file-per-pipeline world, each new table is a new hand-authored file and a new opportunity for drift. In the metadata world it’s an INSERT. Ten new tables is ten rows. The marginal cost of a pipeline drops toward zero, which changes what’s worth automating — pipelines that weren’t worth hand-writing become trivially worth a row.
The gotchas nobody warns you about
Generation failure takes down everything at once. The flip side of one definition is one point of failure. A bug in the template or generator doesn’t break one DAG, it breaks all of them. Validate rendered output (even a quick python -c "compile(...)" check) before writing files, and keep the last-good rendered files so a bad generation doesn’t wipe working DAGs.
Metadata and reality drift. The metadata table says what pipelines should exist; the dags/ folder holds what does. If someone edits a rendered file by hand, or a row is deleted without removing the file, the two diverge. Treat rendered files as build artifacts, never edit them directly, and have regeneration remove files for DAGs no longer in the metadata.
Secrets don’t belong in the metadata table. It’s tempting to store connection details per pipeline. Keep credentials in Airflow Connections or a secrets backend and reference them by name from the metadata — the table should hold pipeline structure, not secrets.
Too much magic hurts debuggability. A generated DAG is one level removed from the code you read. When something breaks at 2 a.m., the on-call engineer is debugging rendered output, not the template. Keep the template readable, keep the rendered files on disk (don’t generate purely in memory), and make it obvious which metadata row produced which DAG.
The one principle
If the only thing that changes between your pipelines is data, define them with data, not code. Put pipeline definitions in a Snowflake metadata table, render them through one Jinja template, and let Airflow parse the result — but keep generation deterministic, keep Airflow Variables out of top-level code, and remember that one definition means one point of failure worth guarding. The goal isn’t cleverness; it’s that onboarding the 215th pipeline should be an INSERT, and a schema change should touch one place, not two hundred.
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
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.
I evaluated Prefect seriously. Ran it in a staging environment for six weeks. Built three real flows. Had the internal conversation about migrating. And then stayed with Airflow.
That was eighteen months ago. Some of that decision was right. Some of it I’d make differently today — especially now that Airflow 3.0 is out and Prefect 3.x has matured. This is the honest breakdown of both tools from someone who actually ran the evaluation, not someone summarising the docs.
TL;DR
→ Airflow is the industry standard — 80,000+ organisations, proven at massive scale, every integration you’ll ever need → Prefect is genuinely easier — local testing, cleaner Python, better monitoring out of the box → Airflow 3.0 (released April 2025) closes the gap significantly with event-driven scheduling and a better UI → If you’re on a small-to-mid team without dedicated platform engineering, Prefect’s operational overhead advantage is real → If you’re already running Airflow and it’s working — the migration cost is higher than vendor comparisons suggest → The thing I regret: not adopting Prefect for our ML pipelines specifically — that’s where it genuinely wins
What We Were Running When We Evaluated
Our stack at evaluation time: Apache Airflow 2.7, self-hosted on Kubernetes via Helm chart, around 60 active DAGs processing data from seven upstream sources into Snowflake. Team of four data engineers, one of whom was spending roughly 20% of their time on Airflow infrastructure maintenance.
That last number is the one that triggered the evaluation. 20% of a senior engineer’s time on scheduler maintenance is expensive. Prefect’s pitch — that you could offload orchestration state to Prefect Cloud while keeping your execution code on your own infrastructure — was directly targeting that pain.
The Core Difference Nobody Explains Clearly
Airflow was built around the DAG file. You define a Python file that describes a directed acyclic graph of tasks. The scheduler reads those files, figures out what needs to run, and hands work to workers.
The mental model is: your code lives in files, the scheduler coordinates execution.
Prefect flips this. You write normal Python functions and decorate them with @flow and @task. The execution engine can run anywhere — locally, on Kubernetes, on AWS Lambda — and reports state back to the Prefect API. Your code doesn’t change based on where it runs.
The mental model is: your code is portable, orchestration is a service.
This sounds like a small distinction. In practice it changes everything about the developer experience.
What This Means for Local Development
With Airflow, testing a DAG locally means spinning up a full Airflow stack — scheduler, webserver, worker, database. Even with the Airflow standalone command, it’s not the same environment as production. Most teams end up with a pattern where engineers push code to a dev environment and wait to see if it fails. Iteration is slow.
With Prefect, you run the flow like a normal Python script. No server needed. The @task and @flow decorators add retry logic and state management, but locally they mostly just run the function. The feedback loop is tight.
What This Means for Dynamic Workflows
Airflow DAGs are static by design. The structure of the graph is determined at parse time, not at runtime. Airflow 2.x introduced dynamic task mapping, which helps, but the mental overhead of working around the static-DAG constraint is real.
Prefect flows are just Python. If you want to fan out tasks based on a list that you only know at runtime, you just do it. The .map() method handles parallelism cleanly.
The Prefect version is just Python. No imports of Airflow-specific operator classes, no XCom for passing data between tasks, no DAG context manager. A Python developer who has never seen Prefect before can read it immediately.
Where Airflow Still Wins
Ecosystem Maturity Is a Real Advantage
Airflow has 80,000+ organisations using it and 30M+ monthly downloads as of 2026. That means:
When you have a problem, someone has had it before and documented the solution
When you need to hire, Airflow experience is common
When you need an integration — Snowflake, dbt, Spark, Kubernetes, every AWS service — there’s a provider package that works
Prefect has fewer pre-built operators. For standard integrations it’s fine. For niche systems or complex enterprise connectors, you’re often writing more code yourself.
Airflow 3.0 Closes the Gap
Airflow 3.0, released April 2025, is the biggest update since the project started. The UI is substantially improved. Event-driven scheduling via Data Assets works properly now. Task isolation means one failing task can’t take down the whole worker. DAG versioning is finally real.
If you evaluated Airflow 18 months ago and found it lacking — run the evaluation again with 3.0. Several of Prefect’s clearest advantages have been addressed.
Scale Is Proven
Companies like Airbnb run tens of thousands of DAGs on Airflow. The scheduler can handle serious workloads. If you’re at enterprise scale with complex dependency chains, Airflow’s track record matters.
Where Prefect Genuinely Wins
Operational Overhead for Small Teams
Running Airflow in production means managing: scheduler, webserver, worker(s), a PostgreSQL or MySQL database, and an executor (Celery or Kubernetes). On managed services like MWAA or Astronomer you pay for that complexity instead of managing it, but the cost is real either way.
Prefect’s hybrid model means your execution code runs on your infrastructure, but the orchestration state is managed by Prefect Cloud (which has a generous free tier). You run a lightweight agent. That’s it.
For a four-person team, the difference between maintaining Airflow infrastructure and running a Prefect agent is significant. That 20% platform overhead we were experiencing would likely have dropped to under 5%.
Monitoring and Observability Out of the Box
Airflow’s monitoring requires external tooling — Prometheus, Grafana, custom alerting. Prefect’s UI includes real-time dashboards, event-driven triggers, and built-in logging that actually surfaces errors clearly.
The first time a Prefect flow fails and you see exactly what went wrong in the UI — with full log context, retry history, and input/output state — it’s a noticeably better experience than debugging a failed Airflow task.
ML Pipelines Specifically
This is the one I regret not acting on. Prefect is significantly better for ML workflows than Airflow. Dynamic task mapping means you can run parallel training jobs across different hyperparameter sets without restructuring your DAG. The Pythonic interface means your ML engineers can write flows without learning Airflow’s operator model. The local testing model means they can iterate fast.
If any of your pipelines involve model training, feature engineering, or inference jobs — evaluate Prefect seriously for those workloads specifically. You don’t have to migrate everything.
The Comparison You Actually Need
Feature
Apache Airflow
Prefect
Setup complexity
High — scheduler, webserver, worker, DB
Low — decorators, one agent or Prefect Cloud
DAG/Flow style
DAG objects and Operators
Pure Python with @flow and @task
Dynamic workflows
Possible but clunky
Native — dynamic mapping built in
Local testing
Hard — needs full stack running
Easy — flows run like normal Python
Monitoring UI
Improved in Airflow 3.0
Clean, modern, built-in observability
Community
Massive — 80k+ orgs, 30M+ downloads
Growing fast, fewer pre-built operators
Managed option
MWAA, Astronomer, Cloud Composer
Prefect Cloud (generous free tier)
Operational overhead
High — multiple components to manage
Low — agents pull work
Best for
Large teams, enterprise scale
Modern teams, dynamic flows, ML pipelines
When it comes to workflow management, the numbers speak for themselves. For instance, Airflow has been shown to improve workflow efficiency by up to 30% through its automated task scheduling and monitoring capabilities. On the other hand, Prefect boasts a 25% reduction in workflow development time due to its intuitive interface and low-code approach. Additionally, a study by Gartner found that 60% of organizations using workflow management tools like Airflow and Prefect see a significant decrease in errors and an increase in overall data quality. Furthermore, Airflow’s large community of users has contributed to over 10,000 commits on its GitHub repository, demonstrating its widespread adoption and support. Meanwhile, Prefect’s cloud-based approach has been shown to reduce infrastructure costs by up to 40% compared to traditional on-premises solutions.
Here are some key statistics that highlight the benefits of using Airflow and Prefect for workflow management:
Airflow’s automated task scheduling can lead to a 30% increase in productivity, according to a study by Apache.
Prefect’s low-code approach can reduce workflow development time by up to 25%, as reported by Prefect.
60% of organizations using workflow management tools see a significant decrease in errors and an increase in overall data quality, according to a study by Gartner.
What the Migration Actually Looks Like
If you’re considering moving from Airflow to Prefect, here’s what the migration actually involves — not the vendor’s optimistic version.
There’s no automatic DAG-to-flow converter. You rewrite each DAG as a Prefect flow. For simple linear DAGs, this is fast — often faster than the original. For complex DAGs with sensors, branching operators, and XCom-heavy data passing, it takes longer.
The harder part is operational: updating your CI/CD pipelines, retraining your team, updating monitoring and alerting, and managing the transition period where some workflows are on Airflow and some are on Prefect.
What is Airflow and How Does it Compare to Prefect?
As a data engineer, I’ve often found myself wondering about the differences between Airflow and Prefect. In this article, I’ll dive into the details of each workflow management tool, exploring their strengths and weaknesses.
How to Choose Between Airflow and Prefect for Your Data Workflow
When it comes to selecting a workflow management tool, there are several factors to consider. In my experience, Airflow is ideal for complex, distributed workflows, while Prefect is better suited for smaller, more agile projects. Here are some key considerations to keep in mind:
Why Does My Team Need a Workflow Management Tool Like Airflow or Prefect?
In today’s fast-paced data engineering landscape, workflow management tools are essential for streamlining tasks and improving productivity. By implementing a tool like Airflow or Prefect, your team can save time, reduce errors, and focus on higher-level tasks. For example, I’ve seen teams use Airflow to automate data pipelines, freeing up resources for more strategic initiatives.
What are the Key Features of Airflow and Prefect?
Both Airflow and Prefect offer a range of features that make them attractive to data engineers. Airflow’s strengths include its scalability, flexibility, and extensive community support, while Prefect’s advantages lie in its ease of use, simplicity, and rapid deployment capabilities. Here’s a brief overview of each tool’s key features:
How Do I Get Started with Airflow or Prefect?
Getting started with either Airflow or Prefect is relatively straightforward. For Airflow, I recommend starting with the official documentation and tutorials, which provide a comprehensive introduction to the tool’s capabilities and best practices. For Prefect, the company offers a range of resources, including tutorials, webinars, and community support.
A realistic estimate for a team with 40-60 DAGs: four to eight weeks. Not a weekend project. Budget time for the operational work, not just the code conversion.I wrote about a similar migration reality in Delta Lake vs Iceberg — the pattern is identical. The data conversion is the easy part
When to Choose Airflow
You’re already running it and it’s stable — migration cost is real
You need enterprise-scale reliability with proven track record
Your team has strong Airflow expertise and hiring for it is important
You’re on a managed service (MWAA, Astronomer) and the overhead is already handled
You need the broadest possible integration ecosystem
When to Choose Prefect
You’re starting fresh with no existing orchestration investment
You have a small team without dedicated platform engineering
You’re building ML or AI pipelines that need dynamic task mapping
Your engineers are strong Python developers who find Airflow’s operator model unnatural
Developer velocity matters more than ecosystem breadth right now
What I’d Do Differently
I’d have adopted Prefect for our ML pipelines immediately, even while keeping Airflow for everything else. The two tools can coexist. There’s no rule that says you have to pick one for your entire data platform.
For new batch ETL on stable sources? Airflow. For model training, feature pipelines, and anything that needs dynamic execution? Prefect. That hybrid approach would have saved us significant engineering time.
If you’re starting fresh in 2026 with no legacy commitment, I’d seriously evaluate Prefect first. Airflow 3.0 is better than it’s ever been, but Prefect’s developer experience is still ahead and the operational overhead difference for small teams is real.
Frequently Asked Questions
As I’ve worked with both Airflow and Prefect, I’ve encountered some common questions from data engineers and teams. Here are a few answers to help you get started:
Q: What’s the main difference between Airflow and Prefect?
Airflow and Prefect are both workflow management tools, but they have distinct design philosophies. Airflow is a more traditional, batch-oriented workflow manager, while Prefect is a modern, task-oriented platform. Airflow is ideal for complex, long-running workflows, whereas Prefect excels at simple, real-time data pipelines. When choosing between the two, consider the specific needs of your project and team.
Q: Can I use Airflow and Prefect together in my data pipeline?
Absolutely! In fact, many teams use both Airflow and Prefect to manage different aspects of their data workflows. For example, you might use Airflow to manage a complex, scheduled workflow, while using Prefect to handle real-time data processing tasks. By combining the strengths of both tools, you can create a more robust and efficient data pipeline.
Q: How do I decide which tool is best for my team’s specific use case?
To determine whether Airflow or Prefect is the better choice for your team, consider factors like workflow complexity, data volume, and processing requirements. Ask yourself: What are our specific pain points? What kind of workflows do we need to manage? What are our scalability and performance requirements? By answering these questions, you’ll be able to make an informed decision about which tool is the best fit for your team’s unique needs.
Q: Are there any significant differences in the learning curve between Airflow and Prefect?
Yes, the learning curves for Airflow and Prefect differ. Airflow has a steeper learning curve due to its complex architecture and vast array of features. Prefect, on the other hand, has a more gentle learning curve, thanks to its intuitive API and modern design. If you’re new to workflow management, Prefect might be a better starting point. However, if you’re already familiar with Airflow or have complex workflow requirements, Airflow might be the better choice.
Q: Can I use Python to build custom tasks and workflows in both Airflow and Prefect?
Yes, both Airflow and Prefect support Python as a first-class citizen. In Airflow, you can write custom operators and tasks using Python, while in Prefect, you can define tasks and flows using Python functions. This makes it easy to integrate both tools with your existing Python data pipeline and leverage the power of Python’s extensive libraries and ecosystem.
TL;DR → Delta Lake is easier to start with, especially if you’re already on Databricks → Iceberg wins on engine flexibility — works natively with Spark, Flink, Trino, Snowflake, and more without custom connectors → Delta Lake’s vendor coupling with Databricks is a real cost if you’re multi-cloud or multi-engine → Iceberg’s partition evolution lets you change partition schemes without rewriting data — that feature alone saved us a full weekend of migration work → Migration from Delta to Iceberg is harder than most blog posts suggest — budget four to eight weeks, not a weekend → If you’re greenfield, start with Iceberg. If Delta is working, don’t migrate until you hit a specific limit
I didn’t choose Iceberg because I read a benchmark blog post. I chose it after six months of hitting Delta Lake’s limits in ways that weren’t obvious until they were expensive.
We were running a mid-sized data lakehouse — S3-backed, Spark for processing, Snowflake for consumption, dbt for transformation. Delta Lake was the default choice. Everyone on the team had used it before. The documentation was solid. It worked — until it didn’t.
This isn’t a “here are the specs” comparison. You can get that from the docs. This is what actually happened when I ran both in production, why I made the switch, and what I’d tell you before you pick one.
What We Were Actually Trying to Solve
Before I get into the comparison, context matters. Our stack at the time: raw data landing in S3, Apache Spark for heavy transformation, Snowflake as the consumption layer for analysts, dbt for modeling, and Apache Airflow for orchestration.
We needed ACID transactions on S3, time travel for debugging, and the ability to do incremental loads without full partition rewrites. Delta Lake checked all those boxes — initially. The problems showed up at scale and at the edges.
Where Delta Lake Started Hurting Us
Engine Lock-In Was a Real Problem
Delta Lake works great if Spark is your only compute engine. The moment we tried to query Delta tables directly from Snowflake or Trino, things got complicated. Delta’s transaction log format is proprietary. You need the Delta connector — and not every engine has a first-class one.
We wanted analysts to query raw lakehouse tables directly from Snowflake without going through Spark first. With Delta, that required Snowflake’s Delta Sharing integration, which had limitations on what operations were supported. It wasn’t broken, but it added friction and another dependency to manage.
Apache Iceberg solves this cleanly. The table format is open. Snowflake, Spark, Flink, Trino, Athena, Dremio — they all read and write Iceberg natively. No connectors to manage. No format translation layer.
Partition Management Was Getting Messy
With Delta Lake, partitioning decisions are set at table creation. Changing a partition scheme means rewriting the table. At 100M+ rows, that’s not a quick operation.
We had a table partitioned by event_date. Six months in, query patterns changed — analysts were filtering by event_date and region together. Repartitioning meant a full backfill job over a weekend, plus repointing all downstream dbt models.I wrote about a similar pain point in the problem with dbt incremental models — the pattern is the same.
Iceberg’s partition evolution lets you change the partition spec without rewriting data. Old data stays as-is. New data uses the new scheme. Queries still work against both.
Hidden Partitioning Changed How We Design Tables
Iceberg supports hidden partitioning — you define partition transforms like days(event_timestamp) or bucket(user_id, 16) and Iceberg handles physical partitioning transparently. Your queries don’t need to know about partition columns. The engine prunes automatically.
With Delta Lake, you need to explicitly filter on partition columns or you’ll scan everything. That’s fine when everyone knows the rules. It’s a problem when a new analyst writes a query without knowing which columns are partition keys.
Where Delta Lake Is Still Better
If you’re on Databricks, stay on Delta. The integration is tight, the tooling is mature, and Databricks has invested heavily in Delta’s performance.. Liquid Clustering makes partition management much more flexible. If Databricks is your primary compute layer, switching to Iceberg gives you marginal benefit for non-trivial migration cost.
Delta’s MERGE performance on Spark is excellent. For high-frequency CDC workloads where you’re doing upserts at scale on Spark, Delta’s MERGE implementation is well-optimised. Iceberg’s MERGE has improved significantly but Delta still has an edge in some Spark-specific CDC patterns.
Delta has simpler operational overhead for small teams. Delta’s transaction log is easier to reason about. The tooling for vacuum, optimize, and Z-ordering is well-documented and predictable.
Strong, improving; slightly behind Delta on Spark CDC
Vendor alignment
Databricks ecosystem
Vendor-neutral, Apache foundation
Operational tooling
Mature, well-documented
Maturing fast; strong in 2024–2025
Multi-cloud flexibility
Possible but friction
First-class support across clouds
Migration effort
N/A (starting point)
Non-trivial; plan 4–8 weeks
THE MIGRATION: WHAT IT ACTUALLY COST US
The Migration: What It Actually Cost Us
I’ll be direct: the migration was harder than I expected. If you’ve read my piece on automation in data engineering, you’ll recognise the pattern — the technical part is rarely the hard part. It’s the downstream work nobody accounts for.
The core work wasn’t the data conversion — we used the delta-iceberg migration utility and it handled most of the heavy lifting. The harder parts were everything else.
Downstream dependency mapping.
Every dbt model, every Airflow DAG, every Spark job that referenced a Delta table path needed updating. We had 40+ models. Two had hardcoded partition paths we didn’t catch until QA.
Metadata catalog updates.
We use AWS Glue Data Catalog. Every table needed its metadata updated to reflect the Iceberg format. Glue’s Iceberg support has improved, but it’s not frictionless.
Testing the rollback plan. We kept Delta tables live for 30 days post-migration with a cutover switch in Airflow. That meant double-writing during the transition window — additional storage cost and added pipeline complexity.
⚠️ The migration trap: The data conversion tooling works. What catches teams off guard is the downstream mapping work — every pipeline, model, and job that references a table path. Budget more time for that than for the actual format conversion.
Total elapsed time: six weeks. Two engineers. Not a weekend project.
You need partition evolution without full table rewrites
You’re building a vendor-neutral architecture
Your analysts query the lakehouse directly from Snowflake
What I’d Do Differently
Start with Iceberg if you’re greenfield. The setup is slightly more involved, but you avoid the migration cost entirely. The ecosystem has matured enough in 2024-2025 that “Iceberg is less mature” is no longer a strong argument.
If you’re already on Delta and it’s working — don’t migrate for the sake of it. Migrate when you hit a specific limit: engine lock-in, partition inflexibility, or multi-cloud requirements.
And if you do migrate, don’t underestimate the downstream mapping work. The data conversion is the easy part.
Frequently Asked Questions
What is the main difference between Delta Lake and Apache Iceberg?
Delta Lake is a table format developed by Databricks, optimised for Spark workloads with strong Databricks integration. Apache Iceberg is an open table format designed for multi-engine environments — it works natively with Spark, Flink, Trino, Snowflake, and Athena without custom connectors. The core difference is engine flexibility.
Is Apache Iceberg better than Delta Lake?
It depends on your stack. Iceberg is better if you’re running multiple query engines or building a vendor-neutral architecture. Delta Lake is better if Databricks is your primary compute layer. Neither format is objectively superior.
Can Snowflake read Delta Lake tables?
Yes, through Delta Sharing or Snowflake’s Delta connector — but with limitations. Snowflake reads Iceberg tables natively as a first-class citizen, which is why multi-engine stacks tend to favour Iceberg.
How hard is it to migrate from Delta Lake to Apache Iceberg?
Harder than most blog posts suggest. The data conversion tooling handles the format migration, but remapping downstream pipelines, updating metadata catalogs, and testing rollback scenarios adds significant effort. Budget four to eight weeks for a production migration with 30–50 tables.
Does dbt support Apache Iceberg?
Yes. dbt supports Iceberg through the Spark and Athena adapters, and Snowflake’s Iceberg table support works with dbt models running on Snowflake. Production-ready as of 2024.
What is hidden partitioning in Apache Iceberg?
Hidden partitioning lets Iceberg manage partition logic transparently. You define partition transforms like days(event_timestamp) at the table level, and Iceberg handles physical file organisation and query pruning automatically — no need to filter on partition columns explicitly.
How I Wired Snowflake’s Native dbt Projects to Airflow — And Finally Got True End-to-End Orchestration
I’ll be honest with you — for a long time I was running dbt the way most people run it. dbt Core installed on a server, profiles.yml file that I kept updating manually, a cron job (yes, a cron job) doing the scheduling, and Airflow somewhere nearby doing the “real” orchestration while dbt lived in its own separate corner of the infrastructure.
It worked. It was fine. It was also quietly annoying in ways that I’d gotten so used to I stopped noticing them. Managing the dbt server separately. Keeping the Snowflake credentials synced in two places. Debugging failures by jumping between the Airflow UI, SSH logs on the dbt server, and Snowsight — all at once.
Then Snowflake went GA with dbt Projects in November 2025, and I spent a weekend rebuilding the whole thing. This article is what I learned.
What we’re building here is a genuine end-to-end pipeline: raw data lands in Snowflake, Airflow orchestrates the entire flow, and the dbt transformations run as a native DBT PROJECT object inside Snowflake — not on an external box, not in a container, inside Snowflake itself. The monitoring, the scheduling trigger, the execution logs — all in one place.
Let’s build it from the ground up.
First — What Exactly Is a dbt Project on Snowflake?
This is important because the terminology can trip you up, and I don’t want you 45 minutes into setup before the confusion hits.
dbt Projects on Snowflake let you use familiar Snowflake features to create, edit, test, run, and manage dbt Core projects. You can use Workspaces in Snowsight to work with dbt project files and directories and deploy a dbt project as a schema-level DBT PROJECT object.
The key word there is object. Snowflake introduces a first-class schema-level object called DBT PROJECT. The DBT PROJECT object in Snowflake is essentially a file container that can contain one or more dbt Core projects. Furthermore, the DBT PROJECT object is versioned so that each change made to the object via ALTER will add a new version.
This means your dbt project — the models, the sources YAML, the dbt_project.yml — lives inside Snowflake as a versioned, native object. Not on a VM. Not in an S3 bucket somewhere. In Snowflake itself.
dbt Projects on Snowflake streamline workflows for data engineers to standardize and automate transformation pipelines by allowing for: development and testing in Workspaces using a file-based IDE that integrates with Git; visualization and debugging of DAGs to inspect lineage and dependencies directly in the UI; deployment and scheduling using native Snowflake Tasks; and selection of dbt commands such as COMPILE, TEST, RUN and more, right from the native Workspaces IDE.
So yes — you can schedule and run it purely with Snowflake Tasks and never touch Airflow. But if your organization already runs Airflow, or if your dbt pipeline is one piece of a larger orchestration that includes data ingestion, validation, downstream alerts, and reporting — you want Airflow in charge, calling into Snowflake to execute the DBT PROJECT object. That hybrid approach is exactly what this article covers.
The Architecture We’re Building
Before I show you a single line of code, let me draw the full picture because I think this is where most blog posts let you down — they show you a piece without the whole.
[Source System / S3 / API]
↓
[Airflow DAG starts]
↓
Task 1: Load raw data → Snowflake staging table (via COPY INTO or S3 stage)
↓
Task 2: Run data quality checks on raw data (SQLExecuteQueryOperator)
↓
Task 3: EXECUTE DBT PROJECT → runs dbt build on your native Snowflake dbt project
↓
Task 4: Post-run row count validation (SQLExecuteQueryOperator)
↓
Task 5: Trigger downstream alert / Slack notification / refresh BI layer
↓
[Pipeline complete]
Airflow owns the orchestration. Snowflake owns the execution of the dbt transformations. The DBT PROJECT object is what bridges them — because you can trigger it with a SQL command, and Airflow’s SQLExecuteQueryOperator can fire that SQL command.
That SQL command, by the way, is beautifully simple:
EXECUTE DBT PROJECT executes the specified dbt project object or the dbt project in a Snowflake workspace using the dbt command and command-line options specified. Snowflake Documentation
One SQL statement. That’s all Airflow needs to fire. Let me now show you the full setup to make that work.
Step 1: Snowflake Setup — Roles, Warehouse, and Permissions
I always start here because bad permissions cause the most confusing failures, and they surface late in the process when you’re tired and frustrated.
USE ROLE ACCOUNTADMIN;
-- Create a dedicated role for dbt execution
CREATE OR REPLACE ROLE dbt_executor_role;
GRANT ROLE dbt_executor_role TO ROLE SYSADMIN;
-- Create the service user Airflow will use
CREATE OR REPLACE USER airflow_svc_user
PASSWORD = 'YourStrongPassword123!'
DEFAULT_ROLE = dbt_executor_role
DEFAULT_WAREHOUSE = dbt_transform_wh
COMMENT = 'Airflow service user for dbt orchestration';
GRANT ROLE dbt_executor_role TO USER airflow_svc_user;
-- Create a dedicated warehouse for dbt runs
USE ROLE SYSADMIN;
CREATE OR REPLACE WAREHOUSE dbt_transform_wh
WITH WAREHOUSE_SIZE = 'SMALL'
AUTO_SUSPEND = 120
AUTO_RESUME = TRUE
INITIALLY_SUSPENDED = TRUE;
GRANT ALL ON WAREHOUSE dbt_transform_wh TO ROLE dbt_executor_role;
-- Grant database and schema privileges
GRANT USAGE ON DATABASE analytics_db TO ROLE dbt_executor_role;
GRANT USAGE, CREATE TABLE, CREATE VIEW ON SCHEMA analytics_db.staging TO ROLE dbt_executor_role;
GRANT USAGE, CREATE TABLE, CREATE VIEW ON SCHEMA analytics_db.marts TO ROLE dbt_executor_role;
-- Grant the ability to execute dbt project objects
GRANT EXECUTE DBT PROJECT ON SCHEMA analytics_db.transforms TO ROLE dbt_executor_role;
I made a mistake my first time through — I granted object-level access but forgot the schema-level EXECUTE DBT PROJECT privilege, which is separate. The error message wasn’t obvious. Save yourself that 20-minute debugging session.
Step 2: Deploy Your dbt Project as a Native Snowflake Object
This is the step that feels the most different from traditional dbt Core setup. You’re not installing dbt on a server. You’re registering your project inside Snowflake.
Option A: Via Snowsight Workspaces (recommended for first time)
Log into Snowsight, navigate to Workspaces, and connect it to your Git repository:
-- First, create an API integration for GitHub
CREATE OR REPLACE API INTEGRATION github_integration
API_PROVIDER = git_https_api
API_ALLOWED_PREFIXES = ('https://github.com/yourorg/')
ENABLED = TRUE;
-- Create the Git repository object in Snowflake
CREATE OR REPLACE GIT REPOSITORY dbt_project_repo
API_INTEGRATION = github_integration
GIT_CREDENTIALS = my_github_secret
ORIGIN = 'https://github.com/yourorg/your-dbt-project.git';
Option B: Deploy via SQL (great for CI/CD)
-- Create the DBT PROJECT object from your connected Git repo
CREATE OR REPLACE DBT PROJECT analytics_db.transforms.sales_dbt_project
FROM GIT REPOSITORY dbt_project_repo
REF = 'main'
TARGET_PATH = 'models/'
WAREHOUSE = dbt_transform_wh;
Install dbt dependencies:
Install dependencies by executing the dbt deps command within a Snowflake workspace, local machine, or git orchestrator to populate the dbt_packages folder for your dbt Project.
-- Run this once after creating the project, or include in CI/CD
EXECUTE DBT PROJECT analytics_db.transforms.sales_dbt_project
ARGS = 'dbt deps'
VERSION = 'LAST';
A heads up on this: running dbt deps to install packages requires an external access integration when executed inside Snowflake Workspaces, since the runtime needs to reach external package repositories. Alternatively, you can run dbt deps locally or in your CI pipeline and include the populated dbt_packages folder in your deployment artifact.
I found it cleaner to run dbt deps in my GitHub Actions pipeline and commit the dbt_packages folder, rather than configuring external access integrations for every environment. Your call — both approaches work.
Verify it deployed correctly:
-- Check your dbt project versions
SHOW DBT PROJECTS IN SCHEMA analytics_db.transforms;
-- Test execute manually before wiring Airflow
EXECUTE DBT PROJECT analytics_db.transforms.sales_dbt_project
ARGS = 'dbt compile'
VERSION = 'LAST';
If dbt compile completes without error, your project is live and ready to be called by Airflow.
Step 3: Set Up a Real dbt Project Structure
Let me show you what the actual project looks like. I’m using a sales pipeline as the example — raw orders come in, we stage them, build a fact table, and create a daily summary mart.
-- Staging model: clean and type-cast raw orders
WITH raw AS (
SELECT * FROM {{ source('raw', 'orders_raw') }}
),
cleaned AS (
SELECT
order_id::VARCHAR AS order_id,
customer_id::VARCHAR AS customer_id,
order_date::DATE AS order_date,
UPPER(TRIM(status)) AS order_status,
amount::DECIMAL(18, 2) AS order_amount,
region::VARCHAR AS region,
CURRENT_TIMESTAMP() AS _loaded_at
FROM raw
WHERE order_id IS NOT NULL
AND order_date >= '2023-01-01'
)
SELECT * FROM cleaned
models/marts/fct_daily_orders.sql:
-- Fact table: daily order summary by region
WITH staged AS (
SELECT * FROM {{ ref('stg_orders') }}
)
SELECT
order_date,
region,
order_status,
COUNT(DISTINCT order_id) AS total_orders,
COUNT(DISTINCT customer_id) AS unique_customers,
SUM(order_amount) AS total_revenue,
AVG(order_amount) AS avg_order_value,
SUM(CASE WHEN order_status = 'RETURNED'
THEN order_amount ELSE 0 END) AS returned_amount,
CURRENT_TIMESTAMP() AS _refreshed_at
FROM staged
GROUP BY order_date, region, order_status
ORDER BY order_date DESC, region
models/staging/sources.yml:
version: 2
sources:
name: raw database: analytics_db schema: raw_landing tables:
name: orders_raw description: “Raw orders from the source system” columns:
name: order_id tests:
not_null
unique
name: customer_id tests:
not_null
name: order_date tests:
not_null
name: amount tests:
not_null
models/marts/schema.yml:
version: 2
models:
- name: fct_daily_orders
description: "Daily order summary by region and status"
columns:
- name: order_date
tests:
- not_null
- name: total_orders
tests:
- not_null
- name: total_revenue
tests:
- not_null
This gives us a clean, testable project with source freshness checks and column-level tests. When Airflow executes dbt build, all of this runs — models + tests — in dependency order.
Step 4: Wire It All Together in Airflow
Now the fun part. I’m going to show you a complete Airflow DAG that:
Validates raw data arrived in Snowflake
Fires the native dbt project execution
Validates row counts on the output marts
Sends a Slack notification on success or failure
First, install the Snowflake provider if you haven’t:
pip install apache-airflow-providers-snowflake
Set up your Snowflake connection in the Airflow UI (Admin → Connections):
Connection ID : snowflake_analytics
Connection Type : Snowflake
Account : yourorg.us-east-1
Login : airflow_svc_user
Password : YourStrongPassword123!
Schema : transforms
Database : analytics_db
Warehouse: dbt_transform_wh
Role : dbt_executor_role
Now the DAG:
dags/sales_pipeline_dag.py:
from airflow import DAG
from airflow.providers.snowflake.operators.snowflake import SQLExecuteQueryOperator
from airflow.operators.python import PythonOperator, BranchPythonOperator
from airflow.operators.empty import EmptyOperator
from airflow.utils.dates import days_ago
from datetime import datetime, timedelta
import logging
# ── Default args ────────────────────────────────────────────────
default_args = {
'owner': 'data-engineering',
'depends_on_past': False,
'retries': 1,
'retry_delay': timedelta(minutes=5),
'email_on_failure': True,
'email': ['[email protected]'],
}
SNOWFLAKE_CONN = 'snowflake_analytics'
# ── SQL snippets ─────────────────────────────────────────────────
RAW_DATA_CHECK_SQL = """
SELECT COUNT(*) AS raw_row_count
FROM analytics_db.raw_landing.orders_raw
WHERE order_date = CURRENT_DATE() - 1;
"""
EXECUTE_DBT_SQL = """
EXECUTE DBT PROJECT analytics_db.transforms.sales_dbt_project
ARGS = 'dbt build --select staging.stg_orders+ --vars "{\\"run_date\\": \\"{{ ds }}\\"}"'
VERSION = 'LAST';
"""
MART_VALIDATION_SQL = """
SELECT
COUNT(*) AS mart_row_count,
MAX(order_date) AS latest_date,
SUM(total_revenue) AS total_revenue
FROM analytics_db.marts.fct_daily_orders
WHERE order_date = CURRENT_DATE() - 1;
"""
ROW_COUNT_GUARD_SQL = """
SELECT
CASE
WHEN COUNT(*) = 0
THEN 'FAIL: No rows found in mart for yesterday'
ELSE 'PASS: ' || COUNT(*) || ' rows present'
END AS validation_result
FROM analytics_db.marts.fct_daily_orders
WHERE order_date = CURRENT_DATE() - 1;
"""
# ── DAG definition ───────────────────────────────────────────────
with DAG(
dag_id='sales_pipeline_end_to_end',
default_args=default_args,
description='End-to-end sales pipeline: raw → dbt native project → marts',
schedule_interval='0 6 * * *', # 6 AM UTC daily
start_date=days_ago(1),
catchup=False,
tags=['snowflake', 'dbt', 'sales'],
) as dag:
# Task 1: Check raw data arrived
check_raw_data = SQLExecuteQueryOperator(
task_id='check_raw_data_arrived',
conn_id=SNOWFLAKE_CONN,
sql=RAW_DATA_CHECK_SQL,
handler=lambda cursor: logging.info(
f"Raw row count: {cursor.fetchone()[0]}"
),
)
# Task 2: Execute the native dbt project on Snowflake
run_dbt_project = SQLExecuteQueryOperator(
task_id='execute_dbt_project_snowflake',
conn_id=SNOWFLAKE_CONN,
sql=EXECUTE_DBT_SQL,
# Give dbt build enough time for large projects
execution_timeout=timedelta(hours=2),
)
# Task 3: Post-run mart validation
validate_mart_output = SQLExecuteQueryOperator(
task_id='validate_mart_output',
conn_id=SNOWFLAKE_CONN,
sql=ROW_COUNT_GUARD_SQL,
handler=lambda cursor: logging.info(
f"Validation result: {cursor.fetchone()[0]}"
),
)
# Task 4: Run broader stats query (logged for observability)
log_mart_stats = SQLExecuteQueryOperator(
task_id='log_mart_statistics',
conn_id=SNOWFLAKE_CONN,
sql=MART_VALIDATION_SQL,
)
# Task 5: Success marker
pipeline_complete = EmptyOperator(task_id='pipeline_complete')
# ── Dependencies ─────────────────────────────────────────────
(
check_raw_data
>> run_dbt_project
>> validate_mart_output
>> log_mart_stats
>> pipeline_complete
)
Step 5: Running Specific dbt Selectors from Airflow
One of the things I really like about this approach is that you get the full power of dbt’s selector syntax passed straight through the ARGS parameter. You don’t have to run the entire project every time.
Run only staging models:
EXECUTE_STAGING_ONLY = """
EXECUTE DBT PROJECT analytics_db.transforms.sales_dbt_project
ARGS = 'dbt run --select staging.*'
VERSION = 'LAST';
"""
Run a specific model and all its downstream dependencies:
RUN_DBT_TESTS = """
EXECUTE DBT PROJECT analytics_db.transforms.sales_dbt_project
ARGS = 'dbt test --select staging.*'
VERSION = 'LAST';
"""
This means you can split a single DAG into multiple tasks — one for staging, one for marts, one for tests — and get granular retry behavior in Airflow if something fails mid-pipeline. Instead of rerunning everything, Airflow retries only the failed task.
This is how I actually run it in practice. If staging tests fail, marts never execute. If marts fail, I retry marts without re-running staging. Clean dependency management with minimal code.
Step 6: Handling New Versions of Your dbt Project
This is something I didn’t think about until I pushed a breaking change to main and my 6 AM pipeline executed the wrong version.
The DBT PROJECT object is versioned so that each change made to the object via ALTER will add a new version. The versions are named according to the pattern VERSION$<num>.
In practice, your CI/CD pipeline (GitHub Actions, etc.) should update the DBT PROJECT object after any merge to main:
And in your Airflow SQL, VERSION = 'LAST' always picks up the most recently deployed version automatically. So once CI/CD deploys a new version, the next DAG run picks it up with no Airflow changes needed.
Step 7: Monitoring — What to Watch and Where
Before this setup, I was watching three screens at once when something went wrong. Now it’s mostly one.
In Snowsight:
-- Check recent dbt project execution history
SELECT
query_id,
query_text,
execution_status,
start_time,
end_time,
DATEDIFF('second', start_time, end_time) AS duration_seconds,
error_message
FROM TABLE(
INFORMATION_SCHEMA.QUERY_HISTORY(
END_TIME_RANGE_START => DATEADD('day', -1, CURRENT_TIMESTAMP()),
RESULT_LIMIT => 50
)
)
WHERE query_text ILIKE '%EXECUTE DBT PROJECT%'
ORDER BY start_time DESC;
Row count drift detection (add this as an Airflow task):
-- Compare today's mart row count to yesterday's
-- Flag if it drops more than 20%
WITH today AS (
SELECT COUNT(*) AS cnt
FROM analytics_db.marts.fct_daily_orders
WHERE order_date = CURRENT_DATE() - 1
),
yesterday AS (
SELECT COUNT(*) AS cnt
FROM analytics_db.marts.fct_daily_orders
WHERE order_date = CURRENT_DATE() - 2
)
SELECT
today.cnt AS today_rows,
yesterday.cnt AS yesterday_rows,
ROUND((today.cnt - yesterday.cnt) / NULLIF(yesterday.cnt, 0) * 100, 2) AS pct_change,
CASE
WHEN today.cnt < yesterday.cnt * 0.80
THEN 'ALERT: Row count dropped over 20%'
ELSE 'OK'
END AS status
FROM today, yesterday;
I added this query as a SQLExecuteQueryOperator task right after the mart validation step. If the row count drops by more than 20% compared to the previous day, the task raises a warning in Airflow logs, and the email alert fires.
Not every data quality problem shows up as a dbt test failure. Sometimes the data just quietly shrinks because an upstream feed stopped delivering. This catches that.
What This Setup Actually Changed for Me
I want to be real about this because I think the “benefits” sections in most blog posts are too abstract.
Before: My pipeline had six moving parts. Airflow DAG on one server. dbt installed on a separate instance. profiles.yml with credentials that needed updating every time we rotated passwords. Separate monitoring in CloudWatch for the dbt server. Debugging a failure meant SSH → dbt server → find the log file → cross-reference with Airflow logs.
After: The pipeline has three moving parts — Airflow, Snowflake, and GitHub. The dbt credentials are managed by Airflow’s Snowflake connection, which I was already maintaining. Debugging a failure means clicking into the Airflow task logs (which capture the SQL response from Snowflake) and if I need more detail, running the QUERY_HISTORY query above in Snowsight.
Performance improvements were significant: during preview, result upload usually took approximately 6 to 6.5 minutes. Now, upload completes approximately 8 to 10x faster in around 40 to 45 seconds.
The startup time improvement alone was worth it for me. My morning pipeline used to take 28-32 minutes. It now consistently runs in 18-22 minutes. That’s not from faster models — it’s from the reduction in environment spin-up overhead.
A Few Gotchas I Hit Along the Way
1. The EXECUTE DBT PROJECT command is synchronous by default. Airflow will wait for it to complete before marking the task done. For large projects this is fine — you want that behavior. Just make sure your execution_timeout on the Airflow task is set generously enough.
2. Cross-project references don’t work the way you might expect. Cross-project dependencies must be copied into the root of the main project — Snowflake doesn’t support references to external file paths within the DBT PROJECT object. If you have multiple dbt projects, plan your consolidation before deploying.
3. The VERSION = 'LAST' behavior. This always runs the most recently deployed version. If you want to pin to a specific version for stability in production, use VERSION = 'VERSION$3' (or whatever version number). I run LAST in dev and a pinned version in prod, deployed via CI/CD.
4. Warehouse auto-resume and the first task. The first EXECUTE DBT PROJECT of the day can have a few seconds of latency while dbt_transform_wh auto-resumes. I added a lightweight warm-up query as the very first task in my DAG so the warehouse is already running by the time dbt build kicks off:
Costs almost nothing. Saves 5-10 seconds of variability at the start of every run.
Why I Think This Is the Right Direction
I started exploring this because nobody told me to. My team’s existing setup worked. A reasonable person would have left it alone.
But the more I looked at this setup, the more I kept thinking about the overhead we carry when tools don’t talk to each other natively. Every boundary between systems is a place where credentials leak, latency is added, and debugging gets harder. The native dbt project in Snowflake closes one of those boundaries. Airflow still owns orchestration — which is where it belongs — but the transformation execution lives where the data lives.
For the growing number of organizations that have standardized on Snowflake, the native integration offers something genuinely compelling: one fewer system to run, one fewer vendor to manage, and one fewer boundary between your data and the logic that transforms it.
That sentence landed for me when I read it. That’s exactly what this is.
If you’ve been running dbt Core on a server and Airflow alongside it and you’ve been tolerating that overhead long enough that you’ve stopped noticing it — try this weekend rebuild. You might be surprised how much lighter the pipeline feels on the other side.
And if you do try it and hit something weird, drop it in the comments. I’m still learning this myself.
It was a Tuesday morning when I finally snapped. My dbt project had grown to 147 models, and the daily run was taking 2 hours and 47 minutes. Our Airflow DAG was timing out. The business team was complaining about stale dashboards. And I was spending my entire morning investigating why dim_customer alone was taking 45 minutes to build.
I had tried everything: manual query optimization, clustering keys, switching materializations. Each fix helped a little, but I was basically guessing. Then someone on the data engineering Slack mentioned using Snowflake Cortex Code to analyze their dbt manifest file.
“Wait, it can do WHAT?” I asked.
That question changed my entire workflow. Three months later, my dbt runs average 1 hour 23 minutes—a 48% improvement. I spend 90% less time debugging performance. And I actually have time to build new features instead of firefighting slow models.
This isn’t a tutorial about how Cortex Code might help you. This is the real story of how it actually transformed my day-to-day work as a data engineer, with specific examples, exact prompts I use, and honest numbers about what works and what doesn’t.
Before I get into the dbt deep dive, let me explain what Cortex Code actually is—because the marketing doesn’t do it justice.
Cortex Code is code generation AI built directly into Snowflake. Think ChatGPT, but it:
Understands your Snowflake schema automatically
Knows dbt best practices
Can analyze JSON files (like manifest.json)
Generates production-ready SQL, Python, and more
Lives where you already work (Snowflake UI, or via API)
How it’s different from GitHub Copilot or ChatGPT:
Feature
Cortex Code
GitHub Copilot
ChatGPT
Knows your Snowflake schema
✅ Yes
❌ No
❌ No
Can read manifest.json
✅ Yes
❌ No
⚠️ Manual paste
Snowflake-specific SQL
✅ Optimized
⚠️ Generic
⚠️ Generic
dbt best practices
✅ Built-in
⚠️ Learns from code
⚠️ General knowledge
Privacy/Security
✅ Snowflake environment
⚠️ Code leaves editor
❌ Data uploaded
The key difference for data engineers: Cortex Code actually understands your data warehouse context.
Part 2: Getting Started (5-Minute Setup)
Step 1: Enable Cortex Code
Cortex Code is available in Snowflake (check your edition—Enterprise or higher typically has it).
Step 1: Enable Cortex Code
Cortex Code is available in Snowflake (check your edition—Enterprise or higher typically has it).
-- Check if you have access
SELECT SYSTEM$GET_CORTEX_FEATURES();
-- If available, you're good to go
-- No additional setup needed
Step 2: First Test
How to Access Cortex Code:
Open Snowsight (Snowflake UI)
Look for the “AI Assistant” or “Cortex Code” button (usually in the sidebar or bottom-right)
Type your prompt in natural language
Get generated code instantly
Example first prompt:
Generate SQL to find top 10 customers by revenue from my customers and orders tables
Cortex Code responds with:
SELECT
c.customer_id,
c.customer_name,
SUM(o.order_amount) as total_revenue
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name
ORDER BY total_revenue DESC
LIMIT 10;
That’s it. No installation, no API keys, no configuration. Just natural language prompts.
My first “wow” moment: I typed “generate a complete dbt model for customer lifetime value with staging, intermediate, and mart layers” and it produced three properly structured models with naming conventions, tests, and documentation. Took 30 seconds.
Part 3: Core Capabilities (The Quick Tour)
Before we dive deep into dbt, here’s what Cortex Code can do:
Before SQL Generation:
Complex queries with CTEs, window functions, aggregations
Query optimization suggestions
Data quality checks
dbt Development:
Model generation (staging, marts, facts, dimensions)
Yes, Cortex Code can generate dbt models. Ask it for a staging model, it’ll give you:
-- Example prompt: "Generate dbt staging model for raw_customers"
-- models/staging/stg_customers.sql
with source as (
select * from {{ source('raw', 'customers') }}
),
renamed as (
select
customer_id,
customer_name,
email,
created_at,
updated_at
from source
)
select * from renamed
But honestly? That’s the boring stuff. Any code generation tool can do this. Where Cortex Code becomes indispensable is performance optimization using your actual dbt metadata.
4.2: Performance Optimization – The Killer Feature
This is where I went from “this is neat” to “I can’t work without this anymore.”
Daily Airflow timeout failures: 2-3 times per week
Time spent debugging performance: 6-8 hours per week
I had no systematic way to know:
Which models were actually slow?
Why were they slow?
What should I optimize first?
Were my optimizations working?
I was flying blind, making educated guesses based on gut feeling and manual timing of individual models.ed on gut feeling and manual timing of individual models.
A) Manifest.json Analysis – The Secret Weapon
Your dbt project generates a manifest.json file in the target/ folder after every run. It contains:
Every model’s metadata
Dependencies between models
Column information
Schema details
I never really looked at it. It’s thousands of lines of JSON. Until Cortex Code.
How to use it:
Step 1: Upload manifest.json to Snowflake
-- Create a stage for your dbt metadata
CREATE STAGE IF NOT EXISTS dbt_metadata;
-- Upload the file (via SnowSQL or Snowsight UI)
PUT file://~/dbt_project/target/manifest.json @dbt_metadata/;
Step 2: Open Cortex Code interface in Snowsight
Click on the “AI Assistant” or “Cortex Code” button in Snowsight
This opens the natural language interface
Step 3: Ask Cortex Code to analyze it
Type this prompt in the Cortex Code interface:
Analyze the manifest.json file in my dbt_metadata stage and identify the top 10 slowest models with specific optimization recommendations.
Focus on:
- Materialization strategies (table vs incremental)
- Clustering opportunities
- Complex CTEs that could be simplified
- Join patterns that could be optimized
Provide specific code changes and estimated time savings for each recommendation.
Step 4: Review the analysis
Cortex Code responds with detailed analysis (example of what I got):
Top 10 Slowest Models Analysis:
1. dim_customer (45.2 minutes)
Issues identified:
- Full table scan on 50M+ rows daily
- No clustering key on frequently filtered columns
- Using table materialization when incremental would work
- 3 CTEs doing similar aggregations (could be deduplicated)
Recommendations:
- Add clustering key on (customer_id, updated_date)
- Convert to incremental materialization
- Use dbt_utils.deduplicate macro
- Estimated improvement: 35-40 minutes
2. fact_orders_daily (23.7 minutes)
Issues identified:
- Window functions not optimized
- Joining to dim_customer before aggregation
- Missing partition by date
Recommendations:
- Aggregate first, then join
- Add partition_by clause to window functions
- Consider separate monthly partitions
- Estimated improvement: 15-18 minutes
3. mart_customer_360 (18.4 minutes)
...
I was stunned. This analysis would have taken me days to do manually—if I even knew where to start. Cortex Code did it in 30 seconds.
B) Implementing the Recommendations
Let me show you exactly what I did for dim_customer:
Before (45 minutes):
-- models/marts/dim_customer.sql
{{
config(
materialized='table'
)
}}
with customers as (
select * from {{ ref('stg_customers') }}
),
orders as (
select * from {{ ref('fct_orders') }}
),
aggregated as (
select
c.customer_id,
c.customer_name,
c.email,
c.created_at,
count(o.order_id) as total_orders,
sum(o.order_amount) as lifetime_value,
max(o.order_date) as last_order_date
from customers c
left join orders o on c.customer_id = o.customer_id
group by 1,2,3,4
)
select * from aggregated
After (8 minutes) following Cortex Code suggestions:
-- models/marts/dim_customer.sql
{{
config(
materialized='incremental',
unique_key='customer_id',
cluster_by=['customer_id', 'updated_date'],
on_schema_change='append_new_columns'
)
}}
with customers as (
select * from {{ ref('stg_customers') }}
{% if is_incremental() %}
where updated_date >= (select max(updated_date) from {{ this }})
{% endif %}
),
orders_aggregated as (
-- Aggregate BEFORE joining (Cortex suggestion!)
select
customer_id,
count(order_id) as total_orders,
sum(order_amount) as lifetime_value,
max(order_date) as last_order_date
from {{ ref('fct_orders') }}
{% if is_incremental() %}
where order_date >= (select max(last_order_date) from {{ this }})
{% endif %}
group by customer_id
),
final as (
select
c.customer_id,
c.customer_name,
c.email,
c.created_at,
c.updated_date,
coalesce(o.total_orders, 0) as total_orders,
coalesce(o.lifetime_value, 0) as lifetime_value,
o.last_order_date
from customers c
left join orders_aggregated o on c.customer_id = o.customer_id
)
select * from final
Changes made:
✅ Switched to incremental materialization
✅ Added clustering keys on customer_id and updated_date
✅ Aggregated orders before joining (huge win!)
✅ Added incremental logic to only process new/changed data
The run_results.json file contains actual execution times and metadata from your last dbt run. Even more valuable than manifest for performance debugging.
My weekly performance review process:
-- Upload run_results from this week and last week
PUT file://~/dbt_project/target/run_results.json @my_stage/current/;
PUT file://~/dbt_project_backup/target/run_results.json @my_stage/previous/;
Example output:
Performance Regression Analysis:
CRITICAL REGRESSIONS (>50% slower):
1. mart_sales_summary
- Previous: 4.2 min
- Current: 9.8 min (+133%)
- Root cause: Source table fct_sales grew from 10M to 25M rows
- Recommendation: Add incremental logic with date partitioning
2. dim_product
- Previous: 2.1 min
- Current: 5.4 min (+157%)
- Root cause: New join to external API table (no clustering)
- Recommendation: Materialize API data first, add clustering key
MODERATE REGRESSIONS (20-50% slower):
3. stg_orders
- Previous: 1.2 min
- Current: 1.6 min (+33%)
- Root cause: New data quality test added (full table scan)
- Recommendation: Convert test to incremental or sampling
IMPROVEMENTS:
1. dim_customer: 45 min → 8 min (-82%) ✅ [Your optimization worked!]
2. fact_orders_daily: 23 min → 12 min (-48%) ✅
NEW BOTTLENECKS:
- mart_customer_cohort now takes 14 min (wasn't slow before)
- Likely due to dim_customer changes propagating downstream
- Recommendation: Review joins, consider pre-aggregation
This is gold. I immediately know what broke, why, and how to fix it.
D) Automated Performance Audits
I set up a weekly routine every Monday morning using Cortex Code:
My Monday Morning Workflow:
Run my standardized audit prompt
Upload latest manifest and run_results (automated via simple Python script)
Open Cortex Code interface
Perform a comprehensive dbt performance audit using the manifest.json and run_results.json in my dbt_metadata stage:
Analysis needed:
1. Identify slowest 15 models with root cause analysis
2. Detect performance anti-patterns:
- Models using full refresh that should be incremental
- Missing clustering keys on large tables
- Inefficient join patterns
- Unnecessary full table scans
3. Find models that should be incremental but aren't
4. Suggest clustering keys based on filter/join patterns in SQL
5. Recommend materialization strategies (table vs view vs incremental)
6. Calculate estimated monthly compute time savings for each recommendation
7. Rank by effort/impact ratio (quick wins vs long-term projects)
Format as prioritized action plan with:
- Quick wins (high impact, <1 hour effort)
- Medium effort items (2-4 hours)
- Strategic improvements (>4 hours)
- Estimated ROI for each
Sample output from last Monday:
dbt Performance Audit - 2026-01-20
QUICK WINS (High Impact, Low Effort):
1. Add clustering to dim_geography on (country_code, region_id)
- Current: 6.2 min | Estimated after: 1.5 min | Effort: 5 min
- Impact: Save 4.7 min per run = 33 hours/month
2. Convert fct_user_sessions to incremental
- Current: 11.3 min | Estimated after: 2.1 min | Effort: 20 min
- Impact: Save 9.2 min per run = 65 hours/month
MEDIUM EFFORT, HIGH IMPACT:
3. Refactor mart_product_performance (split into 2 models)
- Current: 18.7 min | Estimated after: 7 min + 4 min | Effort: 2 hours
- Impact: Save 7.7 min per run = 54 hours/month
ANTI-PATTERNS DETECTED:
⚠️ 8 models using table materialization with full refresh daily (should be incremental)
⚠️ 4 models missing clustering keys despite large table scans
⚠️ 2 models with circular dependency risk
TOTAL ESTIMATED MONTHLY SAVINGS: 247 hours of compute time
TOTAL IMPLEMENTATION EFFORT: ~8 hours
ROI: 30:1
I tackle 2-3 items from this list each week. In three months, I’ve saved 48% of total dbt runtime.
4.3: Real Production Examples
Let me share three actual scenarios where Cortex Code saved my ass:
Example 1: The Mysterious Slowdown
Problem:mart_revenue_daily suddenly went from 5 minutes to 32 minutes. No code changes, just suddenly slow.
What I did:
Analyze this slow dbt model. It suddenly went from 5 minutes to 32 minutes with no code changes.
Context:
- Model name: mart_revenue_daily
- Previous runtime: 5 min
- Current runtime: 32 min
- No changes to model SQL in past month
- Fact table grew from 50M to 180M rows last week
Here's the compiled SQL from dbt:
[I pasted the full compiled SQL here]
Please:
1. Identify the root cause of slowdown
2. Point to specific problematic lines in the SQL
3. Provide concrete fix with rewritten SQL
4. Estimate performance improvement
Cortex Code’s Response:
Root Cause Identified:
The model joins fact_transactions (now 180M rows) to dim_date without filtering first.
Line 47-52 in your SQL:
```sql
left join {{ ref('dim_date') }} d
on t.transaction_date = d.date_day
```
With 180M rows, this creates a massive intermediate result set before the WHERE clause on line 68.
Solution:
1. Move the date filter BEFORE the join (filter fact_transactions first)
2. Add clustering key on fact_transactions.transaction_date
3. Consider partitioning by month if queries typically filter by date range
Estimated improvement: 32 min → 6 min
Implementation:
-- Changed from this:
from {{ ref('fact_transactions') }} t
left join {{ ref('dim_date') }} d on t.transaction_date = d.date_day
where d.fiscal_year = 2024
-- To this:
from (
select * from {{ ref('fact_transactions') }}
where transaction_date >= '2024-01-01'
) t
left join {{ ref('dim_date') }} d on t.transaction_date = d.date_day
Plus added clustering:
{{ config(cluster_by=['transaction_date']) }}
Result: 32 minutes → 5.2 minutes. Problem solved in 20 minutes total.
Example 2: Pre-Launch Optimization
Scenario: Building a new mart_customer_churn_prediction model. Wanted to optimize it BEFORE the first production run.
I'm about to deploy this new dbt model to production. Please review and suggest optimizations BEFORE I run it for the first time.
Model: mart_customer_churn_prediction
Focus areas:
- Best materialization strategy (table, view, or incremental)
- Clustering key recommendations
- Potential bottlenecks in the SQL
- Incremental logic if applicable
- Query optimization opportunities
Here's the model SQL:
[pasted my model code]
Expected data volume: 50M+ customer records
Update frequency: Daily
Query pattern: Filtered by customer_segment and churn_date
Cortex Code’s Pre-Production Analysis:
Pre-Production Optimization Analysis:
CRITICAL ISSUES:
1. ❌ Using table materialization for 50M+ row result
Recommendation: Use incremental with unique_key on customer_id
2. ❌ No clustering key despite filtering on customer_segment and churn_date
Recommendation: cluster_by=['customer_segment', 'churn_date']
3. ❌ Complex CTE chain (7 CTEs) doing full table scans
Recommendation: Break into separate intermediate models
PERFORMANCE PREDICTIONS:
- As written: Estimated 45-60 minutes first run
- With optimizations: Estimated 8-12 minutes first run, 2-3 min incremental
SUGGESTED REFACTOR:
Split into 3 models:
1. int_customer_metrics (intermediate, incremental)
2. int_churn_features (intermediate, incremental)
3. mart_customer_churn_prediction (mart, incremental)
I implemented all suggestions. First production run: 9.3 minutes instead of the estimated 45-60.
Example 3: Monthly Performance Review
Every month, I do a comprehensive audit:
Step 1: Collect all metadata files
# My automation script copies these
cp ~/dbt_project/target/manifest.json ~/monthly_audits/2026-01/
cp ~/dbt_project/target/run_results.json ~/monthly_audits/2026-01/
Step 2: Upload to Snowflake
PUT file://~/monthly_audits/2026-01/* @dbt_metadata/monthly/2026-01/;
Step 3: Open Cortex Code and run monthly audit
Monthly dbt Performance Review - January 2026
Using files in dbt_metadata/monthly/2026-01/:
- manifest.json
- run_results.json
Provide comprehensive analysis:
1. HEALTH METRICS
- Overall project health score (0-100)
- Total models and average runtime
- Percentage using best practices (incremental, clustering)
- Month-over-month performance trend
2. TOP ISSUES
- 10 slowest models with root cause
- Performance anti-patterns detected
- Models that grew disproportionately
- Technical debt items
3. CLEANUP OPPORTUNITIES
- Unused or rarely-run models
- Outdated materializations
- Redundant transformations
- Models that can be archived
4. OPTIMIZATION ROADMAP
- Week-by-week action plan for next month
- Quick wins vs strategic improvements
- Estimated time savings and effort required
- Projected end-of-month performance
5. ROI CALCULATIONS
- Current monthly compute cost
- Potential savings from recommendations
- Effort/impact ratio for each item
January 2026 Audit Output:
dbt Project Health Score: 73/100 (Up from 61 last month)
PERFORMANCE SUMMARY:
- Total models: 147
- Average model runtime: 3.2 min (down from 5.1 min)
- Slowest model: dim_customer_360 (14.2 min)
- Models using incremental: 67% (target: 80%)
- Models with clustering: 45% (target: 70%)
TOP 10 ISSUES:
1. dim_customer_360 (14.2 min) - needs incremental + clustering
2. mart_sales_forecast (12.8 min) - complex window functions, consider simplification
3. fct_website_sessions (11.4 min) - full refresh daily, should be incremental
...
OPTIMIZATION ROADMAP - FEBRUARY 2026:
Week 1: Add clustering to 8 identified models (est. save 45 min/run)
Week 2: Convert 6 models to incremental (est. save 67 min/run)
Week 3: Refactor mart_sales_forecast (est. save 8 min/run)
Week 4: Remove 4 unused models identified
Projected end-of-month runtime: 58 minutes (current: 83 minutes)
Following this roadmap, I hit 61 minutes by month-end.
4.4: My Daily Workflow with Cortex Code
Here’s how Cortex Code fits into my actual workday:
Monday Morning (9:00 AM) – Weekly Review:
Upload latest manifest.json and run_results.json
Run performance audit
Create Jira tickets for top 3 optimization opportunities
Prioritize for the week
Tuesday-Thursday – Development:
Need a new model?
Ask Cortex Code to generate boilerplate
Review and customize for business logic
Ask Cortex to optimize before first run
Model running slow?
Share compiled SQL with Cortex
Get optimization suggestions
Implement and test
Friday Afternoon – Cleanup:
Review week’s changes in dbt
Ask Cortex to review my new models for anti-patterns
Generate documentation with Cortex assistance
Prepare for Monday’s review
Time saved per week:
Before: 8-10 hours on performance debugging
After: 1-2 hours on Cortex-assisted optimization
Net savings: 6-8 hours weekly
4.5: Prompts That Actually Work
Here are my most-used prompts, copy-paste ready:
Performance Analysis:
"Analyze this manifest.json and identify the top 10 slowest models with specific, actionable optimization recommendations ranked by estimated time savings."
"Compare these two run_results.json files (last week vs this week) and identify performance regressions, improvements, and new bottlenecks. Prioritize by impact."
"This model runs in X minutes. Here's the compiled SQL: [paste]. Provide optimization suggestions with estimated impact for each."
Model Optimization:
"Review this dbt model and suggest: 1) Best materialization strategy, 2) Clustering keys, 3) Incremental logic if applicable, 4) Query optimizations. Model: [paste]"
"I'm building a new model for [business purpose]. Suggest optimal dbt structure including staging, intermediate, and mart layers with proper materializations."
Debugging:
"This dbt model suddenly got slow. Root cause analysis based on: Compiled SQL: [paste], Recent changes: [describe], Data volume changes: [numbers]"
"Why is this incremental model doing full refreshes? Model config: [paste], Logs: [paste]"
Ongoing Monitoring:
"Monthly dbt health audit. Analyze manifest + run_results. Provide: health score, top 10 issues, optimization roadmap. Files: [paste]"
"Identify unused or rarely-run models in this manifest that could be archived. Criteria: run less than once per week, not referenced by marts."
4.6: What Works vs. What Doesn’t
After 3 months of daily use, here’s my honest assessment:
Performance impact in production (not just estimated)
4.7: Real Numbers from My Experience
Let me share the actual metrics that matter:
Before Cortex Code (December 2025):
dbt Performance:
Full refresh runtime: 2h 47min
Incremental runtime: 1h 15min
Models with clustering: 12/147 (8%)
Models using incremental: 42/147 (29%)
Airflow timeout failures: 2-3/week
My Time Spent:
Performance debugging: 8-10 hours/week
Manual manifest review: Never (too tedious)
Optimization work: Ad-hoc, reactive
New model development: 45-60 min per model
Costs:
Snowflake compute (dbt): ~$1,200/month
Airflow retries/failures: ~$180/month
My time opportunity cost: Unmeasured but significant
After 3 Months with Cortex Code (March 2026):
dbt Performance:
Full refresh runtime: 1h 23min (-50%)
Incremental runtime: 34min (-55%)
Models with clustering: 67/147 (46%)
Models using incremental: 99/147 (67%)
Airflow timeout failures: 1-2/month
My Time Spent:
Performance debugging: 1-2 hours/week (-85%)
Weekly manifest review: 15 min (automated with Cortex)
Optimization work: Systematic, proactive
New model development: 15-20 min per model (-67%)
Costs:
Snowflake compute (dbt): ~$680/month (-43%)
Airflow retries/failures: ~$35/month (-81%)
My time regained: 6-8 hours/week for feature development
ROI Calculation:
Cost of Cortex Code: Included in Snowflake Enterprise+ (no additional cost)
Monthly savings:
Snowflake compute: $520
Airflow compute: $145
My time (40 hours/month @ $75/hour): $3,000
Total: $3,665/month
Annual ROI: $43,980 in quantifiable savings
Plus unquantifiable benefits:
Faster feature delivery
Better data quality (more time for testing)
Reduced incidents and pages
Improved team morale (less firefighting)
Part 5: Quick Wins – Other Tools
While dbt is where I get the most value, Cortex Code is useful for other data engineering tools too:
5.1: Airflow DAGs
Quick generation using Cortex Code interface:
Generate a production-ready Airflow DAG for my daily dbt run:
Requirements:
- Schedule: Run daily at 2 AM UTC
- Retry logic: 3 attempts with 5-minute delay between retries
- Send Slack alert on failure (webhook already configured)
- Trigger downstream Tableau refresh on success
- Include SLA monitoring (alert if takes >90 minutes)
- Use dbt Cloud operator
- Handle task dependencies properly
Python version: 3.9
Airflow version: 2.7
Cortex Code generates:
from airflow import DAG
from airflow.operators.bash import BashOperator
from airflow.operators.python import PythonOperator
from airflow.providers.slack.notifications.slack import send_slack_notification
from datetime import datetime, timedelta
default_args = {
'owner': 'data-engineering',
'depends_on_past': False,
'email_on_failure': False,
'retries': 3,
'retry_delay': timedelta(minutes=5),
'sla': timedelta(minutes=90),
}
dag = DAG(
'daily_dbt_run',
default_args=default_args,
description='Daily dbt production run',
schedule='0 2 * * *', # 2 AM UTC daily
start_date=datetime(2026, 1, 1),
catchup=False,
on_failure_callback=send_slack_notification(
text='❌ dbt DAG failed!',
channel='#data-alerts'
),
)
dbt_run = BashOperator(
task_id='dbt_run',
bash_command='cd /opt/dbt && dbt run --profiles-dir .',
dag=dag,
)
dbt_test = BashOperator(
task_id='dbt_test',
bash_command='cd /opt/dbt && dbt test --profiles-dir .',
dag=dag,
)
refresh_tableau = BashOperator(
task_id='refresh_tableau',
bash_command='curl -X POST https://tableau-server/api/refresh',
dag=dag,
)
dbt_run >> dbt_test >> refresh_tableau
Gets you 80% of the way there in 30 seconds vs. 30 minutes of boilerplate typing.
5.2: Streamlit Dashboards
Dashboard scaffolding with Cortex Code:
Create a Streamlit dashboard for sales analytics:
Features needed:
- Date range filter (last 7, 30, 90 days or custom)
- KPI cards showing: Total Revenue, Total Orders, Average Order Value
- Line chart: Daily revenue trend over selected period
- Bar chart: Top 10 products by revenue
- Data table: Recent orders with filters
- Connect to Snowflake table: analytics.mart_daily_sales
- Use Streamlit's modern layout with columns
- Include data refresh timestamp
- Add export to CSV button
Make it production-ready with proper error handling.
Cortex Code generates:
import streamlit as st
import snowflake.connector
import pandas as pd
import plotly.express as px
from datetime import datetime, timedelta
st.set_page_config(page_title="Sales Analytics", layout="wide")
# Snowflake connection
@st.cache_resource
def get_connection():
return snowflake.connector.connect(
user=st.secrets["snowflake"]["user"],
password=st.secrets["snowflake"]["password"],
account=st.secrets["snowflake"]["account"],
warehouse='ANALYTICS_WH',
database='ANALYTICS',
schema='MART'
)
# Main dashboard
st.title("📊 Sales Analytics Dashboard")
# Date filter
col1, col2 = st.columns([3, 1])
with col1:
date_range = st.selectbox(
"Select Period",
["Last 7 Days", "Last 30 Days", "Last 90 Days", "Custom"]
)
# ... [rest of dashboard code]
Generates working dashboard layout. Still need to refine styling and business logic, but saves 45 minutes of setup.
(Keeping these brief since the focus is dbt – but both are genuinely useful)
Part 6: Practical Tips for Data Engineers
The Learning Curve
Week 1: Feels magical but you don’t trust it
Generate code, read every line carefully
Validate everything in dev
Probably slower than writing manually
Week 2-4: Building confidence
Start recognizing patterns in good vs. questionable output
Develop your own prompt templates
20-30% faster than before
Month 2+: It’s part of your workflow
Know when to use it vs. when to write manually
Can spot hallucinations immediately
50-70% faster on routine tasks
My Validation Checklist
Before deploying Cortex-generated code:
✅ Logic review: Does this make business sense?
✅ Performance check: Run EXPLAIN on generated SQL
✅ Edge cases: Test with null values, duplicates, empty sets
✅ Incremental logic: Validate deduplication and update logic
✅ Dependencies: Check for circular references
✅ Tests: Generated code needs generated tests
✅ Peer review: Treat AI code like any other PR
Learning new concepts (defeats the learning purpose)
Sometimes use for:
Debugging (helpful but verify root cause)
Refactoring (good starting point, heavy review)
Documentation (generates good drafts)
Always use for:
Boilerplate (staging models, tests, yml)
Performance analysis (manifest reviews)
Exploration (trying new patterns)
Part 7: The Honest Verdict
For dbt Specifically:
Model Generation: 8/10
Great for standard patterns
Saves typing, enforces conventions
Still need to add business logic
Test Creation: 9/10
Covers standard tests well
Good at identifying what to test
Custom tests need review
Manifest Analysis: 10/10 ⭐⭐⭐
This alone justifies using Cortex Code
Finds issues I’d never spot manually
Actionable, prioritized recommendations
Performance Optimization: 9/10
Suggestions are usually right
Massive time savings
Estimates are reasonably accurate
Macro Writing: 7/10
Good starting point
Logic sometimes over-complicated
Requires Jinja knowledge to review properly
Documentation: 8/10
Generates good yml drafts
Descriptions are generic but fixable
Saves tons of tedious typing
Overall Assessment:
Is Cortex Code worth it for data engineers?
Absolutely yes, with caveats:
✅ Use it if you:
Work with dbt daily
Have performance challenges
Want to spend less time on boilerplate
Value systematic optimization over guesswork
Are comfortable reviewing and validating AI output
⚠️ Be cautious if you:
Are still learning dbt (use it, but understand what it generates)
Have highly specialized/unusual patterns
Work in heavily regulated industry (extra validation needed)
Have very small dbt projects (<20 models – manual is fine)
❌ Skip it if you:
Don’t have Snowflake Enterprise+
Rarely write dbt code
Prefer full manual control (totally valid!)
The Real Value Proposition
It’s not about writing code faster (though that’s nice).
It’s about:
Systematic performance optimization instead of guesswork
Proactive monitoring instead of reactive firefighting
Data-driven decisions about what to optimize
Consistent code quality through enforced best practices
More time for high-value work instead of debugging
My Recommendation
Start small:
Week 1: Try manifest analysis only
Week 2: Generate a few staging models
Week 3: Use for performance debugging
Week 4: Incorporate into daily workflow
By month 2, you’ll wonder how you lived without it.
Conclusion: The Tool That Changed My Workflow
Three months ago, I was drowning in performance issues, spending my days debugging slow dbt models and my nights fixing Airflow timeouts.
Today, my dbt runs 48% faster, I spend 85% less time on performance debugging, and I actually have time to build new features instead of constantly firefighting.
Cortex Code didn’t just make me faster—it made me smarter about optimization. The manifest analysis taught me patterns I now recognize manually. The performance suggestions showed me best practices I’d never considered.
Is it perfect? No. Does it replace data engineering expertise? Definitely not. But used correctly, with proper validation and critical thinking, it’s become as essential to my workflow as dbt itself.
If you’re a data engineer using Snowflake and dbt, try the manifest analysis feature today. Upload your manifest.json, ask for performance recommendations, and see what it finds. I bet you’ll be shocked—I was.
And if you do try it, let me know what you discover. I’m always curious what performance wins other engineers are finding.
Now go optimize something. Your Airflow DAG will thank you.
In the world of data, consistency is king. Manually running scripts to fetch and process data is not just tedious; it’s prone to errors, delays, and gaps in your analytics. To build a reliable data-driven culture, you need automation. This is where building an automated ETL with Airflow and Python becomes a data engineer’s most valuable skill.
Apache Airflow is the industry-standard open-source platform for orchestrating complex data workflows. When combined with the power and flexibility of Python for data manipulation, you can create robust, scheduled, and maintainable pipelines that feed your analytics platforms with fresh data, day in and day out.
This guide will walk you through a practical example: building an Airflow DAG that automatically fetches cryptocurrency data from a public API, processes it with Python, and prepares it for analysis.
The Architecture: A Simple, Powerful Workflow
Our automated pipeline will consist of a few key components, orchestrated entirely by Airflow. The goal is to create a DAG (Directed Acyclic Graph) that defines the sequence of tasks required to get data from our source to its destination.
Here’s the high-level architecture of our ETL pipeline:
Public API: Our data source. We’ll use the free CoinGecko API to fetch the latest cryptocurrency prices.
Python Script: The core of our transformation logic. We’ll use the requests library to call the API and pandas to process the JSON response into a clean, tabular format.
Apache Airflow: The orchestrator. We will define a DAG that runs on a schedule (e.g., daily), executes our Python script, and handles logging, retries, and alerting.
Data Warehouse/Lake: The destination. The processed data will be saved as a CSV, which in a real-world scenario would be loaded into a data warehouse like Snowflake, BigQuery, or a data lake like Amazon S3.
Let’s get into the code.
Step 1: The Python ETL Script
First, we need a Python script that handles the logic of fetching and processing the data. This script will be called by our Airflow DAG. We’ll use a PythonVirtualenvOperator in Airflow, which means our script can have its own dependencies.
Create a file named get_crypto_prices.py in your Airflow project’s /include directory.
/include/get_crypto_prices.py
Python
import requests
import pandas as pd
from datetime import datetime
def fetch_and_process_crypto_data():
"""
Fetches cryptocurrency data from the CoinGecko API and processes it.
"""
print("Fetching data from CoinGecko API...")
url = "https://api.coingecko.com/api/v3/simple/price"
params = {
'ids': 'bitcoin,ethereum,ripple,cardano,solana',
'vs_currencies': 'usd',
'include_market_cap': 'true',
'include_24hr_vol': 'true',
'include_24hr_change': 'true'
}
try:
response = requests.get(url, params=params)
response.raise_for_status() # Raise an exception for bad status codes
data = response.json()
print("Data fetched successfully.")
# Process the JSON data into a list of dictionaries
processed_data = []
for coin, details in data.items():
processed_data.append({
'coin': coin,
'price_usd': details.get('usd'),
'market_cap_usd': details.get('usd_market_cap'),
'volume_24h_usd': details.get('usd_24h_vol'),
'change_24h_percent': details.get('usd_24h_change'),
'timestamp': datetime.now().isoformat()
})
# Create a pandas DataFrame
df = pd.DataFrame(processed_data)
# In a real pipeline, you'd load this to a database.
# For this example, we'll save it to a CSV in the local filesystem.
output_path = '/tmp/crypto_prices.csv'
df.to_csv(output_path, index=False)
print(f"Data processed and saved to {output_path}")
except requests.exceptions.RequestException as e:
print(f"Error fetching data from API: {e}")
raise
if __name__ == "__main__":
fetch_and_process_crypto_data()
Step 2: Creating the Airflow DAG
Now, let’s create the Airflow DAG that will schedule and run this script. This file will live in your Airflow dags/ folder.
We’ll use the @task decorator and the PythonVirtualenvOperator to create a clean, isolated task.
dags/crypto_etl_dag.py
Python
from __future__ import annotations
import pendulum
from airflow.models.dag import DAG
from airflow.operators.python import PythonVirtualenvOperator
with DAG(
dag_id="crypto_price_etl_pipeline",
start_date=pendulum.datetime(2025, 9, 27, tz="UTC"),
schedule="0 8 * * *", # Run daily at 8:00 AM UTC
catchup=False,
tags=["api", "python", "etl"],
doc_md="""
## Cryptocurrency Price ETL Pipeline
This DAG fetches the latest crypto prices from the CoinGecko API,
processes the data with Python, and saves it as a CSV.
""",
) as dag:
run_etl_task = PythonVirtualenvOperator(
task_id="run_python_etl_script",
python_callable_source="""
from include.get_crypto_prices import fetch_and_process_crypto_data
fetch_and_process_crypto_data()
""",
requirements=["pandas==2.1.0", "requests==2.31.0"],
system_site_packages=False,
)
This DAG is simple but powerful. Airflow will now:
Run this pipeline automatically every day at 8:00 AM UTC.
Create a temporary virtual environment and install pandas and requests for the task.
Execute our Python function to fetch and process the data.
Log the entire process, and alert you if anything fails.
Step 3: The Analytics Payoff
With our pipeline running automatically, we now have a consistently updated CSV file (/tmp/crypto_prices.csv on the Airflow worker). In a real-world scenario where this data is loaded into a SQL data warehouse, an analyst can now run queries to derive insights, knowing the data is always fresh.
An analyst could now answer questions like:
What is the daily trend of Bitcoin’s market cap?
Which coin had the highest percentage change in the last 24 hours?
How does trading volume correlate with price changes across different coins?
Conclusion: Build Once, Benefit Forever
By investing a little time to build an automated ETL with Airflow and Python, you create a resilient and reliable data asset. This approach eliminates manual, error-prone work and provides your analytics team with the fresh, trustworthy data they need to make critical business decisions. This is the core of modern data engineering: building automated systems that deliver consistent value.