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:
- Extract: get data out of source systems
- Transform: reshape, clean, join, and aggregate it
- 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.
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.
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).
Streaming with Kafka and Flink
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"
}
}
|
Apache Flink for Stateful Stream Processing
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
""")
|
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
|
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