Reference architecture · modern ELT stack
dbt + Airflow + Snowflake
Data is loaded raw into Snowflake, transformed inside Snowflake by dbt, and the whole run is scheduled and monitored by Airflow. Each tool does one job: Snowflake stores and computes, dbt defines the transformations, and Airflow decides when things run.
How a daily run flows
- Extract and load. Fivetran or Airbyte copy database and SaaS data into Snowflake
RAWschemas. Files land in S3 and Snowpipe loads them, and Kafka events arrive through Snowpipe Streaming. Nothing is transformed yet. - Land in RAW. Data arrives as-is, with JSON kept in
VARIANTcolumns. dbt declares these tables assourceswith freshness rules. - Airflow starts the transformation. The DAG waits for loads to finish (sensor or connector API), runs
dbt source freshness, thendbt build. With Cosmos, each model becomes its own Airflow task with its own retry. - dbt transforms inside Snowflake. dbt compiles models to SQL and runs them on
TRANSFORM_WH, building staging → intermediate → marts in DAG order. Tests run right after each model, and a failed test stops everything downstream of it. - Serve. BI tools query the marts through
BI_WH(multi-cluster for the morning rush). Snowpark notebooks, reverse ETL and data shares read the same tables. - Monitor. Airflow alerts on failures. dbt artifacts (
run_results.json) record timings and test results, and SnowflakeACCOUNT_USAGEplus resource monitors track cost.
Components and responsibilities
| Component | Responsibility | Key design choices |
|---|---|---|
| ELFivetran / Airbyte | Copy source data into Snowflake with no business logic | CDC for databases, incremental API syncs, one RAW schema per source |
| SFSnowpipe | Continuous file loading from an S3 external stage | Storage integration (no keys in SQL), auto-ingest via SQS, file sizes of 100–250 MB |
| SFSnowflake | Stores all layers; supplies compute for loads, dbt and BI | Separate warehouses per workload, transient RAW/staging tables, Time Travel on marts |
| dbtdbt Core | Defines every transformation, test and document as code | Staging/intermediate/marts layers, incremental facts, snapshots for SCD2, contracts on public marts |
| AFAirflow | Decides when things run, in what order, and what happens on failure | Cosmos for per-model tasks, retries, SLAs, Slack/email alerts, backfills by date |
| GitGitHub + CI | Version control and automated checks before merge | state:modified+ with --defer, SQLFluff linting, deploy on merge |
| BIBI / consumers | Read only from marts | Read-only REPORTER role, dedicated BI warehouse, exposures declared in dbt |
Snowflake layout
Databases and schemas
RAW -- loaded by Fivetran / Snowpipe
├─ ERP
├─ SALESFORCE
└─ EVENTS
ANALYTICS -- built by dbt
├─ STAGING
├─ INTERMEDIATE
├─ MARTS
└─ SNAPSHOTS
DBT_DEV_<USER> -- each developer's sandbox
CI_PR_<N> -- zero-copy clone per pull request
Roles and warehouses
role LOADER → write RAW → LOAD_WH (XS)
role TRANSFORMER → read RAW,
write ANALYTICS → TRANSFORM_WH (M)
role REPORTER → read MARTS → BI_WH (S, 1–4 clusters)
all custom roles roll up to SYSADMIN
service users: key-pair auth, no passwords
auto_suspend = 60 on every warehouse
Airflow DAG and dbt project
DAG with Cosmos
from cosmos import DbtTaskGroup, ProjectConfig, ProfileConfig
from cosmos.profiles import SnowflakePrivateKeyPemProfileMapping
profile = ProfileConfig(
profile_name="analytics", target_name="prod",
profile_mapping=SnowflakePrivateKeyPemProfileMapping(
conn_id="snowflake_transformer"),
)
with DAG("daily_elt", schedule="0 5 * * *",
start_date=datetime(2026, 1, 1), catchup=False,
default_args={"retries": 2}) as dag:
wait_load = FivetranSensor(task_id="wait_fivetran", ...)
freshness = BashOperator(task_id="freshness",
bash_command="dbt source freshness")
transform = DbtTaskGroup(group_id="dbt",
project_config=ProjectConfig("/opt/airflow/dbt/analytics"),
profile_config=profile)
wait_load >> freshness >> transform
dbt project structure
analytics/
├─ dbt_project.yml
├─ models/
│ ├─ staging/
│ │ ├─ erp/_erp__sources.yml
│ │ └─ erp/stg_erp__employees.sql
│ ├─ intermediate/
│ │ └─ int_dimemployee_prepared.sql
│ └─ marts/
│ ├─ dim_employee.sql
│ ├─ fct_payroll.sql -- incremental
│ └─ _marts.yml -- tests, contracts
├─ snapshots/employee_snapshot.yml
├─ macros/generate_schema_name.sql
├─ seeds/
└─ packages.yml -- dbt_utils
Change flow from laptop to production
DBT_DEV_<USER>→
Open a pull request→
CI clones prod, runs dbt build -s state:modified+ --defer→
Review and merge→
New dbt image deployed to Airflow→
Next scheduled run builds prod
Explaining it in an interview
The two-minute answer
“We use an ELT pattern. Fivetran and Snowpipe land raw data in Snowflake, and dbt transforms it in layers: staging cleans each source, intermediate holds the business logic, and marts publish tested facts and dimensions. Airflow orchestrates the run. It waits for the loads, checks source freshness, then runs dbt through Cosmos so each model is its own task with retries and alerts.
Snowflake gives us a separate warehouse for loading, transforming and BI, so the workloads never compete. Every change goes through a pull request, and CI builds only the modified models against a zero-copy clone of prod.”
- Why ELT and not ETL? Snowflake compute is elastic, so transforming inside it is simpler and keeps the raw data available for replay.
- Why Airflow if dbt has its own DAG? dbt only orders SQL models. Airflow coordinates the whole pipeline: loads, dbt, ML jobs, reverse ETL, alerts and backfills.
- Why Cosmos over a single BashOperator? You get per-model visibility and retries, so a failure reruns one model instead of the whole project.
- Alternatives worth naming: dbt Cloud's scheduler instead of Airflow; Snowflake tasks or dynamic tables for simple in-warehouse pipelines; Dagster for asset-based orchestration.