Friday, September 11, 2026
HomeData ScienceBuilding ETL Pipelines with Python – Airflow, dbt & Great Expectations 2026

Building ETL Pipelines with Python – Airflow, dbt & Great Expectations 2026

Table of Content

Data pipelines are the plumbing of data science. No matter how good your model is, it is useless without reliable, clean, timely data flowing into it. This guide builds production-grade ETL (Extract, Transform, Load) pipelines using Python — with Apache Airflow for orchestration, dbt for SQL transformations, and Great Expectations for automated data quality checks.

Pipeline Architecture Patterns

ETL (Extract-Transform-Load) extracts raw data, transforms it in memory, then loads it to the destination — good when transformations are complex Python logic. ELT (Extract-Load-Transform) loads raw data first, then transforms it inside the warehouse using SQL — modern data warehouses (Snowflake, BigQuery, Redshift) are optimised for this pattern. Most modern data stacks use ELT with dbt for transformations and Python for extraction and orchestration.

Simple ETL Pipeline

import pandas as pd
import numpy as np
import sqlite3
import logging
from pathlib import Path
from datetime import datetime, timedelta
import requests

logging.basicConfig(level=logging.INFO,
                    format='%(asctime)s %(levelname)s %(message)s')
log = logging.getLogger(__name__)

# ── Extract ──────────────────────────────────────────────────
def extract_api_data(endpoint: str, params: dict) -> pd.DataFrame:
    log.info(f'Extracting from {endpoint}')
    r = requests.get(endpoint, params=params, timeout=30)
    r.raise_for_status()
    data = r.json()
    df   = pd.DataFrame(data)
    log.info(f'Extracted {len(df)} rows')
    return df

def extract_csv(filepath: str) -> pd.DataFrame:
    log.info(f'Reading {filepath}')
    df = pd.read_csv(filepath, parse_dates=['date'])
    log.info(f'Read {len(df)} rows, {df.shape[1]} columns')
    return df

# ── Transform ─────────────────────────────────────────────────
def transform_sales(df: pd.DataFrame) -> pd.DataFrame:
    log.info('Transforming sales data')

    # Validate input
    required = ['date', 'product_id', 'quantity', 'unit_price', 'customer_id']
    missing  = [c for c in required if c not in df.columns]
    if missing: raise ValueError(f'Missing columns: {missing}')

    # Clean
    df = df.copy()
    df['date']       = pd.to_datetime(df['date'], errors='coerce')
    df['quantity']   = pd.to_numeric(df['quantity'], errors='coerce').clip(lower=0)
    df['unit_price'] = pd.to_numeric(df['unit_price'], errors='coerce').clip(lower=0)

    # Drop invalid rows
    before = len(df)
    df = df.dropna(subset=['date', 'quantity', 'unit_price'])
    df = df[df['quantity'] > 0]
    log.info(f'Dropped {before - len(df)} invalid rows')

    # Enrich
    df['revenue']      = df['quantity'] * df['unit_price']
    df['year_month']   = df['date'].dt.to_period('M').astype(str)
    df['day_of_week']  = df['date'].dt.day_name()
    df['is_weekend']   = df['date'].dt.dayofweek >= 5
    df['processed_at'] = datetime.utcnow()

    log.info(f'Transform complete: {len(df)} rows, revenue sum = {df["revenue"].sum():,.0f}')
    return df

# ── Load ──────────────────────────────────────────────────────
def load_to_sqlite(df: pd.DataFrame, db_path: str, table: str,
                   if_exists='append'):
    log.info(f'Loading {len(df)} rows to {table}')
    with sqlite3.connect(db_path) as conn:
        df.to_sql(table, conn, if_exists=if_exists, index=False)
    log.info('Load complete')

# ── Orchestrate ───────────────────────────────────────────────
def run_pipeline(date: str):
    log.info(f'=== Starting ETL pipeline for {date} ===')

    # In practice, these come from APIs or file systems
    raw   = extract_csv(f'data/sales_{date}.csv')
    clean = transform_sales(raw)
    load_to_sqlite(clean, 'warehouse.db', 'sales_daily')

    log.info(f'=== Pipeline complete: {len(clean)} rows processed ===')
    return clean

if __name__ == '__main__':
    run_pipeline(datetime.today().strftime('%Y-%m-%d'))

Apache Airflow for Orchestration

pip install apache-airflow

# dags/sales_etl_dag.py
from datetime import datetime, timedelta
from airflow import DAG
from airflow.operators.python import PythonOperator
from airflow.operators.bash  import BashOperator
from airflow.sensors.filesystem import FileSensor

default_args = {
    'owner':            'dataexpertise',
    'depends_on_past':  False,
    'start_date':       datetime(2026, 9, 1),
    'email_on_failure': True,
    'email':            ['kekare.durgesh9@gmail.com'],
    'retries':          2,
    'retry_delay':      timedelta(minutes=5),
}

with DAG(
    dag_id='sales_etl_daily',
    default_args=default_args,
    description='Daily sales ETL pipeline',
    schedule='0 6 * * *',          # every day at 6am
    catchup=False,
    tags=['etl', 'sales'],
) as dag:

    # Wait for source file to arrive
    wait_for_file = FileSensor(
        task_id='wait_for_sales_file',
        filepath='/data/incoming/sales_{{ ds }}.csv',
        poke_interval=300,   # check every 5 minutes
        timeout=7200,        # fail after 2 hours
        mode='poke'
    )

    # Extract and validate
    def extract(**context):
        from etl.extract import extract_csv
        from etl.validate import validate_schema
        date = context['ds']
        df   = extract_csv(f'/data/incoming/sales_{date}.csv')
        validate_schema(df)
        df.to_parquet(f'/tmp/raw_sales_{date}.parquet', index=False)
        return len(df)

    extract_task = PythonOperator(
        task_id='extract',
        python_callable=extract,
    )

    # Transform
    def transform(**context):
        from etl.transform import transform_sales
        import pandas as pd
        date = context['ds']
        df   = pd.read_parquet(f'/tmp/raw_sales_{date}.parquet')
        clean = transform_sales(df)
        clean.to_parquet(f'/tmp/clean_sales_{date}.parquet', index=False)
        # Push metrics to XCom for downstream tasks
        context['ti'].xcom_push(key='row_count',  value=len(clean))
        context['ti'].xcom_push(key='total_rev',  value=float(clean['revenue'].sum()))

    transform_task = PythonOperator(
        task_id='transform',
        python_callable=transform,
    )

    # Load
    def load(**context):
        from etl.load import load_to_postgres
        import pandas as pd
        date  = context['ds']
        df    = pd.read_parquet(f'/tmp/clean_sales_{date}.parquet')
        load_to_postgres(df, 'sales_daily', date)

    load_task = PythonOperator(
        task_id='load',
        python_callable=load,
    )

    # Run dbt transformations after load
    dbt_run = BashOperator(
        task_id='dbt_run',
        bash_command='cd /dbt && dbt run --select marts.sales_summary --vars '{"date": "{{ ds }}"}''
    )

    dbt_test = BashOperator(
        task_id='dbt_test',
        bash_command='cd /dbt && dbt test --select marts.sales_summary'
    )

    # Task dependencies — define the pipeline graph
    wait_for_file >> extract_task >> transform_task >> load_task >> dbt_run >> dbt_test

Data Quality with Great Expectations

turned on monitoring screen
Photo by Stephen Dawson on Unsplash
pip install great_expectations

import great_expectations as gx

context = gx.get_context()

# Define expectations for your data
validator = context.sources.pandas_default.read_csv('sales.csv')

validator.expect_column_to_exist('revenue')
validator.expect_column_values_to_not_be_null('date')
validator.expect_column_values_to_not_be_null('customer_id')
validator.expect_column_values_to_be_between('revenue', min_value=0, max_value=1_000_000)
validator.expect_column_values_to_match_regex('date', r'^\d{4}-\d{2}-\d{2}$')
validator.expect_column_values_to_be_unique('transaction_id')
validator.expect_column_median_to_be_between('revenue', min_value=100, max_value=10_000)
validator.expect_table_row_count_to_be_between(min_value=100, max_value=1_000_000)

results = validator.validate()
if not results['success']:
    failed = [r for r in results['results'] if not r['success']]
    print(f'❌ {len(failed)} expectations failed:')
    for f in failed:
        print(f'  - {f["expectation_config"]["expectation_type"]}: '
              f'{f["result"]}')
    raise ValueError('Data quality check failed')
else:
    print('✅ All data quality checks passed')

Conclusion

A robust data pipeline is built in layers: extraction (reliable, idempotent, logged), transformation (tested, reproducible, documented), loading (transactional, schema-validated), and quality (automated checks that fail loudly). Airflow handles orchestration and dependency management. dbt handles SQL transformations with built-in testing. Great Expectations catches data quality issues before they corrupt downstream models. Together they give you the infrastructure that makes data science teams 10x more productive — because reliable data means reliable models.

Leave feedback about this

  • Rating

Durgesh Kekare
Durgesh Kekarehttps://www.dataexpertise.in
Durgesh Kekare is a data science educator and founder of DataExpertise.in. With expertise in Python, machine learning, and analytics, he helps 10,000+ learners break into data careers.

Latest Posts

List of Categories