Skip to content
All Articles
Data Engineering··12 min read

How to Build Your First ETL Pipeline in Python

New to data engineering? Build your first ETL pipeline in Python: pull data from an API, clean it with pandas, and load it into a database, the right way.

ETLPythonData EngineeringPandasData PipelinesTutorial

The first time someone told me to "go build an ETL pipeline," it sounded like a job for a big team with a cluster and a budget. It is not. An ETL pipeline is three ordinary steps: get some data, tidy it up, and put it somewhere useful. That is the whole idea.

In this guide you will learn how to build an ETL pipeline in Python from scratch, one small piece at a time, and finish with a real script you can run and schedule. No prior data-engineering experience needed. If you can write a Python function and run a file, you can follow along. We will pull live data from a free public API, clean it with pandas, and load it into a database, then talk about what changes when this stops being a toy.

What an ETL pipeline actually is

Forget the jargon for a second. You already run ETL pipelines in real life. Making dinner is one. You buy ingredients (extract), you wash and chop them (transform), and you plate the meal (load). Data pipelines do the same thing with information instead of vegetables.

Extract API, CSV, database Transform clean & reshape Load database, warehouse
Extract pulls raw data in, Transform cleans and reshapes it, Load writes it out. The middle step is where most of the real work lives.

Here is why people bother splitting it into three named steps. Raw data is almost never in the shape you need. It has missing values, weird column names, prices stored as text, and duplicates. If you jam fetching, cleaning, and saving into one tangled block of code, you get something that works once on your laptop and breaks the moment anything changes. Three clear steps stay easy to read, easy to fix, and easy to reuse.

What you'll build

We will build a small pipeline that pulls the top 20 cryptocurrencies and their prices from a free public API, cleans the response into a tidy table, and saves it to a local database you can query. It is about 40 lines of code total, split across the three stages.

CoinGecko API public, no key Raw JSON list of dicts Clean table pandas DataFrame SQLite coins table extract() transform() load()
Each stage becomes one small function. That mapping, one job per function, is the habit that keeps pipelines maintainable as they grow.

Pick any API you like once you understand the shape. Weather, exchange rates, your own app's data. The steps do not change.

Set up your environment

Create a folder, make a virtual environment so your packages stay isolated, and install the three libraries we need.

setup.sh
python -m venv venv
source venv/bin/activate      # on Windows: venv\Scripts\activate
pip install requests pandas sqlalchemy

That is the whole setup. requests fetches data over HTTP, pandas is the workhorse for cleaning tabular data, and SQLAlchemy gives us a simple way to write to a database.

Step 1: Extract the data

Extraction just means "go get the raw data." We ask the API for the top 20 coins and hand back whatever it gives us. Notice we do not clean anything here. Fetching and cleaning are different jobs, so they get different functions.

extract.py
import requests
 
def extract():
    """Pull the top 20 coins by market cap from the CoinGecko API."""
    url = "https://api.coingecko.com/api/v3/coins/markets"
    params = {
        "vs_currency": "usd",
        "order": "market_cap_desc",
        "per_page": 20,
        "page": 1,
    }
    response = requests.get(url, params=params, timeout=30)
    response.raise_for_status()   # stop loudly if the API returns an error
    return response.json()        # a list of dictionaries

Run this on its own and you get back a list of Python dictionaries, one per coin, each with a couple of dozen fields you mostly do not care about. Cleaning that up is the next step.

Step 2: Transform it with pandas

This is the heart of the pipeline. We load the raw records into a pandas DataFrame (think of it as a spreadsheet in code), keep only the columns we want, rename them, fix the types, and drop anything broken.

transform.py
import pandas as pd
from datetime import datetime, timezone
 
def transform(raw_records):
    """Keep the useful columns, clean them, and stamp each row."""
    df = pd.DataFrame(raw_records)
 
    # 1. keep only the columns we actually need
    df = df[["id", "symbol", "name", "current_price", "market_cap"]]
 
    # 2. rename to clearer names
    df = df.rename(columns={
        "current_price": "price_usd",
        "market_cap": "market_cap_usd",
    })
 
    # 3. force numbers to be numbers, and drop rows with no price
    df["price_usd"] = pd.to_numeric(df["price_usd"], errors="coerce")
    df = df.dropna(subset=["price_usd"])
 
    # 4. record when this batch was pulled
    df["loaded_at"] = datetime.now(timezone.utc)
 
    return df

Read those four moves top to bottom, because they are the four you will repeat in almost every pipeline you ever write. Trim to the columns you need. Give them honest names. Make sure each column is the type you think it is. Add a timestamp so future-you knows when the data arrived. That last one feels optional now and saves you a headache later when you are staring at two batches and cannot tell which is fresh.

The errors="coerce" bit is quietly important. If a price comes back as junk instead of a number, pandas turns it into a blank rather than crashing, and the next line drops those blank rows. Real data is messy, so you plan for messy.

Step 3: Load it into a database

Loading writes the clean table to its final home. We will use SQLite, a database that lives in a single file and needs zero setup, which makes it perfect for learning. SQLAlchemy lets us swap that for PostgreSQL later by changing one line.

load.py
from sqlalchemy import create_engine
 
def load(df):
    """Write the cleaned rows into a local SQLite database."""
    engine = create_engine("sqlite:///crypto.db")
    df.to_sql("coins", engine, if_exists="replace", index=False)

Put it together and run it

Now the three functions become one pipeline. This is the file you actually run.

pipeline.py
from extract import extract
from transform import transform
from load import load
 
def run_pipeline():
    raw = extract()
    clean = transform(raw)
    load(clean)
    print(f"Loaded {len(clean)} rows into crypto.db")
 
if __name__ == "__main__":
    run_pipeline()
run.sh
python pipeline.py
# Loaded 20 rows into crypto.db

That is a working ETL pipeline. Twenty rows of clean crypto prices, pulled live and saved to a database, in about 40 lines. To prove it landed, query the database:

check.py
import sqlite3
 
conn = sqlite3.connect("crypto.db")
rows = conn.execute(
    "SELECT name, price_usd FROM coins ORDER BY market_cap_usd DESC LIMIT 5"
)
for name, price in rows:
    print(f"{name:<15} ${price:,.2f}")

Seeing your own data come back out of the database is the moment it clicks. You built the thing.

From a script to a real pipeline

Here is the honest part most tutorials skip. What you just built is correct, and it is also a toy. The jump from "runs on my laptop" to "a business depends on it at 2am" is where data engineering actually lives. You do not need all of this today, but you should know it exists, because it is what the next questions will be about.

Logging instead of print. print disappears the moment the script ends. Real pipelines record what happened so you can debug a 3am failure from the logs alone.

pipeline.py (with logging)
import logging
 
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
)
 
def run_pipeline():
    logging.info("Pipeline started")
    try:
        raw = extract()
        logging.info("Extracted %d records", len(raw))
        clean = transform(raw)
        load(clean)
        logging.info("Loaded %d rows", len(clean))
    except Exception:
        logging.exception("Pipeline failed")
        raise

Scheduling. A pipeline you run by hand is a script. A pipeline that runs itself is infrastructure. The simplest scheduler is cron, built into Mac and Linux.

crontab
# run the pipeline every day at 6am
0 6 * * * /path/to/venv/bin/python /path/to/pipeline.py >> /path/to/pipeline.log 2>&1

Idempotency and incremental loads. Running the pipeline twice should not create duplicate or double-counted data. And once your source has millions of rows, you stop reprocessing everything every run and start loading only what changed. That single idea, process the delta and not the whole world, is the biggest performance lever in data engineering. On a production pipeline I built that moves millions of records a day, switching to incremental upserts instead of full reloads cut data-prep time by about 40%. Same code shape you just wrote, different loading strategy.

Knowing when to graduate. A single script with cron is genuinely fine for a long time. You outgrow it when you have many pipelines with dependencies (run B only after A succeeds), or when one step gets too slow to run in a line. The first problem is what orchestrators like Dagster, Airflow and Prefect solve, and I compared them in Dagster vs Airflow vs Prefect. The second is when you reach for a queue and a pool of workers, which is how one pipeline I re-architected went from 40,000 to over two million records a day (the story is in scaling data pipelines with RabbitMQ). You do not need either yet. You just need to recognise the day you do.

Common beginner mistakes

I made most of these myself, so here they are before you do.

  • Cramming all three steps into one function. It feels faster and it always costs you later. Keep extract, transform and load separate.
  • Trusting the API blindly. Networks fail and APIs change. Use raise_for_status() and a timeout, and expect a bad response sometimes.
  • Confusing replace and append. One keeps history, one erases it. Pick on purpose.
  • No logging. When a scheduled run fails silently at night, print statements you cannot see are worthless.
  • Hardcoding secrets. API keys and database passwords belong in environment variables, never pasted into the script you push to GitHub.
  • Skipping the timestamp. Add a loaded_at column from day one. You will thank yourself the first time you debug stale data.

Frequently asked questions

What is an ETL pipeline?

An ETL pipeline is a process that Extracts data from a source, Transforms it into a clean and usable shape, and Loads it into a destination such as a database or data warehouse. It is the standard pattern for moving data from where it is created to where it gets analysed.

Is Python good for ETL?

Yes, Python is one of the most popular languages for ETL. It has a simple syntax and a deep set of libraries for the job: requests for fetching, pandas for cleaning and reshaping, and SQLAlchemy for loading into databases. For bigger workloads the same skills carry over to tools like PySpark.

What libraries do I need for ETL in Python?

For a beginner pipeline, three cover it: requests to pull data from APIs, pandas to clean and transform it, and SQLAlchemy to load it into a database. As you scale you might add Polars or DuckDB for speed and an orchestrator like Dagster or Airflow for scheduling and dependencies.

What is the difference between ETL and ELT?

In ETL you transform the data before loading it into the destination. In ELT you load the raw data first and transform it inside the warehouse afterwards, which suits modern cloud warehouses that are cheap to store in and fast to query. The letters are the same three steps in a different order.

How do I schedule an ETL pipeline in Python?

The simplest way is cron on Mac or Linux, which runs your script on a fixed schedule with one line in a crontab. As pipelines grow and gain dependencies, teams move to an orchestrator such as Airflow, Dagster or Prefect, which add retries, monitoring and the ability to run steps in the right order.

Where to go next

You now know how to build an ETL pipeline in Python, and more importantly, you know what the three steps are for and where a real one grows beyond a script. That mental model, extract cleanly, transform deliberately, load carefully, is the same one behind pipelines moving billions of rows. The scale changes. The shape does not.

Play with it next. Swap the crypto API for a weather one. Change replace to append and keep a history table. Add a second transform. Every change teaches you something the first version hid.

If you are past the toy stage and staring at a real pipeline that is slow, fragile, or quietly losing data, that is the work I do for a living. Here is how I can help, or tell me what you are building and I will point you in the right direction.

MH

Mirza Hammad Tariq

AWS Data Engineer with 5+ years building production-grade ETL pipelines, cloud data warehouses, and scalable data architectures in Python, SQL, Dagster, and AWS.

Work With Me
The proof

Related case studies

Data EngineeringDelivered

Dynamic, Fully-Automated ETL Pipeline on AWS

100% API automation processing millions of records daily

Built a dynamic ETL pipeline on AWS for a sales-engagement SaaS platform, using Glue, Redshift, Apache Hudi and Athena to automate extraction of milli…

−40%automation
PythonPySparkAWSAWS Glue
View Case Study
Data EngineeringHigh-Throughput

Scaling Data Pipeline with RabbitMQ

Scaling a verification workload from ~40K to 2M+ records a day with an event-driven worker fleet

Re-architected a bottlenecked, per-request processing system into a distributed, message-driven pipeline on RabbitMQ. A fleet of stateless async worke…

2M+/day~50×
Python 3.12asyncioRabbitMQaio-pika
View Case Study
Keep reading

Continue reading

Data Engineering

RabbitMQ Data Pipelines: Scale ETL without adding compute

How to scale a data pipeline with RabbitMQ: work queues, a horizontal worker pool, durability and backpressure. The pattern that took one pipeline past 2M records a day.

RabbitMQMessage QueueData EngineeringDistributed Systems
Data Engineering

Dagster vs Airflow vs Prefect for ETL in 2026

Dagster vs Airflow vs Prefect for ETL in 2026, compared by the one question they disagree on: what a pipeline is actually made of. Field notes from a 50M-records/day build.

DagsterAirflowPrefectETL
Data Engineering

AWS Athena Query Optimization: Scan Less, Pay Less

AWS Athena query optimization comes down to one thing: scanning less data. How partitioning, Parquet, bucketing and projection cut what you scan, and the bill.

Amazon AthenaQuery OptimizationCost OptimizationPartitioning
Taking on new projects · Outside IR35

Have a data pipeline or warehouse problem worth solving?

From messy source data to analytics-ready warehouses that cut cost. Let's scope it. I reply within one business day.