Building Your First Data Pipeline with Python and Airflow
Extract from an API, transform with pandas, load to a warehouse — scheduled in Airflow with retries, idempotency, and a local Docker setup.
By Dinesh Chandra
Table of contents
Every data engineer remembers their first pipeline. This tutorial builds one you can run on a laptop: pull a public API, clean it with pandas, load Postgres, and let Airflow retry and schedule the work.
It is a teaching DAG. The habits (idempotency, no giant XCom, test before schedule) are the same ones you want on Snowflake and dbt later.
flowchart LR
api[Public API] --> extract[Extract task]
extract --> transform[Transform with pandas]
transform --> load[Load to Postgres]
load --> warehouse[(Warehouse)]
What we are building
Three tasks, one daily run:
- Extract — hourly weather from the Open-Meteo API
- Transform — types and column names in pandas
- Load — append into Postgres, then upsert if you re-run the day
Prerequisites
- Python 3.10+
- Docker (easiest local Airflow)
- Basic SQL
If Docker is not an option, a local venv + airflow standalone works.
The DAG code is the same.
Step 1: Run Airflow with Docker
curl -LfO 'https://airflow.apache.org/docs/apache-airflow/stable/docker-compose.yaml'
docker compose up airflow-init
docker compose up -d
Open http://localhost:8080. Default compose credentials are in the
official doc for your Airflow version — do not assume airflow/airflow
on a shared machine.
Put DAGs in the dags/ folder the compose file mounts.
Step 2: Write the DAG
Create dags/weather_pipeline.py:
from datetime import datetime, timedelta
from pathlib import Path
import pandas as pd
import requests
from airflow.decorators import dag, task
from airflow.models import Variable
DATA_DIR = Path("/opt/airflow/data/weather")
@dag(
schedule="@daily",
start_date=datetime(2026, 1, 1),
catchup=False,
default_args={"retries": 2, "retry_delay": timedelta(minutes=5)},
tags=["tutorial"],
)
def weather_pipeline():
@task
def extract(logical_date: str) -> str:
DATA_DIR.mkdir(parents=True, exist_ok=True)
url = (
"https://api.open-meteo.com/v1/forecast"
"?latitude=52.52&longitude=13.41&hourly=temperature_2m"
)
raw = requests.get(url, timeout=30)
raw.raise_for_status()
path = DATA_DIR / f"{logical_date}.json"
path.write_text(raw.text, encoding="utf-8")
return str(path)
@task
def transform(path: str, logical_date: str) -> str:
import json
payload = json.loads(Path(path).read_text(encoding="utf-8"))
df = pd.DataFrame(payload["hourly"])
df["time"] = pd.to_datetime(df["time"])
df = df.rename(columns={"temperature_2m": "temp_celsius"})
df["run_date"] = logical_date
out = DATA_DIR / f"{logical_date}.parquet"
df.to_parquet(out, index=False)
return str(out)
@task
def load(parquet_path: str) -> None:
from sqlalchemy import create_engine, text
conn = Variable.get(
"WAREHOUSE_URI",
default_var="postgresql://user:pass@warehouse:5432/analytics",
)
engine = create_engine(conn)
df = pd.read_parquet(parquet_path)
run_date = str(df["run_date"].iloc[0])
with engine.begin() as cxn:
cxn.execute(
text("delete from hourly_weather where run_date = :d"),
{"d": run_date},
)
df.to_sql("hourly_weather", cxn, if_exists="append", index=False)
day = "{{ ds }}"
load(transform(extract(day), day))
weather_pipeline()
Two upgrades from the “hello world” version:
- Tasks pass paths, not the whole JSON, through XCom.
- Load deletes the run date first, so a retry does not duplicate rows.
Create hourly_weather once (or let the first to_sql create it, then
tighten types).
Step 3: Test before you enable the schedule
docker compose exec airflow-scheduler airflow dags list
docker compose exec airflow-scheduler airflow dags test weather_pipeline 2026-08-20
dags test runs the DAG for one logical date without the scheduler.
Fix import errors here, not at 3 a.m.
Then enable the DAG in the UI. Three tasks should go green.
Idempotency (why the DELETE is there)
Airflow retries. You will also backfill. If load only appends, every
retry doubles the day.
Patterns that work:
- Delete-then-insert for the partition (
run_date,ds,hour) INSERT … ON CONFLICT/MERGEon a natural key- Write to a dated table and swap
What does not work: “the API is only called once so we are fine.” The API is not the only thing that retries.
ds is the day you meant, not the clock
{{ ds }} is the logical date of the run. datetime.now() is
when the worker happened to start. A retry at 03:12 and a backfill
of last Tuesday must write the same partition.
# wrong — every retry is a new "now"
run_date = datetime.utcnow().date().isoformat()
# right — the interval the DAG is covering
run_date = "{{ ds }}"
The extract URL should also take that date when the API allows it. A forecast endpoint that only returns “today” is a teaching shortcut. A production extract keys on the data interval.
flowchart LR
interval[Data interval 2026-08-20] --> extract[Extract for that day]
extract --> files["/data/weather/2026-08-20.*"]
files --> load[Delete + insert that partition]
One logical date, one folder, one warehouse partition. Retries overwrite, they do not append a second now().
Catchup is a loaded gun
This DAG has catchup=False. That is correct for a first tutorial.
If you later set catchup=True and move start_date back a year,
the scheduler will enqueue hundreds of runs. Do that only when:
- Every task is idempotent on
ds - The API and warehouse can take the burst
- You have a pause plan (
max_active_runs=1is a start)
@dag(
schedule="@daily",
start_date=datetime(2026, 1, 1),
catchup=False,
max_active_runs=1,
default_args={"retries": 2, "retry_delay": timedelta(minutes=5)},
)
A backfill you intend is airflow dags backfill (or the UI) for
a date range — not a surprise queue after a config edit.
What not to put in XCom
XCom is a metadata store. A 50 MB dataframe in XCom will hurt the metadata database. Write Parquet or a staging table. Pass the path or the table name.
Where to go next
- Assert row counts and nulls between transform and load. See data quality checks.
- Parameterize lat/long with an Airflow Variable or DAG param.
- Swap Postgres for Snowflake (
SnowflakeOperatoror a Python hook) once the shape is right. - Compare orchestrators in Airflow vs Dagster vs Prefect.
If you want more practice after this tutorial, use interview prep or the Airflow practice test.
FAQ
Why did a retry double yesterday’s rows?
load appended. Delete the run_date partition (or MERGE on
the grain) before you insert. Idempotency is a write pattern, not
a scheduler setting.
Should I pass the pandas DataFrame through XCom? No. Write Parquet or a staging table. Pass the path. XCom is for small metadata.
What is the difference between ds and data_interval_end?
ds is the interval start as YYYY-MM-DD. Daily DAGs usually
key partitions on ds. Hourly DAGs need the hour, not just the
day — use data_interval_start or ts.
Is airflow dags test the same as a scheduled run?
It executes the tasks for one logical date without the scheduler
queue. Use it to catch import and type errors. It is not a
substitute for one real scheduled run in a non-prod environment.
When do I turn catchup on?
When you want historical intervals and every task can safely
rewrite those partitions. Leave it off until you have proven
that with a manual backfill of two days.
Enjoyed this post?
Get the next one in your inbox — one email a week, no spam.
Newsletter signup is not live yet. Use the contact form if you want to be notified.