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.
A Data Pipeline: Extract -> Transform -> Load -> Consume
Figure: Each stage's output feeds the next, like an assembly line. A feedback loop (for example, model scores written back to sources) is optional.
Key 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.
ETL: transform BEFORE loading
Figure: In ETL, data is transformed in a separate staging engine and only clean, structured rows land in the warehouse. Common when the target has limited compute or strict schemas.
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
- Only clean data in warehouse
- Less storage needed
- Mature tools and practices
- Data privacy/security easier
- 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.
ELT: load raw FIRST, transform inside the target
Figure: In ELT, raw data lands in the target (data lake / warehouse) untouched, then its own compute transforms it into cleaned zones. Both zones live inside the target, which is why cheap, elastic cloud warehouses made ELT the modern default.
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
- Faster to get data available
- Can re-transform raw data anytime
- Leverages warehouse compute
- More flexible and agile
- Simpler architecture
- 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.providers.standard.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='0 2 * * *', # 2 AM daily
)
extract = PythonOperator(task_id='extract', python_callable=extract_daily_sales, dag=dag)
transform = PythonOperator(task_id='transform', python_callable=transform_and_load, dag=dag)
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.providers.standard.operators.python import PythonOperator
from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator
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='0 2 * * *', # 2 AM daily
start_date=datetime(2024, 1, 1),
catchup=False
)
# Task 1: Extract from source database
extract_sales = SQLExecuteQueryOperator(
task_id='extract_sales',
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 = SQLExecuteQueryOperator(
task_id='load_warehouse',
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
Airflow DAG: a linear dependency chain
Figure: A directed acyclic graph of tasks. Each edge is a dependency, so every task waits for its upstream task to succeed before it runs.
Parallel Execution
Tasks without dependencies can run in parallel for faster execution.
Parallel Execution: independent tasks fork, then join
Figure: extract_sales and extract_products have no dependency between them, so the scheduler runs them simultaneously. The join task waits for both before transforming, shortening total runtime.
Pipeline Best Practices
Running the same pipeline multiple times produces the same result. Use MERGE or UPSERT instead of INSERT. Crucial for retry logic.
Only process new/changed data, not the entire dataset. Use timestamps, watermarks, or change data capture (CDC) to identify what changed.
Validate data at each stage. Check for nulls, duplicates, schema changes, and business rule violations. Fail fast on bad data.
Track pipeline health, execution time, data volume, and failure rates. Alert on failures, SLA breaches, and anomalies.
Break pipelines into small, single-purpose tasks. Easier to debug, test, and reuse. Each task should do one thing well.
Store pipeline code in Git. Use CI/CD for deployment. Track changes, enable rollbacks, and collaborate effectively.
Don't hard-code dates, credentials, or configuration. Use parameters, environment variables, or config files.
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 gx
import pandas as pd
df = pd.read_csv('sales_data.csv')
context = gx.get_context()
data_source = context.data_sources.add_pandas("pandas")
data_asset = data_source.add_dataframe_asset(name="sales_data")
batch_definition = data_asset.add_batch_definition_whole_dataframe("batch")
batch = batch_definition.get_batch(batch_parameters={"dataframe": df})
# Define expectations (data quality rules)
suite = gx.ExpectationSuite(name="sales_data_quality")
suite.add_expectation(gx.expectations.ExpectColumnValuesToNotBeNull(column="customer_id"))
suite.add_expectation(gx.expectations.ExpectColumnValuesToBeUnique(column="order_id"))
suite.add_expectation(gx.expectations.ExpectColumnValuesToBeBetween(column="amount", min_value=0, max_value=100000))
suite.add_expectation(gx.expectations.ExpectColumnValuesToBeInSet(column="status", value_set=["pending", "completed", "cancelled"]))
suite.add_expectation(gx.expectations.ExpectColumnValuesToMatchRegex(column="email", regex=r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'))
# Validate
result = batch.validate(suite)
if not result.success:
# Log failures
for expectation_result in result.results:
if not expectation_result.success:
print(f"FAILED: {expectation_result.expectation_config.type}")
print(f" Details: {expectation_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