← All guides

Practical Data Engineering | Week 2

ETL vs ELT: Same Pipeline, Two Patterns

2026-09-23 · 9 min read

A technical comparison of ETL with Spark and ELT with dbt: code for both, how Airflow orchestrates each, cost and PII trade-offs, and how Microsoft Fabric supports both.

ETL vs ELT: Same Pipeline, Two Patterns

ETL and ELT use the same sources and serve the same dashboards. The difference is where the transformation runs, and that one decision changes your compute costs, your tooling, your team's skills and how you handle sensitive data.

Side by side

                   ETL                           ELT
Transform runs in  Spark cluster                 Warehouse compute
Warehouse holds    Clean, modeled data only      Raw + staging + modeled
Logic written in   PySpark / Scala               SQL + dbt (Jinja)
Reprocess from     Data lake raw zone            Raw tables in the warehouse
PII handling       Masked before it lands        Masking policies in warehouse
Best for           Heavy or non-SQL transforms   SQL-friendly analytics

ETL in practice: Spark cleans, then loads

Spark reads from the lake, applies every transformation, including masking PII, and writes only the finished table to the warehouse.

from pyspark.sql import functions as F

clean = (
    spark.read.parquet("/lake/bronze/orders/")
    .filter(F.col("order_status") != "TEST")
    .withColumn("email", F.sha2(F.col("email"), 256))
    .withColumn("amount", F.col("amount").cast("decimal(18,2)"))
    .dropDuplicates(["order_id"])
)

# Snowflake: write with the Spark connector
(
    clean.write.format("net.snowflake.spark.snowflake")
    .options(**sf_options)
    .option("dbtable", "ANALYTICS.FACT_ORDERS")
    .mode("append")
    .save()
)

# Microsoft Fabric: write a Delta table to the Lakehouse
clean.write.format("delta").mode("overwrite").saveAsTable("fact_orders")

ELT in practice: land raw, transform with dbt

Step 1: load the raw data into the warehouse unchanged.

COPY INTO raw.orders
FROM @raw_stage/orders/
FILE_FORMAT = (TYPE = 'CSV' SKIP_HEADER = 1 FIELD_OPTIONALLY_ENCLOSED_BY = '"')
ON_ERROR = 'ABORT_STATEMENT';

Step 2: a dbt staging model cleans and types the raw data.

-- models/staging/stg_orders.sql
select
    order_id,
    customer_id,
    cast(amount as number(18, 2))   as amount,
    try_to_timestamp(order_ts)      as order_ts,
    lower(trim(order_status))       as order_status
from {{ source('raw', 'orders') }}
where order_status <> 'TEST'

Step 3: an incremental mart model processes only new rows on each run.

-- models/marts/fct_orders.sql
{{ config(materialized='incremental', unique_key='order_id') }}

select *
from {{ ref('stg_orders') }}

{% if is_incremental() %}
where order_ts > (select max(order_ts) from {{ this }})
{% endif %}

Step 4: tests are declared in YAML and run as part of dbt build.

# models/marts/schema.yml
version: 2
models:
  - name: fct_orders
    columns:
      - name: order_id
        tests: [unique, not_null]
      - name: amount
        tests: [not_null]

Orchestrating both patterns with Airflow

The DAG structure is identical. Only the operators change.

from airflow.operators.bash import BashOperator
from airflow.providers.apache.spark.operators.spark_submit import SparkSubmitOperator
from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator

# ETL DAG
spark_transform = SparkSubmitOperator(
    task_id="spark_transform",
    application="/jobs/orders_clean.py",
    conn_id="spark_default",
)
spark_transform >> load_warehouse >> refresh_pbi

# ELT DAG
load_raw = SQLExecuteQueryOperator(
    task_id="load_raw",
    conn_id="snowflake_default",
    sql="sql/copy_raw_orders.sql",
)
dbt_build = BashOperator(
    task_id="dbt_build",
    bash_command="cd /opt/dbt/analytics && dbt build --target prod",
)
load_raw >> dbt_build >> refresh_pbi

Cost and performance trade-offs

Microsoft Fabric supports both

Decision guide

From my own work

My Python ETL jobs load incrementally into Oracle before Power BI ever sees the data. Classic ETL, and still the right call when the database is the source of truth and the transformations need procedural control.