Data Processing Pipelines
ETL, ELT, and batch processing workflows
Moving and Transforming Data
Data pipelines are automated workflows that move data from source to destination, applying transformations along the way. They're the backbone of modern data platforms, taking raw data from databases, APIs, files, and streams, then cleaning, enriching, and preparing it for analytics. Understanding ETL vs ELT, batch vs streaming, and orchestration patterns is essential for building reliable data systems.
What is a Data Pipeline?
A data pipeline is a series of data processing steps where the output of one step becomes the input of the next. Think of it like an assembly line for data.
┌──────────┐ ┌──────────┐ ┌───────────┐ ┌─────────┐
│ EXTRACT │───►│TRANSFORM │───►│ LOAD │───►│ CONSUME │
│ (Sources)│ │ (Clean, │ │(Target DB)│ │ (BI, ML)│
│ │ │ Enrich) │ │ │ │ │
└──────────┘ └──────────┘ └───────────┘ └─────────┘
▲ │
│ │
└──────────────────────────────────────────────────┘
Optional: Feedback LoopKey Components
📥 Extract
Pull data from sources (databases, APIs, files, streams)
⚙️ Transform
Clean, enrich, aggregate, join, filter data
📤 Load
Write to destination (warehouse, lake, database)
ETL vs ELT: Order Matters
The difference between ETL and ELT is when transformation happens, before or after loading.
ETL: Extract, Transform, Load
Traditional approach, clean data before storing
Transform data in a staging area or processing engine BEFORE loading into the target system.
┌──────────┐ ┌──────────────┐ ┌──────────────┐ │ SOURCE │───►│ STAGING │───►│ TARGET │ │ Database │ │ AREA │ │ Warehouse │ │ │ │ │ │ │ │ Raw │ │ Transform: │ │ Clean, │ │ Messy │ │ • Clean │ │ Structured │ │ Data │ │ • Validate │ │ Data │ │ │ │ • Enrich │ │ │ └──────────┘ └──────────────┘ └──────────────┘
Example: ETL with Python
import pandas as pd
# EXTRACT: Read from source
df = pd.read_csv('sales_data.csv')
# Raw: 10,000 rows with duplicates, nulls, bad data
# TRANSFORM: Clean and enrich
df = df.drop_duplicates() # Remove duplicates
df = df.dropna(subset=['customer_id']) # Remove nulls
df['amount'] = df['amount'].abs() # Fix negative amounts
df['date'] = pd.to_datetime(df['date']) # Parse dates
df = df[df['amount'] > 0] # Filter valid transactions
# Enrich with customer data
customers = pd.read_sql("SELECT * FROM customers", db_conn)
df = df.merge(customers, on='customer_id', how='left')
# LOAD: Write cleaned data to warehouse
df.to_sql('clean_sales', warehouse_conn, if_exists='append')10,000 → 8,734 rows loaded (duplicates removed, invalid filtered)
Warehouse receives only clean, validated data
When to Use ETL
- Target system has limited compute (can't handle transformations)
- Need to minimize storage in expensive warehouse
- Data requires complex transformations
- Compliance requires data masking before storage
- Multiple sources need integration before loading
Pros & Cons
✅ Pros
- Only clean data in warehouse
- Less storage needed
- Mature tools and practices
- Data privacy/security easier
❌ Cons
- Slower (transform before load)
- Requires separate staging infrastructure
- Can't query raw data later
- Inflexible, hard to change transforms
ELT: Extract, Load, Transform
Modern approach, load raw, transform in target
Load raw data into the target system FIRST, then transform it using the target's compute power.
┌──────────┐ ┌────────────────────────────┐
│ SOURCE │───►│ TARGET (Data Lake / │
│ Database │ │ Warehouse) │
│ │ │ │
│ Raw │ │ RAW ZONE │
│ Data │ │ • Store everything │
│ │ │ │
└──────────┘ │ ↓ Transform using SQL, │
│ Spark, dbt │
│ │
│ TRANSFORMED ZONE │
│ • Cleaned, enriched data │
└────────────────────────────┘Example: ELT with dbt
-- EXTRACT & LOAD: Use Fivetran/Airbyte to load raw data
-- Raw table already exists in warehouse: raw_sales
-- TRANSFORM: dbt model (runs in warehouse)
-- models/staging/stg_sales.sql
WITH source AS (
SELECT * FROM raw_sales
),
cleaned AS (
SELECT DISTINCT
customer_id,
ABS(amount) AS amount,
CAST(date AS DATE) AS date
FROM source
WHERE customer_id IS NOT NULL
AND amount > 0
)
SELECT
c.*,
cust.name AS customer_name,
cust.segment
FROM cleaned c
LEFT JOIN dim_customers cust
ON c.customer_id = cust.customer_idTransformed table created in warehouse using SQL
Leverages warehouse compute (Snowflake, BigQuery, etc.)
When to Use ELT
- Target system has powerful compute (cloud warehouses)
- Storage is cheap (data lakes)
- Need flexibility to re-transform raw data
- Want faster data availability (load quickly, transform later)
- Using modern cloud data platforms
Pros & Cons
✅ Pros
- Faster to get data available
- Can re-transform raw data anytime
- Leverages warehouse compute
- More flexible and agile
- Simpler architecture
❌ Cons
- More storage needed (raw + transformed)
- Requires powerful target system
- Costs more in compute
- Raw data may have PII/sensitive info
Batch vs Streaming Processing
Data pipelines can process data in batches (periodic chunks) or streams (continuous flow).
Batch Processing
Process data in scheduled intervals (hourly, daily, weekly). Collects data over time, then processes it all at once.
Time: 00:00 01:00 02:00 03:00
│ │ │ │
Data: ████████ ████████ ████████ ████████
Collect Collect Collect Collect
│
▼
Process
All Data
From Last HourExample: Daily Batch Job
# Airflow DAG - runs daily at 2 AM
from airflow import DAG
from airflow.operators.python import PythonOperator
from datetime import datetime, timedelta
def extract_daily_sales():
"""Extract yesterday's sales"""
query = """
SELECT * FROM sales
WHERE date = CURRENT_DATE - 1
"""
df = pd.read_sql(query, source_db)
df.to_parquet('/data/raw/sales_{{ ds }}.parquet')
def transform_and_load():
"""Transform and load to warehouse"""
df = pd.read_parquet('/data/raw/sales_{{ ds }}.parquet')
# Aggregate by customer
daily_summary = df.groupby('customer_id').agg({
'amount': 'sum',
'quantity': 'sum'
}).reset_index()
daily_summary.to_sql('daily_sales_summary', warehouse,
if_exists='append')
dag = DAG(
'daily_sales_pipeline',
start_date=datetime(2024, 1, 1),
schedule_interval='0 2 * * *', # 2 AM daily
)
extract = PythonOperator(task_id='extract', python_callable=extract_daily_sales)
transform = PythonOperator(task_id='transform', python_callable=transform_and_load)
extract >> transformRuns every day at 2 AM, processes previous day's data
Latency: ~2-26 hours depending on when transaction occurred
Best For
- Historical analysis and reporting
- Large-scale data processing
- Cost-effective (process when resources are cheap)
- When real-time isn't required
- Complex transformations needing full dataset
Streaming Processing
Process data continuously as it arrives. Each event is processed immediately or in micro-batches (seconds).
Time: 00:00:00 00:00:01 00:00:02 00:00:03
│ │ │ │
Data: █ ──► Process
█ ──► Process
█ ──► Process
█ ──► Process
Continuous flow, each event processed immediatelyExample: Kafka Stream Processing
from kafka import KafkaConsumer
import json
consumer = KafkaConsumer(
'sales_events',
bootstrap_servers=['localhost:9092'],
value_deserializer=lambda m: json.loads(m.decode('utf-8'))
)
# Process each event as it arrives
for message in consumer:
event = message.value
# Transform in real-time
processed = {
'customer_id': event['customer_id'],
'amount': abs(float(event['amount'])),
'timestamp': event['timestamp'],
'category': categorize_purchase(event['amount'])
}
# Write to real-time dashboard database
write_to_db(processed)
# Check for fraud in real-time
if event['amount'] > 10000:
send_fraud_alert(event)Each transaction processed within milliseconds
Fraud alerts sent immediately, dashboard updates in real-time
Best For
- Real-time dashboards and alerts
- Fraud detection
- Live recommendations
- IoT sensor monitoring
- Financial trading systems
- Click-stream analysis
| Aspect | Batch | Streaming |
|---|---|---|
| Latency | Minutes to hours | Milliseconds to seconds |
| Cost | Lower (scheduled resources) | Higher (always-on infrastructure) |
| Complexity | Simpler | More complex |
| Use Case | Reports, analytics | Real-time alerts, dashboards |
| Tools | Airflow, dbt, Spark Batch | Kafka, Flink, Spark Streaming |
Popular Pipeline Tools
Extraction Tools
Fivetran
Managed ELT, 150+ connectors, automated schema changes
Best for: No-code data ingestionAirbyte
Open-source, customizable connectors, self-hosted or cloud
Best for: Custom integrations, cost controldlt (data load tool)
Lightweight, open-source, Python-first EL with automatic schema inference
Best for: Python developers who want minimal configurationAWS Glue
Fully managed ETL service, serverless Spark jobs, built-in crawlers, Data Catalog integration
Best for: AWS-native stacks, zero-infrastructure ETL, large-scale batch processingTransformation Tools
dbt (Data Build Tool)
SQL-based, version control, testing, documentation
Best for: Analytics engineers, ELT workflowsApache Spark
Distributed processing, batch and streaming, Python/Scala
Best for: Large-scale data processingPandas/Polars
Python libraries for data manipulation
Best for: Small to medium datasets, custom logicAWS Glue
Fully managed ETL service, serverless Spark jobs, built-in crawlers, Data Catalog integration
Best for: AWS-native stacks, zero-infrastructure ETL, large-scale batch processingOrchestration Tools
Apache Airflow
Python-based DAGs, rich ecosystem, highly flexible
Best for: Complex workflows, Python usersPrefect
Modern alternative to Airflow, better observability
Best for: Developer experience, dynamic workflowsDagster
Asset-based, data-aware, great testing
Best for: Data quality focus, testingMage
Modern orchestration with great UI, notebook-style development, fast adoption
Best for: Teams wanting developer-friendly experienceStreaming Tools
Apache Kafka
Distributed event streaming, high throughput
Best for: Event-driven architecturesApache Flink
Stream processing framework, exactly-once semantics
Best for: Complex event processingAWS Kinesis
Managed streaming service on AWS
Best for: AWS-native applicationsOrchestration: Coordinating the Pipeline
Orchestration tools schedule, monitor, and manage dependencies between pipeline tasks. They ensure tasks run in the right order, handle failures, and provide visibility.
Key Concepts
DAG (Directed Acyclic Graph)
Tasks connected by dependencies, no cycles allowed
Dependencies
Task B can't start until Task A completes successfully
Retry Logic
Automatically retry failed tasks with backoff
Example: Airflow DAG
from airflow import DAG
from airflow.operators.python import PythonOperator
from airflow.providers.postgres.operators.postgres import PostgresOperator
from datetime import datetime, timedelta
# Define default arguments
default_args = {
'owner': 'data-team',
'retries': 3,
'retry_delay': timedelta(minutes=5),
'email_on_failure': True,
'email': ['alerts@company.com']
}
# Create DAG
dag = DAG(
'sales_analytics_pipeline',
default_args=default_args,
description='Daily sales data processing',
schedule_interval='0 2 * * *', # 2 AM daily
start_date=datetime(2024, 1, 1),
catchup=False
)
# Task 1: Extract from source database
extract_sales = PostgresOperator(
task_id='extract_sales',
postgres_conn_id='source_db',
sql='''
CREATE TABLE staging_sales AS
SELECT * FROM sales
WHERE date = CURRENT_DATE - 1
''',
dag=dag
)
# Task 2: Transform data
def transform_data():
import pandas as pd
df = pd.read_sql("SELECT * FROM staging_sales", conn)
# Clean and aggregate
df = df.dropna()
summary = df.groupby('customer_id').agg({
'amount': 'sum',
'quantity': 'sum'
})
summary.to_sql('transformed_sales', conn, if_exists='replace')
transform_task = PythonOperator(
task_id='transform_sales',
python_callable=transform_data,
dag=dag
)
# Task 3: Load to warehouse
load_warehouse = PostgresOperator(
task_id='load_warehouse',
postgres_conn_id='warehouse_db',
sql='''
INSERT INTO sales_summary
SELECT * FROM transformed_sales
''',
dag=dag
)
# Task 4: Update metrics
def update_metrics():
# Update dashboard metrics
calculate_daily_kpis()
send_slack_notification("Sales pipeline completed")
update_metrics_task = PythonOperator(
task_id='update_metrics',
python_callable=update_metrics,
dag=dag
)
# Define dependencies
extract_sales >> transform_task >> load_warehouse >> update_metrics_taskTasks run in sequence: Extract → Transform → Load → Metrics
If any task fails, it retries 3 times with 5-minute delays
Email alert sent on failure
DAG Visualization
┌─────────────────┐
│ extract_sales │
└────────┬────────┘
│
▼
┌────────────────┐
│transform_sales │
└────────┬───────┘
│
▼
┌────────────────┐
│ load_warehouse │
└────────┬───────┘
│
▼
┌────────────────┐
│ update_metrics │
└────────────────┘Parallel Execution
Tasks without dependencies can run in parallel for faster execution.
┌─────────────┐
│ START │
└──────┬──────┘
│
┌──────┴──────┐
│ │
▼ ▼
┌────────┐ ┌────────┐
│Extract │ │Extract │ (Run in parallel)
│ Sales │ │Products│
└───┬────┘ └───┬────┘
│ │
└──────┬─────┘
│
▼
┌──────────┐
│ Join & │
│Transform │
└─────┬────┘
│
▼
┌──────────┐
│ Load │
└──────────┘Pipeline Best Practices
✅ Idempotency
Running the same pipeline multiple times produces the same result. Use MERGE or UPSERT instead of INSERT. Crucial for retry logic.
✅ Incremental Processing
Only process new/changed data, not the entire dataset. Use timestamps, watermarks, or change data capture (CDC) to identify what changed.
✅ Data Quality Checks
Validate data at each stage. Check for nulls, duplicates, schema changes, and business rule violations. Fail fast on bad data.
✅ Monitoring & Alerting
Track pipeline health, execution time, data volume, and failure rates. Alert on failures, SLA breaches, and anomalies.
✅ Small, Focused Tasks
Break pipelines into small, single-purpose tasks. Easier to debug, test, and reuse. Each task should do one thing well.
✅ Version Control
Store pipeline code in Git. Use CI/CD for deployment. Track changes, enable rollbacks, and collaborate effectively.
❌ Avoid Hard-Coding
Don't hard-code dates, credentials, or configuration. Use parameters, environment variables, or config files.
❌ Don't Process Everything
Full reprocessing is expensive and slow. Use incremental loads and only process changed data whenever possible.
Data Quality Checks
Always validate data before loading to catch issues early.
Example: Great Expectations
import great_expectations as ge
# Load data
df = ge.read_csv('sales_data.csv')
# Define expectations (data quality rules)
df.expect_column_values_to_not_be_null('customer_id')
df.expect_column_values_to_be_unique('order_id')
df.expect_column_values_to_be_between('amount', min_value=0, max_value=100000)
df.expect_column_values_to_be_in_set('status', ['pending', 'completed', 'cancelled'])
df.expect_column_values_to_match_regex('email', r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
# Validate
results = df.validate()
if not results['success']:
# Log failures
for result in results['results']:
if not result['success']:
print(f"FAILED: {result['expectation_config']['expectation_type']}")
print(f" Details: {result['result']}")
# Stop pipeline
raise ValueError("Data quality checks failed!")
else:
print("✓ All data quality checks passed")
# Continue to load data✓ All data quality checks passed
Pipeline continues to load data
FAILED: expect_column_values_to_not_be_null
Details: 150 null values found in customer_id
Pipeline stops, data NOT loaded
Common Pipeline Patterns
Change Data Capture (CDC)
Track only changes (inserts, updates, deletes) in source database and replicate them.
Use: Real-time replication, incremental loads
Slowly Changing Dimensions (SCD)
Track historical changes in dimension tables (Type 1, 2, 3).
Use: Customer addresses, product prices over time
Lambda Architecture
Combine batch and stream processing for completeness + low latency.
Stream: Fast, approximate (seconds)
Medallion Architecture
Bronze (raw) → Silver (cleaned) → Gold (business-ready) zones.
Use: Progressive data refinement
Key Takeaways
- ETL transforms before loading (traditional)
- ELT loads raw, transforms in target (modern)
- Batch processes data in scheduled intervals
- Streaming processes data continuously
- Orchestration coordinates pipeline execution
- Idempotency enables safe retries
- Data quality checks catch issues early
- Incremental loads save time and cost