Most ETL-with-Python content stops at naming libraries. This page goes further: a complete, production-ready script template, the actual distinction between a transformation library and an orchestration tool, and the specific failure modes that turn a working script into a fragile one.
Before automating anything, it helps to be precise about what each phase actually does.
Rather than three disconnected snippets, here's what a real pipeline looks like end to end, extracting from an API, cleansing the data, and loading it into a SQL database, with logging and error handling built in from the start:
import logging
import pandas as pd
import requests
from sqlalchemy import create_engine
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)
def extract(api_url: str, params: dict) -> pd.DataFrame:
logger.info("Extracting data from %s", api_url)
response = requests.get(api_url, params=params, timeout=30)
response.raise_for_status()
return pd.DataFrame(response.json()["data"])
def transform(df: pd.DataFrame) -> pd.DataFrame:
logger.info("Transforming %d rows", len(df))
df = df.drop_duplicates().dropna(subset=["customer_id"])
df["email"] = df["email"].str.lower().str.strip()
df["created_at"] = pd.to_datetime(df["created_at"], utc=True)
return df
def load(df: pd.DataFrame, engine, table_name: str) -> None:
logger.info("Loading %d rows into %s", len(df), table_name)
df.to_sql(table_name, engine, if_exists="append", index=False)
def run_pipeline():
engine = create_engine("postgresql://user:password@localhost/warehouse")
try:
raw = extract("https://api.example.com/v1/customers", {"since": "2026-01-01"})
clean = transform(raw)
load(clean, engine, "customer_summary")
logger.info("Pipeline completed successfully")
except Exception:
logger.exception("Pipeline failed")
raise
if __name__ == "__main__":
run_pipeline()
Each function has one job and a clear interface, which makes this testable and maintainable as it grows, compared to a single long script with everything inline.
A lot of confusion in this space comes from treating "Python ETL tool" as one category. It isn't:
| Category | Purpose | Examples |
|---|---|---|
| Data manipulation libraries | Extract, transform, and load logic itself | pandas, DLT, petl, Bonobo |
| Full ETL frameworks | End-to-end pipeline building with a broader feature set | Mage.ai, Kedro |
| Orchestration tools | Scheduling, dependency management, retries, monitoring | Apache Airflow, Dagster, Prefect |
| Data quality frameworks | Automated validation and testing of data | Great Expectations (GX) |
You typically need one tool from the first or second category and one from the third; they're not substitutes for each other. DLT (data load tool) in particular has become a common lightweight starting point for extraction specifically, useful when you need reliable API or database extraction without adopting a full framework. Prefect is worth knowing about if Airflow's operational overhead (a scheduler, web server, and metadata database to maintain) is the actual pain point; it can migrate existing Airflow DAGs directly rather than requiring a rebuild.
import pandas as pd
from sqlalchemy import create_engine
engine = create_engine("postgresql://user:password@localhost/source_db")
customers = pd.read_sql("SELECT id, name, email, created_at FROM customers", engine)
For files, pandas.read_csv(), read_excel(), and read_json() cover most flat-file cases directly, with openpyxl handling more advanced Excel-specific formatting needs under the hood.
# Clean
df = df.drop_duplicates().dropna(subset=["customer_id"])
df["email"] = df["email"].str.lower().str.strip()
# Restructure
df = df.rename(columns={"cust_id": "customer_id"})
summary = df.groupby("region")["revenue"].sum().reset_index()
# Enrich
df["order_value_tier"] = pd.cut(df["order_value"], bins=[0, 50, 200, float("inf")], labels=["low", "mid", "high"])
# To a database
df.to_sql("customer_summary", engine, if_exists="append", index=False)
# To cloud storage
df.to_parquet("s3://your-bucket/processed/customer_summary.parquet")
For large volumes, bulk-loading utilities (native COPY commands in Postgres/Redshift or warehouse-specific bulk loaders) significantly outperform row-by-row inserts; to_sql() is fine for moderate volumes but becomes a bottleneck at scale.
Scheduling options scale with complexity:
Once a pipeline outgrows full reloads, two related techniques matter:
For teams whose ETL work runs on legacy platforms, our Custom ETL Workflows with Python Scripting piece goes deeper into SQLAlchemy-heavy patterns, and our Automating ETL Testing with Python piece covers the validation layer.
Building a Python ETL pipeline that survives production contact, schema drift, scale, and real failure modes is different from building a working prototype. Our ETL Migration Solutions practice builds and modernizes these pipelines with the same validation discipline covered throughout this page, delivered across 400+ client engagements over 17 years.
Ready to Automate Your Data Workflows?
Talk to a DataTerrain ETL Specialist →