LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Data Pipeline Patterns: ETL vs ELT, Streaming, Batch Processing, and Orchestration

data-engineeringkafkaflinkdbtairflowdagsterpipelines

Data pipelines move data from where it’s created to where it’s useful. That sounds simple, but the engineering space between “data is here” and “data is useful over there” spans an enormous range of architectures, trade-offs, and failure modes. This guide cuts through the terminology and gives you a mental model for choosing between ETL and ELT, streaming and batch, and the orchestration tools that tie it all together.

The Core Problem: Data Is Created Where It’s Not Useful

Operational data lives in transactional databases — PostgreSQL, MySQL, MongoDB — optimized for fast reads and writes of individual records. Analytical work needs the opposite: scan millions of rows, aggregate across dimensions, join across sources. These workloads conflict. Running a complex analytical query against your production OLTP database slows down your users.

A data pipeline solves this by copying and transforming data into a system optimized for analytics — a data warehouse (BigQuery, Snowflake, Redshift) or data lake (S3 + Parquet). The pipeline has three fundamental jobs:

  1. Extract: get data out of source systems
  2. Transform: reshape, clean, join, and aggregate it
  3. Load: put it into the destination

The question is in what order you do these, and how frequently.

ETL vs ELT

These acronyms describe where transformation happens.

ETL (Extract, Transform, Load)

Traditional approach: transform data before loading it into the destination.

Source DB → [Extract] → Staging Area → [Transform] → [Load] → Data Warehouse

When it made sense: Data warehouses used to be expensive per-GB. Loading raw, untransformed data wasted storage and compute. Transformation happened on a dedicated ETL server (Informatica, DataStage, Talend) before the warehouse ever saw the data.

Drawbacks: The transformation code is divorced from the warehouse, making it hard to debug. Schema changes in the source require updating the transformation logic. Re-processing historical data requires re-running the transformation from scratch. You lose the raw data once it’s transformed.

ELT (Extract, Load, Transform)

Modern approach: load raw data first, transform inside the destination.

Source DB → [Extract] → [Load raw] → Data Warehouse → [Transform] → Marts/Views

Why it works now: Modern cloud warehouses (BigQuery, Snowflake) have essentially unlimited storage and massively parallel compute. Loading raw data first is cheap. Transforming with SQL inside the warehouse uses the warehouse’s own distributed compute — often faster than a separate ETL server.

Advantages:

  • Raw data is always available — you can re-transform with new logic against the same raw data
  • Transformations are SQL in the warehouse — engineers already know SQL
  • Schema changes: just reload the raw data, re-run transformations
  • Easier debugging — query the raw tables directly

ELT is the default architecture for modern data engineering. Tools like dbt (data build tool) exist specifically to manage the transform step inside the warehouse.

When ETL Still Makes Sense

  • PII/compliance: must strip sensitive data before it ever lands in the warehouse
  • Legacy warehouses: cost-per-byte makes raw storage prohibitive
  • Complex transformations: ML feature engineering that isn’t expressible in SQL
  • Real-time: stream processing (Kafka/Flink) is essentially streaming ETL

Batch vs Streaming

The other fundamental dimension: how frequently do you move data?

Batch Processing

Data is collected over a period and processed in a single job. Common patterns:

  • Daily batch: run at midnight, process yesterday’s data
  • Hourly micro-batch: process the last hour every hour
  • Event-triggered batch: process when enough data accumulates

Trade-offs:

  • Simple: one job runs, succeeds or fails, you know the result
  • High latency: data can be hours old by the time it’s in the warehouse
  • Efficient: amortizes overhead across many records
  • Easy to replay: re-run with a date range parameter

Best for: reporting, dashboards, ML training, compliance exports — anything where 1-hour-old data is acceptable.

Streaming

Data is processed continuously as it arrives, record by record or in small micro-batches.

Trade-offs:

  • Low latency: data can be visible in seconds
  • Complex: ordering guarantees, exactly-once semantics, state management, late data
  • Expensive: always-running compute even during low-traffic periods
  • Harder to replay: need to re-process from Kafka offsets or source CDC events

Best for: fraud detection, real-time dashboards, alerting, personalization — anything where stale data causes real harm.

The Lambda Architecture (and Why to Avoid It)

The Lambda architecture tries to get the best of both worlds by running both a batch layer and a streaming layer in parallel, then merging results:

                    ┌──── Batch Layer (accurate, slow) ────┐
Data → Message Bus ─┤                                       ├─ Serving Layer
                    └──── Speed Layer (approximate, fast) ──┘

The problem: you maintain two completely separate codebases for the same transformation logic — one for the batch system, one for the stream processor. They inevitably diverge. Debugging is miserable.

The Kappa architecture simplifies this: use one streaming pipeline for everything, replay historical data by re-reading from Kafka with old offsets. This works well when your message bus retains enough history (Kafka can retain data indefinitely).

Apache Kafka as the Data Backbone

Kafka is the dominant backbone for data pipelines. Its durable, replayable log means any downstream consumer can re-process all history, which eliminates the “we lost data before the pipeline was set up” problem.

Key Kafka Connect patterns for ELT:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// Debezium source connector — CDC from PostgreSQL
{
  "name": "postgres-cdc-source",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "postgres",
    "database.port": "5432",
    "database.user": "debezium",
    "database.password": "secret",
    "database.dbname": "production",
    "database.server.name": "prod",
    "table.include.list": "public.orders,public.customers,public.products",
    "plugin.name": "pgoutput",
    "publication.autocreate.mode": "filtered",
    "decimal.handling.mode": "double",
    "transforms": "unwrap",
    "transforms.unwrap.type": "io.debezium.transforms.ExtractNewRecordState",
    "transforms.unwrap.drop.tombstones": "false",
    "transforms.unwrap.delete.handling.mode": "rewrite"
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// S3 sink connector — land raw events into the data lake
{
  "name": "s3-sink",
  "config": {
    "connector.class": "io.confluent.connect.s3.S3SinkConnector",
    "tasks.max": "4",
    "topics": "prod.public.orders,prod.public.customers",
    "s3.region": "us-east-1",
    "s3.bucket.name": "data-lake-raw",
    "s3.part.size": "67108864",
    "storage.class": "io.confluent.connect.s3.storage.S3Storage",
    "format.class": "io.confluent.connect.s3.format.parquet.ParquetFormat",
    "flush.size": "100000",
    "rotate.interval.ms": "600000",
    "locale": "en_US",
    "timezone": "UTC",
    "timestamp.extractor": "RecordField",
    "timestamp.field": "updated_at",
    "partitioner.class": "io.confluent.connect.storage.partitioner.TimeBasedPartitioner",
    "path.format": "'year'=YYYY/'month'=MM/'day'=dd/'hour'=HH",
    "locale": "en_US",
    "timezone": "UTC"
  }
}

Flink is the most powerful stream processing framework. Key capabilities:

  • Event-time processing: handle late-arriving data correctly
  • Exactly-once semantics: distributed transactions across Kafka and state backends
  • Stateful computation: aggregations, joins, sessionization that span many events
  • SQL API: write streaming queries in standard SQL
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# PyFlink: real-time order aggregation
from pyflink.datastream import StreamExecutionEnvironment
from pyflink.table import StreamTableEnvironment, EnvironmentSettings

env = StreamExecutionEnvironment.get_execution_environment()
env.set_parallelism(4)

settings = EnvironmentSettings.new_instance().in_streaming_mode().build()
t_env = StreamTableEnvironment.create(env, environment_settings=settings)

# Create Kafka source table
t_env.execute_sql("""
    CREATE TABLE orders_raw (
        order_id        STRING,
        customer_id     STRING,
        product_id      STRING,
        amount          DECIMAL(10, 2),
        status          STRING,
        event_time      TIMESTAMP(3),
        WATERMARK FOR event_time AS event_time - INTERVAL '10' SECOND
    ) WITH (
        'connector' = 'kafka',
        'topic' = 'prod.public.orders',
        'properties.bootstrap.servers' = 'kafka:9092',
        'properties.group.id' = 'flink-orders-agg',
        'scan.startup.mode' = 'latest-offset',
        'format' = 'json',
        'json.timestamp-format.standard' = 'ISO-8601'
    )
""")

# Create output sink (e.g., write results back to Kafka or a database)
t_env.execute_sql("""
    CREATE TABLE order_aggregates (
        window_start    TIMESTAMP(3),
        window_end      TIMESTAMP(3),
        customer_id     STRING,
        order_count     BIGINT,
        total_amount    DECIMAL(10, 2),
        avg_amount      DECIMAL(10, 2),
        PRIMARY KEY (window_start, customer_id) NOT ENFORCED
    ) WITH (
        'connector' = 'upsert-kafka',
        'topic' = 'order-aggregates',
        'properties.bootstrap.servers' = 'kafka:9092',
        'key.format' = 'json',
        'value.format' = 'json'
    )
""")

# Tumbling window aggregation — 5-minute windows
t_env.execute_sql("""
    INSERT INTO order_aggregates
    SELECT
        TUMBLE_START(event_time, INTERVAL '5' MINUTE) AS window_start,
        TUMBLE_END(event_time,   INTERVAL '5' MINUTE) AS window_end,
        customer_id,
        COUNT(*)              AS order_count,
        SUM(amount)           AS total_amount,
        AVG(amount)           AS avg_amount
    FROM orders_raw
    WHERE status = 'COMPLETED'
    GROUP BY
        TUMBLE(event_time, INTERVAL '5' MINUTE),
        customer_id
""")

Handling Late Data

Late data is a fundamental streaming challenge — events arrive after the window that should contain them has closed.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# Flink watermark strategy with late data handling
t_env.execute_sql("""
    CREATE TABLE orders_with_late (
        order_id    STRING,
        amount      DECIMAL(10, 2),
        event_time  TIMESTAMP(3),
        -- Watermark: wait 30 seconds for late events
        WATERMARK FOR event_time AS event_time - INTERVAL '30' SECOND
    ) WITH (
        'connector' = 'kafka',
        'topic' = 'orders',
        'properties.bootstrap.servers' = 'kafka:9092',
        'format' = 'json'
    )
""")

# Session windows — group events within 10 minutes of each other
t_env.execute_sql("""
    SELECT
        SESSION_START(event_time, INTERVAL '10' MINUTE) AS session_start,
        SESSION_END(event_time,   INTERVAL '10' MINUTE) AS session_end,
        customer_id,
        COUNT(*) AS orders_in_session,
        SUM(amount) AS session_value
    FROM orders_with_late
    GROUP BY
        SESSION(event_time, INTERVAL '10' MINUTE),
        customer_id
""")

Change Data Capture (CDC) Patterns

CDC with Debezium captures every INSERT, UPDATE, and DELETE from a database as a stream of events. This is the gold standard for keeping downstream systems in sync:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# Process Debezium CDC events — maintain a materialized view
from pyflink.datastream import StreamExecutionEnvironment
from pyflink.table import StreamTableEnvironment

t_env = StreamTableEnvironment.create(...)

# Debezium produces: op="c" (create), "u" (update), "d" (delete), "r" (read/snapshot)
t_env.execute_sql("""
    CREATE TABLE customers_cdc (
        id          BIGINT,
        name        STRING,
        email       STRING,
        created_at  TIMESTAMP(3),
        op          STRING METADATA FROM 'value.op' VIRTUAL,
        PRIMARY KEY (id) NOT ENFORCED
    ) WITH (
        'connector' = 'kafka',
        'topic' = 'prod.public.customers',
        'properties.bootstrap.servers' = 'kafka:9092',
        'format' = 'debezium-json'
    )
""")

# Upsert into a materialized table in a database
t_env.execute_sql("""
    CREATE TABLE customers_materialized (
        id      BIGINT PRIMARY KEY NOT ENFORCED,
        name    STRING,
        email   STRING
    ) WITH (
        'connector' = 'jdbc',
        'url' = 'jdbc:postgresql://warehouse:5432/analytics',
        'table-name' = 'customers'
    )
""")

t_env.execute_sql("""
    INSERT INTO customers_materialized
    SELECT id, name, email FROM customers_cdc
""")

Batch Transformation with dbt

dbt (data build tool) is the standard for the “T” in ELT when working with a SQL data warehouse. It turns SQL SELECT statements into a DAG of materialized tables and views, with testing, documentation, and lineage tracking built in.

Project Structure

my_dbt_project/
├── dbt_project.yml
├── profiles.yml          # Connection settings (usually ~/.dbt/profiles.yml)
├── models/
│   ├── staging/          # Raw → cleaned, renamed, typed
│   │   ├── _sources.yml  # Source declarations
│   │   ├── stg_orders.sql
│   │   └── stg_customers.sql
│   ├── intermediate/     # Business logic joins
│   │   └── int_order_items_enriched.sql
│   └── marts/            # Final analytical tables
│       ├── orders.sql
│       └── customer_lifetime_value.sql
├── tests/
│   └── assert_positive_amounts.sql
├── macros/
│   └── generate_surrogate_key.sql
└── snapshots/
    └── orders_snapshot.sql

dbt_project.yml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
name: 'my_project'
version: '1.0.0'
config-version: 2

profile: 'my_project'

model-paths: ["models"]
test-paths: ["tests"]
snapshot-paths: ["snapshots"]
macro-paths: ["macros"]

models:
  my_project:
    staging:
      +materialized: view          # Staging models are views — no storage cost
      +schema: staging
    intermediate:
      +materialized: ephemeral     # Inlined as CTEs, not materialized
    marts:
      +materialized: table         # Final marts are tables for query performance
      +schema: marts

Staging Models — Clean the Raw Data

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
-- models/staging/stg_orders.sql
-- Rename columns, cast types, filter junk, nothing else

with source as (
    select * from {{ source('raw', 'orders') }}
),

renamed as (
    select
        -- Keys
        id::varchar                         as order_id,
        customer_id::varchar                as customer_id,
        product_id::varchar                 as product_id,

        -- Amounts
        amount::numeric(10,2)               as order_amount_usd,

        -- Dates (normalize to UTC timestamp)
        created_at at time zone 'UTC'       as created_at,
        updated_at at time zone 'UTC'       as updated_at,

        -- Status normalization
        lower(trim(status))                 as order_status,

        -- Metadata
        _loaded_at                          as _loaded_at

    from source
    where id is not null
      and amount > 0          -- Filter obviously invalid records
),

final as (
    select *,
        case
            when order_status in ('complete', 'completed', 'done') then 'completed'
            when order_status in ('cancel', 'cancelled', 'void')   then 'cancelled'
            else order_status
        end as order_status_normalized
    from renamed
)

select * from final

Intermediate Models — Business Logic

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
-- models/intermediate/int_orders_enriched.sql
-- Join orders with customer and product data

with orders as (
    select * from {{ ref('stg_orders') }}
),

customers as (
    select * from {{ ref('stg_customers') }}
),

products as (
    select * from {{ ref('stg_products') }}
),

enriched as (
    select
        o.order_id,
        o.order_amount_usd,
        o.order_status_normalized,
        o.created_at,

        -- Customer attributes
        c.customer_id,
        c.customer_name,
        c.customer_email,
        c.customer_segment,
        c.first_order_date,

        -- Product attributes
        p.product_id,
        p.product_name,
        p.product_category,
        p.product_cost_usd,

        -- Derived
        o.order_amount_usd - p.product_cost_usd   as gross_margin_usd,
        date_trunc('month', o.created_at)::date   as order_month

    from orders o
    left join customers c using (customer_id)
    left join products  p using (product_id)
)

select * from enriched

Mart Models — Analytical Aggregates

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
-- models/marts/customer_lifetime_value.sql
-- Final analytical table: one row per customer

{{
    config(
        materialized='table',
        sort='customer_segment',
        dist='customer_id'    -- Redshift distribution key
    )
}}

with orders as (
    select * from {{ ref('int_orders_enriched') }}
    where order_status_normalized = 'completed'
),

clv as (
    select
        customer_id,
        customer_name,
        customer_email,
        customer_segment,
        first_order_date,

        count(distinct order_id)                            as total_orders,
        sum(order_amount_usd)                               as lifetime_revenue_usd,
        sum(gross_margin_usd)                               as lifetime_margin_usd,
        avg(order_amount_usd)                               as avg_order_value_usd,
        max(created_at)                                     as last_order_date,
        max(created_at)::date - first_order_date::date      as customer_age_days,

        -- Cohort month
        date_trunc('month', first_order_date)::date         as acquisition_month,

        -- Recency (days since last order)
        current_date - max(created_at)::date                as days_since_last_order

    from orders
    group by 1, 2, 3, 4, 5
),

-- Add churn flag
final as (
    select
        *,
        case when days_since_last_order > 90 then true else false end as is_churned,
        lifetime_revenue_usd / nullif(customer_age_days, 0) * 365     as annualized_revenue_usd
    from clv
)

select * from final

dbt Tests

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# models/staging/_sources.yml
version: 2

sources:
  - name: raw
    database: analytics
    schema: raw_data
    tables:
      - name: orders
        loaded_at_field: _loaded_at
        freshness:
          warn_after: {count: 6, period: hour}
          error_after: {count: 24, period: hour}
        columns:
          - name: id
            tests:
              - not_null
              - unique
          - name: amount
            tests:
              - not_null
              - dbt_utils.expression_is_true:
                  expression: ">= 0"
          - name: status
            tests:
              - accepted_values:
                  values: ['pending', 'processing', 'completed', 'cancelled']
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# models/marts/_schema.yml
version: 2

models:
  - name: customer_lifetime_value
    description: "One row per customer with aggregated lifetime metrics"
    columns:
      - name: customer_id
        tests:
          - not_null
          - unique
      - name: lifetime_revenue_usd
        tests:
          - not_null
          - dbt_utils.expression_is_true:
              expression: ">= 0"
      - name: customer_segment
        tests:
          - not_null
          - accepted_values:
              values: ['enterprise', 'mid-market', 'smb', 'self-serve']

dbt Snapshots (Slowly Changing Dimensions)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
-- snapshots/orders_snapshot.sql
-- Track historical state of orders (Type 2 SCD)

{% snapshot orders_snapshot %}
{{
    config(
        target_schema='snapshots',
        unique_key='order_id',
        strategy='timestamp',
        updated_at='updated_at',
        invalidate_hard_deletes=True
    )
}}

select * from {{ source('raw', 'orders') }}

{% endsnapshot %}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# dbt commands
dbt run                          # Run all models
dbt run --select staging.*       # Run staging models only
dbt run --select +marts.customer_lifetime_value  # Run with all upstream deps
dbt test                         # Run all tests
dbt test --select stg_orders     # Test a specific model
dbt snapshot                     # Run snapshots
dbt source freshness             # Check source freshness
dbt docs generate && dbt docs serve   # Build and serve documentation
dbt compile                      # Compile SQL without running
dbt debug                        # Test connection and config

Orchestration with Airflow

Airflow schedules and monitors batch pipelines as DAGs (Directed Acyclic Graphs).

A Complete Airflow DAG

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
# dags/daily_etl.py
from datetime import datetime, timedelta
from airflow import DAG
from airflow.operators.bash import BashOperator
from airflow.operators.python import PythonOperator
from airflow.providers.amazon.aws.transfers.s3_to_redshift import S3ToRedshiftOperator
from airflow.providers.postgres.operators.postgres import PostgresOperator
from airflow.utils.task_group import TaskGroup

default_args = {
    'owner': 'data-engineering',
    'depends_on_past': False,
    'email_on_failure': True,
    'email': ['data-alerts@company.com'],
    'retries': 2,
    'retry_delay': timedelta(minutes=5),
    'execution_timeout': timedelta(hours=2),
}

with DAG(
    dag_id='daily_data_pipeline',
    default_args=default_args,
    description='Extract from Postgres, load to S3, transform with dbt',
    schedule_interval='0 2 * * *',    # 2 AM daily
    start_date=datetime(2026, 1, 1),
    catchup=False,                     # Don't backfill missed runs
    max_active_runs=1,                 # Prevent concurrent runs
    tags=['production', 'daily'],
) as dag:

    # --- Extract phase ---
    with TaskGroup('extract', tooltip='Extract data from sources') as extract_group:

        extract_orders = BashOperator(
            task_id='extract_orders',
            bash_command="""
                psql $SOURCE_DB_CONN -c "\COPY (
                    SELECT * FROM orders
                    WHERE updated_at >= '{{ ds }}' AND updated_at < '{{ next_ds }}'
                ) TO STDOUT CSV HEADER" | \
                aws s3 cp - s3://data-lake/raw/orders/date={{ ds }}/orders.csv
            """,
            env={'SOURCE_DB_CONN': '{{ var.value.source_db_conn }}'},
        )

        extract_customers = BashOperator(
            task_id='extract_customers',
            bash_command="""
                psql $SOURCE_DB_CONN -c "\COPY customers TO STDOUT CSV HEADER" | \
                aws s3 cp - s3://data-lake/raw/customers/date={{ ds }}/customers.csv
            """,
            env={'SOURCE_DB_CONN': '{{ var.value.source_db_conn }}'},
        )

    # --- Load phase ---
    with TaskGroup('load', tooltip='Load raw data into warehouse') as load_group:

        load_orders = S3ToRedshiftOperator(
            task_id='load_orders_to_warehouse',
            schema='raw_data',
            table='orders',
            s3_bucket='data-lake',
            s3_key='raw/orders/date={{ ds }}/orders.csv',
            copy_options=[
                'CSV HEADER',
                'IGNOREHEADER 1',
                "TIMEFORMAT 'auto'",
                'ACCEPTINVCHARS',
            ],
            redshift_conn_id='redshift_default',
            aws_conn_id='aws_default',
        )

        load_customers = S3ToRedshiftOperator(
            task_id='load_customers_to_warehouse',
            schema='raw_data',
            table='customers',
            s3_bucket='data-lake',
            s3_key='raw/customers/date={{ ds }}/customers.csv',
            copy_options=['CSV HEADER', 'IGNOREHEADER 1'],
            redshift_conn_id='redshift_default',
            aws_conn_id='aws_default',
        )

    # --- Transform phase (dbt) ---
    with TaskGroup('transform', tooltip='Run dbt models') as transform_group:

        dbt_run_staging = BashOperator(
            task_id='dbt_staging',
            bash_command='cd /opt/dbt/my_project && dbt run --select staging.*',
        )

        dbt_run_marts = BashOperator(
            task_id='dbt_marts',
            bash_command='cd /opt/dbt/my_project && dbt run --select marts.*',
        )

        dbt_test = BashOperator(
            task_id='dbt_test',
            bash_command='cd /opt/dbt/my_project && dbt test',
        )

        dbt_run_staging >> dbt_run_marts >> dbt_test

    # --- Dependencies ---
    extract_group >> load_group >> transform_group

Airflow Best Practices

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# Use XCom for small data passing between tasks
def check_row_count(**context):
    count = get_row_count_from_db()
    context['ti'].xcom_push(key='row_count', value=count)
    if count == 0:
        raise ValueError("No rows extracted — aborting pipeline")

def validate_counts(**context):
    count = context['ti'].xcom_pull(task_ids='check_count', key='row_count')
    print(f"Processing {count} rows")

# Use sensors for external dependencies
from airflow.sensors.s3_key_sensor import S3KeySensor

wait_for_upstream_file = S3KeySensor(
    task_id='wait_for_upstream',
    bucket_name='data-lake',
    bucket_key='upstream/ready/{{ ds }}/_SUCCESS',
    aws_conn_id='aws_default',
    timeout=3600,           # Wait up to 1 hour
    poke_interval=60,       # Check every minute
    mode='reschedule',      # Release worker slot while waiting
)

# Use branching for conditional logic
from airflow.operators.python import BranchPythonOperator

def choose_path(**context):
    if context['ds'] == context['ds'][:7] + '-01':  # First of month
        return 'full_refresh'
    return 'incremental_load'

branch = BranchPythonOperator(
    task_id='choose_load_strategy',
    python_callable=choose_path,
)

Orchestration with Dagster

Dagster takes a different approach from Airflow — it’s asset-centric rather than task-centric. Instead of defining workflows as DAGs of tasks, you define Software-Defined Assets (the data itself) and Dagster infers the pipeline.

Assets vs Tasks

In Airflow, you define tasks (actions). In Dagster, you define assets (data artifacts). The pipeline is derived from data dependencies, not task dependencies.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# dagster_pipeline/assets.py
import pandas as pd
from dagster import asset, AssetIn, Output, MetadataValue
import duckdb

@asset(
    description="Raw orders extracted from PostgreSQL",
    metadata={"source": "production-postgres", "table": "orders"},
)
def raw_orders(context) -> pd.DataFrame:
    """Extract orders from source database."""
    conn = get_postgres_connection()
    df = pd.read_sql("""
        SELECT id, customer_id, amount, status, created_at, updated_at
        FROM orders
        WHERE updated_at >= NOW() - INTERVAL '1 day'
    """, conn)
    context.log.info(f"Extracted {len(df)} orders")
    return df


@asset(
    ins={"raw_orders": AssetIn()},
    description="Cleaned and typed orders",
)
def staged_orders(raw_orders: pd.DataFrame) -> Output[pd.DataFrame]:
    """Clean and normalize orders."""
    df = raw_orders.copy()
    df['amount'] = pd.to_numeric(df['amount'], errors='coerce')
    df['created_at'] = pd.to_datetime(df['created_at'], utc=True)
    df['status'] = df['status'].str.lower().str.strip()
    df = df.dropna(subset=['id', 'amount'])
    df = df[df['amount'] > 0]

    return Output(
        df,
        metadata={
            "num_records": len(df),
            "preview": MetadataValue.md(df.head(5).to_markdown()),
            "schema": MetadataValue.json({col: str(dtype) for col, dtype in df.dtypes.items()}),
        }
    )


@asset(
    ins={"staged_orders": AssetIn(), "staged_customers": AssetIn()},
    description="Customer lifetime value — one row per customer",
)
def customer_lifetime_value(
    staged_orders: pd.DataFrame,
    staged_customers: pd.DataFrame
) -> Output[pd.DataFrame]:
    """Compute CLV by joining orders and customers."""
    joined = staged_orders.merge(staged_customers, on='customer_id', how='left')
    clv = (
        joined
        .query("status == 'completed'")
        .groupby('customer_id')
        .agg(
            total_orders=('id', 'count'),
            lifetime_revenue=('amount', 'sum'),
            avg_order_value=('amount', 'mean'),
            last_order_date=('created_at', 'max'),
        )
        .reset_index()
    )
    return Output(
        clv,
        metadata={
            "num_customers": len(clv),
            "total_revenue": float(clv['lifetime_revenue'].sum()),
        }
    )
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# dagster_pipeline/__init__.py
from dagster import Definitions, ScheduleDefinition, define_asset_job

# Define a job that materializes all assets
daily_pipeline_job = define_asset_job(
    name="daily_pipeline",
    selection="*",    # All assets
)

# Schedule it
daily_schedule = ScheduleDefinition(
    job=daily_pipeline_job,
    cron_schedule="0 2 * * *",  # 2 AM daily
)

defs = Definitions(
    assets=[raw_orders, staged_orders, customer_lifetime_value],
    jobs=[daily_pipeline_job],
    schedules=[daily_schedule],
)
1
2
3
4
5
# Run locally
dagster dev                              # Start dev UI on http://localhost:3000
dagster asset materialize --select "*"  # Materialize all assets
dagster asset materialize --select raw_orders  # Single asset
dagster job execute -j daily_pipeline   # Run the full job

Dagster vs Airflow: When to Choose Each

Airflow Dagster
Mental model Task graph (actions) Asset graph (data)
Observability Task success/failure Asset lineage + metadata
Testing Hard — requires running DAGs First-class — assets are plain functions
Type safety None Optional Python types
Backfilling Time-based partitions Asset partitions + lineage-aware
Ecosystem Huge, battle-tested Growing rapidly, modern design
Learning curve Low (familiar to most) Medium (new paradigms)

Choose Airflow when: you need a massive ecosystem, your team knows it, or you’re integrating with many existing providers.

Choose Dagster when: you want asset lineage visibility, better testability, type-safe pipelines, or you’re starting fresh and can invest in learning the model.

Putting It Together: A Reference Architecture

┌─────────────────────────────────────────────────────────────────────┐
│                         Source Systems                               │
│   PostgreSQL   MySQL   REST APIs   Salesforce   Event streams        │
└───────┬───────────┬──────────┬───────────┬─────────────┬────────────┘
        │           │          │           │             │
        ▼           ▼          ▼           ▼             ▼
┌─────────────────────────────────────────────────────────────────────┐
│                       Ingestion Layer                                │
│   Debezium CDC   Airbyte   Singer   Kafka Connect   Custom scripts   │
└───────────────────────────────┬─────────────────────────────────────┘
                                │
                    ┌───────────┴───────────┐
                    ▼                       ▼
           Apache Kafka             Direct to Storage
         (streaming events)        (batch file drops)
                    │                       │
                    │                       ▼
                    │              ┌─────────────────┐
                    │              │   Object Store  │
                    │              │  (S3 / MinIO)   │
                    │              │   Raw zone      │
                    │              └────────┬────────┘
                    │                       │
        ┌───────────┴──────────┐            │
        ▼                      ▼            ▼
   Apache Flink          Batch Load    Data Warehouse
  (stream SQL,          (COPY/INSERT)  (Snowflake /
   windowing,                          BigQuery /
   enrichment)                         Redshift /
        │                              DuckDB)
        │                                   │
        └───────────────────────────────────┘
                                            │
                              ┌─────────────▼──────────────┐
                              │       dbt Transform         │
                              │  staging → intermediate →  │
                              │  marts → docs → tests      │
                              └─────────────┬──────────────┘
                                            │
                              ┌─────────────▼──────────────┐
                              │    Orchestration Layer      │
                              │   Airflow / Dagster         │
                              │   scheduling, monitoring,  │
                              │   alerting, lineage        │
                              └─────────────┬──────────────┘
                                            │
                              ┌─────────────▼──────────────┐
                              │    Consumption Layer        │
                              │  BI Tools  ML Models  APIs │
                              │  Looker  Superset  Jupyter  │
                              └────────────────────────────┘

Common Failure Modes and How to Handle Them

Schema drift: source systems add or rename columns without warning. Mitigations: use SELECT * in staging to capture everything, add schema contracts with Great Expectations or dbt contracts, alert on schema changes with Debezium’s schema change events.

Late-arriving data: events arrive out of order in streaming pipelines. Mitigation: set generous watermarks in Flink, re-run dbt models daily to capture late-arriving batch data with WHERE updated_at >= lookback_window.

Duplicate data: sources emit the same event multiple times (especially after retries). Mitigation: use INSERT ... ON CONFLICT DO UPDATE (upsert) in your load step, deduplicate in staging models with ROW_NUMBER() OVER (PARTITION BY id ORDER BY updated_at DESC) = 1.

Silent failures: a pipeline runs but produces wrong data. Mitigation: dbt tests on counts, value ranges, referential integrity; data quality dashboards; anomaly detection on key metrics (sudden 50% drop in row count = something broke).

Backfill complexity: you need to re-process 2 years of data with new logic. Mitigation: keep raw data immutable in the data lake forever; ELT makes re-transformation trivial — just re-run dbt against the unchanged raw data.

The data engineering landscape evolves quickly, but these patterns — CDC for extraction, ELT for transformation, dbt for SQL modeling, Airflow or Dagster for orchestration — form a stable foundation that handles the vast majority of real-world requirements.

Comments