ETL Pipelines Cheat Sheet
Covers extract-transform-load pipeline design, Airflow DAG orchestration, and best practices for idempotent, reliable, and incrementally loading data pipelines.
Orchestrating with Airflow
Define a daily ETL DAG with explicit task dependencies.
from airflow import DAGfrom airflow.operators.python import PythonOperatorfrom datetime import datetimedef extract(): ...def transform(): ...def load(): ...with DAG( dag_id="daily_sales_etl", schedule="0 2 * * *", # 2am daily start_date=datetime(2024, 1, 1), catchup=False,) as dag: extract_task = PythonOperator(task_id="extract", python_callable=extract) transform_task = PythonOperator(task_id="transform", python_callable=transform) load_task = PythonOperator(task_id="load", python_callable=load) extract_task >> transform_task >> load_task
Extract-Transform-Load with pandas
A minimal ETL step implemented directly in pandas.
import pandas as pd# Extractdf = pd.read_csv("raw_sales.csv")# Transformdf["order_date"] = pd.to_datetime(df["order_date"])df = df.dropna(subset=["customer_id"])df["amount"] = df["amount"].clip(lower=0)df["region"] = df["region"].str.upper().str.strip()# Loaddf.to_sql("sales_clean", con=engine, if_exists="append", index=False)
ETL vs. ELT Concepts
Core terminology in pipeline design.
- Extract- Pull raw data from source systems (databases, APIs, files, event streams)
- Transform- Clean, validate, deduplicate, and reshape data into the target schema
- Load- Write the transformed data into the destination warehouse or data mart
- ETL vs ELT- ETL transforms before loading; ELT loads raw data first and transforms inside the warehouse (e.g. dbt)
- Idempotency- Re-running a pipeline with the same input should produce the same output, with no duplicates
- Incremental load- Only process new/changed records (via timestamp or CDC) instead of full reloads
Best Practices
Habits that keep pipelines reliable in production.
- Schema validation- Validate incoming data against an expected schema before it reaches downstream tables
- Data quality checks- Assert row counts, null rates, and value ranges at each pipeline stage
- Retry and alerting- Automatically retry transient failures and alert on-call when a pipeline fails
- Orchestration- Use a scheduler like Airflow, Dagster, or Prefect to manage dependencies between tasks
- Backfilling- Ability to reprocess historical date ranges when logic changes or bugs are fixed
Incremental Models with dbt
Transform-in-warehouse (ELT) so only new/changed rows are reprocessed on each run.
-- models/fct_orders.sql{{ config( materialized='incremental', unique_key='order_id', incremental_strategy='merge' )}}SELECT order_id, customer_id, amount, updated_atFROM {{ source('raw', 'orders') }}{% if is_incremental() %}WHERE updated_at > (SELECT MAX(updated_at) FROM {{ this }}){% endif %}
Slowly Changing Dimension (Type 2)
Preserve history of dimension changes instead of overwriting rows in place.
-- Close out changed current rowsMERGE INTO dim_customer AS targetUSING staging_customer AS sourceON target.customer_id = source.customer_id AND target.is_current = TRUEWHEN MATCHED AND (target.email <> source.email OR target.address <> source.address) THEN UPDATE SET target.is_current = FALSE, target.valid_to = CURRENT_DATEWHEN NOT MATCHED THEN INSERT (customer_id, email, address, valid_from, valid_to, is_current) VALUES (source.customer_id, source.email, source.address, CURRENT_DATE, NULL, TRUE);-- Insert the new current row for every record that was just closed outINSERT INTO dim_customer (customer_id, email, address, valid_from, valid_to, is_current)SELECT s.customer_id, s.email, s.address, CURRENT_DATE, NULL, TRUEFROM staging_customer sJOIN dim_customer d ON d.customer_id = s.customer_id AND d.is_current = FALSE AND d.valid_to = CURRENT_DATE;
Dynamic Task Mapping & Sensors
Fan out one task per table at runtime and wait on an upstream DAG without blocking a worker slot.
from airflow.sensors.external_task import ExternalTaskSensorfrom airflow.decorators import taskwait_for_upstream = ExternalTaskSensor( task_id="wait_for_upstream_dag", external_dag_id="raw_ingestion", external_task_id="load_raw", timeout=3600, mode="reschedule", # frees the worker slot while waiting, unlike mode="poke")@taskdef get_source_tables(): return ["orders", "customers", "products"]@taskdef load_table(table_name: str): ... # extract + load a single table# Creates one mapped task instance per table, all running in parallelwait_for_upstream >> load_table.expand(table_name=get_source_tables())
Change Data Capture Patterns
Streaming source changes into a pipeline instead of batch-polling full tables.
- Change Data Capture (CDC)- Capturing row-level inserts, updates, and deletes from a source system as they happen
- Log-based CDC- Reads the database's write-ahead log or binlog (e.g. Debezium on MySQL binlog / Postgres WAL) instead of polling tables
- Debezium- Open-source connector suite that streams CDC events from databases into Kafka topics
- Outbox pattern- Write domain events to an outbox table in the same transaction as the business change, then relay them to a queue
- At-least-once vs. exactly-once- At-least-once may redeliver duplicates; exactly-once needs idempotent writes or a transactional sink
- Backpressure- Consumers deliberately slow ingestion when downstream can't keep up, preventing queue or memory overload
Data Quality & Failure Handling
Guardrails that keep a production pipeline trustworthy.
- Great Expectations- Framework for declaring and running data quality expectations (not-null, ranges, uniqueness) as a pipeline step
- Schema drift- Source schema changes (new/removed columns, type changes) that silently break downstream assumptions if undetected
- Row count reconciliation- Compare source vs. destination row counts after each load to catch silent drops or duplication
- Dead-letter queue- Route records that fail validation or transformation to a separate store instead of failing the whole batch
- Freshness checks- Alert when a table hasn't been updated within its expected SLA window
- Contract testing- Producers and consumers agree on a schema contract so upstream changes don't silently break downstream jobs
Design every load step to be idempotent, such as upserting on a natural key or overwriting by partition - pipelines will eventually be re-run after a failure, and non-idempotent loads silently create duplicate rows.