Home DevOps Airflow ETL Pipeline: Stock Data to SQLite, End to End
Intermediate 4 min · August 1, 2026

Airflow ETL Pipeline: Stock Data to SQLite, End to End

Airflow ETL pipeline: extract stock data from a REST API, transform with pandas, and load into SQLite with SqliteHook.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.

Follow
Production
production tested
August 01, 2026
last updated
1,750
articles · all by Naren
Before you start⏱ 15-20 min read
  • Airflow DAG basics and the TaskFlow API.
  • Comfort with pandas and basic SQL (INSERT, upsert).
  • A working Airflow instance with the SQLite provider installed.
  • A free Polygon.io API key for the exercise.
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • An ETL DAG is three atomic tasks: extract, transform, load — each independently retryable and testable.
  • Extract with a TaskFlow task and the requests library against the provider's REST endpoint.
  • Transform with pandas: flatten the JSON response into rows and clean dtypes before writing.
  • Load via SqliteHook and a SQLAlchemy engine with an idempotent upsert on the ticker-date key.
  • The API key lives in a connection, never in code; one leaked key in a repo starts a rotation fire.
  • A 3-task DAG runs in about 40 seconds end to end, versus the 15-minute cron script it replaced.
✦ Definition~90s read
What is Airflow ETL Pipeline?

An Airflow ETL pipeline is a DAG of three atomic tasks — extract from a REST API, transform with pandas, load into a database via a Hook — wired with TaskFlow and configured through connections.

An ETL pipeline is a kitchen.
Plain-English First

An ETL pipeline is a kitchen. The extract task is the shopper who fetches ingredients from the API market; the transform task is the prep cook who washes, slices, and arranges them on plates; the load task is the waiter who puts the finished plates in the database fridge. Each worker does one job, and if one slips, only that station replays — not the whole restaurant. The recipe (the DAG) says who hands what to whom, and the fridge key is a connection, not a note taped to the wall.

Every data team builds this eventually: pull rows from an API, clean them, stick them in a database. The shape of the DAG decides whether that pipeline is a joy to run or a 2 AM pager rotation.

We're building a stock-price ETL: Polygon REST API into a SQLite database, using the TaskFlow API, pandas for the transform, and SqliteHook for the load. Three tasks, each atomic, each retryable, each debuggable in isolation. That's the whole lesson in one line.

The traps are real and familiar: API keys hardcoded in the repo, 10 MB payloads pushed through XCom, loads that duplicate rows on every rerun. We'll hit each one and fix it properly. By the end you'll have a pipeline you'd actually put in production — and the muscle memory to build the next one faster.

1. Design First: Source, Destination, Transformation Plan

Before any code, write the contract. The Polygon API gives previous-day aggregates per ticker; the destination is a SQLite table; the transform turns nested JSON into flat rows. Writing this down first kills half the bugs before they exist.

This is the tech spec we'll build against. Notice what it pins down: the endpoint, the auth method, the target schema, the dedupe key. If a requirement changes, you change the spec, not the DAG.

StageSource / DestinationDetails
Extractapi.polygon.io/v2/aggsGET /ticker/{ticker}/prev, Bearer token
Transformpandas DataFrame in memoryFlatten results[], keep ticker + date
LoadSQLite via SqliteHooktable stock_prices, upsert on (ticker, date)
Authconnection market_data_apikey, host, extras: {token: ...}
🔥One Table per Job
A load target with a natural key and a single writer is the easiest thing to make idempotent. Keep the destination simple and the upsert key obvious — it pays off on every rerun.
📊 Production Insight
Write the tech spec before the DAG.
Pin the endpoint, schema, and dedupe key early.
A clear contract kills half the bugs before code.
🎯 Key Takeaway
Design the three stages before writing tasks.
The spec decides the schema, the key, and the auth.
A contract-first DAG is a DAG that survives reviews.
Design first: the ETL contract Design First: The ETL Contract Pin the contract before writing code Extract Transform Load Polygon /prev API Bearer token per ticker nested results[] JSON flatten to flat rows SQLite: stock_prices upsert (ticker, date) Write the contract before the code THECODEFORGE.IO
thecodeforge.io
Airflow Etl Pipeline

2. Extract: Authenticated REST Call as a Task

Extract is one task, one responsibility: hit the API, return the parsed JSON. Keep it dumb. No cleaning here, no filtering — the transform owns that.

Two production habits make this task survive: the API key comes from the connection (never from a constant), and the request has a timeout. An API that hangs without one steals a worker slot for the full task timeout.

Know the endpoint family. /v2/aggs/ticker/{ticker}/prev returns the previous trading session; its sibling /v1/open-close/{ticker}/{date}?adjusted=true returns one specific day's open-close bars, splits-adjusted when adjusted=true — the right call when "previous day" isn't the day you need.

The task returns the payload, and TaskFlow pushes it through XCom for the transform. Keep the payload small; for heavy responses, write to object storage and pass the URI instead.

extract_task.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import requests
from airflow.decorators import task
from airflow.hooks.base import BaseHook


@task

def extract_stock_data(tickers: list[str]) -> dict:
    conn = BaseHook.get_connection("market_data_api")
    token = conn.extra_dejson["token"]
    base_url = conn.host or "https://api.polygon.io"

    payloads = {}
    for ticker in tickers:
        resp = requests.get(
            f"{base_url}/v2/aggs/ticker/{ticker}/prev",
            headers={"Authorization": f"Bearer {token}"},
            timeout=30,
        )
        resp.raise_for_status()
        payloads[ticker] = resp.json()
    return payloads
Output
{'AAPL': {'ticker': 'AAPL', 'results': [{'T': '2026-07-31', 'o': 219.4, 'c': 221.7, 'h': 222.1, 'l': 218.2, 'v': 48210000}]}}
📊 Production Insight
One task, one responsibility: fetch and return.
Always set a request timeout on API calls.
Secrets come from connections, never from constants.
🎯 Key Takeaway
Extract fetches; transform cleans; load writes.
A timeout is non-negotiable on production API calls.
The key belongs to the connection, not the code.
Extract: one task, one responsibility Extract: One Task, One Responsibility Key from a connection, not from code Connection: market_data_api token + host — never in code extract_stock_data GET /v2/aggs/ticker/{t}/prev, 30s Parsed JSON payload pushed to transform via XCom Hardcoded key → vendor leak scan account suspended pending rotation THECODEFORGE.IO
thecodeforge.io
Airflow Etl Pipeline

3. Transform: Flattening JSON with pandas

Polygon returns nested results arrays with short field names: o, h, l, c, v, T. The transform expands them into readable columns and coerces types — this is where the schema contract from section 1 comes alive.

Two rules keep transform tasks honest: every field the load expects must exist (assert it), and the task returns a compact payload, typically rows as a list of dicts or a CSV path. Don't return a DataFrame through XCom; it serializes badly.

A transform task that produces nothing silently is the worst kind of failure. End it with a row-count check.

One Polygon quirk to expect: on market-closed days — weekends and holidays — the response body is sparse and the result keys are missing entirely. The row-count guard turns that into a loud, deliberate failure; decide per pipeline whether a closed day is an error or a legitimate empty batch, and make the transform's contract say which.

transform_task.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import pandas as pd
from airflow.decorators import task


@task
def transform_stock_payload(payloads: dict) -> list[dict]:
    frames = []
    for ticker, body in payloads.items():
        frame = pd.DataFrame(body.get("results", []))
        frame["ticker"] = ticker
        frame = frame.rename(columns={
            "T": "date", "o": "open", "h": "high",
            "l": "low", "c": "close", "v": "volume",
        })
        frames.append(frame)

    combined = pd.concat(frames, ignore_index=True)
    combined["date"] = pd.to_datetime(combined["date"]).dt.date
    combined = combined[["ticker", "date", "open", "high", "low", "close", "volume"]]

    rows = combined.to_dict("records")
    if not rows:
        raise ValueError("Transform produced zero rows")
    return rows
Output
[{'ticker': 'AAPL', 'date': '2026-07-31', 'open': 219.4, 'high': 222.1, 'low': 218.2, 'close': 221.7, 'volume': 48210000}]
📊 Production Insight
Flatten early, assert often, and count the rows.
Return compact records, never a DataFrame.
A silent zero-row transform is a hidden outage.
🎯 Key Takeaway
pandas makes JSON flattening a ten-line job.
Assert the schema the load expects.
End every transform with a row-count guard.
Transform: flattening JSON with pandas Transform: Flattening JSON With pandas Short names to readable columns Polygon response: results[] short names: o h l c v T pandas flatten + rename T→date, o→open, c→close Flat rows: list of dicts open, high, low, close, volume Assert fields; end with a row count Return compact rows, not DataFrames THECODEFORGE.IO
thecodeforge.io
Airflow Etl Pipeline

4. Load: SqliteHook + SQLAlchemy + Upsert

The load task opens the database through SqliteHook, builds a SQLAlchemy engine from the connection URI, and upserts on the (ticker, date) key. Idempotent by construction: rerun it a hundred times, the table holds the same rows.

Why a hook instead of a raw sqlite3 call? The hook owns the connection config — the same DAG runs unchanged across dev and prod because the database path lives in the connection. The engine gives you to_sql for bulk inserts and parameterized upsert for control.

One trap: SQLite is a file, and "where is the file" is a connection detail. Two connections pointing at two files is a classic "green run, empty table" bug.

If the target ever lacks a natural unique key, the classic idempotency pattern is delete-then-insert keyed on run date: delete the run's rows first, then insert — replays produce identical tables without needing ON CONFLICT.

load_task.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from airflow.decorators import task
from airflow.providers.sqlite.hooks.sqlite import SqliteHook
from sqlalchemy import text


@task
def load_stock_prices(rows: list[dict]) -> int:
    hook = SqliteHook(sqlite_conn_id="market_database_conn")
    engine = hook.get_sqlalchemy_engine()

    with engine.begin() as conn:
        for row in rows:
            conn.execute(
                text("""
                    INSERT INTO stock_prices
                        (ticker, date, open, high, low, close, volume)
                    VALUES (:ticker, :date, :open, :high, :low, :close, :volume)
                    ON CONFLICT (ticker, date) DO UPDATE SET
                        open = excluded.open,
                        high = excluded.high,
                        low = excluded.low,
                        close = excluded.close,
                        volume = excluded.volume
                """),
                row,
            )
    return len(rows)
Output
Loaded 4 rows for 4 tickers; reruns update in place.
📊 Production Insight
Hook owns the connection; the DAG stays portable.
Upsert on the natural key makes loads idempotent.
Two connections at two files mean empty tables.
🎯 Key Takeaway
SqliteHook + SQLAlchemy make loads clean and testable.
ON CONFLICT DO UPDATE is your rerun safety net.
The file location is connection config, not DAG code.

5. The Full DAG: Wiring It Together

Here's the complete file, from imports to schedule. Three tasks, explicit dependencies, sensible defaults: retries on transient API blips, catchup off for a pipeline that only needs today, and tags so the team finds it in the UI.

Note what's absent: no API key, no file path, no credentials. Everything environment-specific sits in connections. The DAG is deployable to dev or prod unchanged.

This is the shape to replicate for every ETL you build: extract, transform, load, wired left to right, each task independently retryable.

market_etl_dag.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
from datetime import datetime, timedelta

import pandas as pd
import requests
from airflow.decorators import dag, task
from airflow.hooks.base import BaseHook
from airflow.providers.sqlite.hooks.sqlite import SqliteHook
from sqlalchemy import text

TICKERS = ["AAPL", "MSFT", "GOOGL", "NVDA"]


@task
def extract_stock_data(tickers: list[str]) -> dict:
    conn = BaseHook.get_connection("market_data_api")
    token = conn.extra_dejson["token"]
    base_url = conn.host or "https://api.polygon.io"

    payloads = {}
    for ticker in tickers:
        resp = requests.get(
            f"{base_url}/v2/aggs/ticker/{ticker}/prev",
            headers={"Authorization": f"Bearer {token}"},
            timeout=30,
        )
        resp.raise_for_status()
        payloads[ticker] = resp.json()
    return payloads


@task
def transform_stock_payload(payloads: dict) -> list[dict]:
    frames = []
    for ticker, body in payloads.items():
        frame = pd.DataFrame(body.get("results", []))
        frame["ticker"] = ticker
        frame = frame.rename(columns={
            "T": "date", "o": "open", "h": "high",
            "l": "low", "c": "close", "v": "volume",
        })
        frames.append(frame)

    combined = pd.concat(frames, ignore_index=True)
    combined["date"] = pd.to_datetime(combined["date"]).dt.date
    combined = combined[["ticker", "date", "open", "high", "low", "close", "volume"]]

    rows = combined.to_dict("records")
    if not rows:
        raise ValueError("Transform produced zero rows")
    return rows


@task
def load_stock_prices(rows: list[dict]) -> int:
    hook = SqliteHook(sqlite_conn_id="market_database_conn")
    engine = hook.get_sqlalchemy_engine()

    with engine.begin() as conn:
        for row in rows:
            conn.execute(
                text("""
                    INSERT INTO stock_prices
                        (ticker, date, open, high, low, close, volume)
                    VALUES (:ticker, :date, :open, :high, :low, :close, :volume)
                    ON CONFLICT (ticker, date) DO UPDATE SET
                        open = excluded.open,
                        high = excluded.high,
                        low = excluded.low,
                        close = excluded.close,
                        volume = excluded.volume
                """),
                row,
            )
    return len(rows)


@dag(
    schedule="@daily",
    start_date=datetime(2026, 7, 1),
    catchup=False,
    tags=["market", "etl"],
    default_args={"retries": 2, "retry_delay": timedelta(minutes=5)},
)
def market_etl():
    extract_stock_data(TICKERS) >> transform_stock_payload() >> load_stock_prices()


market_etl()
Output
Run 2026-08-01T00:00:00: extract_stock_data succeeded, transform_stock_payload succeeded, load_stock_prices returned 4 rows.
📊 Production Insight
The full DAG is three tasks and two arrows.
Retries cover transient API and DB blips.
Zero credentials in the file means zero leak surface.
🎯 Key Takeaway
This file is the production template for API-to-DB ETL.
Every stage is atomic and independently retryable.
Connections carry the environment; the DAG stays clean.

6. Secrets Through Connections, Never Code

The incident that opened this article had one cause: a key in code. The fix is structural, not stylistic. Credentials live in Airflow connections; the DAG references connection ids; the secrets backend (Vault, AWS Secrets Manager) holds the values.

Rotation becomes a config change instead of a deploy: update the secret, and every DAG picks it up on the next run. No redeploy, no rollback dance, no git history archaeology.

Add the scan to CI, too. The key already in the repo's history is the one you can't delete by deleting the line.

⚠ The Repo Is Public by Default
Treat every push as a potential leak. Scan for keys in CI, rotate credentials on any exposure, and keep secrets in a backend — a leaked key costs hours of rotation and a trust hit with the vendor.
📊 Production Insight
Connection ids are the DAG's only credential touchpoint.
Rotation becomes a config change, not a redeploy.
CI key scanning turns leaks into pull-request failures.
🎯 Key Takeaway
Credentials belong to the platform, never the repo.
Secrets backends make rotation boring and fast.
Scan every push; leaked history can't be uncommitted.

7. Running, Verifying, and Extending

To verify without waiting for the schedule: airflow dags test market_etl runs the whole DAG once, in the scheduler's context, and streams the logs. Then check the database with a quick count and a duplicate check — the two queries every ETL review should run.

``bash airflow dags test market_etl 2026-08-01 sqlite3 /opt/airflow/data/market.db "SELECT COUNT() FROM stock_prices;" sqlite3 /opt/airflow/data/market.db \ "SELECT ticker, date, COUNT() FROM stock_prices GROUP BY 1,2 HAVING COUNT(*) > 1;" ``

Extending the pipeline is mechanical now: add a ticker to TICKERS, or map over tickers with expand() when the list grows. Add a quality gate task after load — row count matches the API response count — and you have a production pipeline.

A stronger gate than a row count is a schema validation task between transform and load: pandera or Great Expectations checks required fields, types, and value ranges, and fails before a single row touches the table — the same checks that used to live as buried asserts become a visible, testable task that can page someone.

The 40-second runtime vs the 15-minute cron script isn't the win. The win is failure isolation: when the API is down, only extract fails, and it retries on its own.

📊 Production Insight
airflow dags test validates a full run in minutes.
Count and duplicate checks belong in every ETL review.
Failure isolation is the real win over cron.
🎯 Key Takeaway
Test the DAG before the schedule ever fires.
Verify the table with count and duplicate queries.
A retryable extract beats a 15-minute monolithic script.
● Production incidentPOST-MORTEMseverity: high

The API Key That Ended Up in the Repo

Symptom
A routine git push included a DAG file with the market data API key hardcoded. The vendor's automated leak scan flagged the key, and the account was suspended pending rotation.
Assumption
The team assumed the repo was private and the key was low-risk, so they inlined it for "simplicity."
Root cause
The extract task called the API with an API_KEY constant defined at the top of the DAG file. One commit, one shared repo, and the key was effectively public.
Fix
Moved the key into an Airflow connection (market_data_api), pointed the DAG at the connection id, and configured Vault as the secrets backend. The DAG code now contains zero credentials.
Key lesson
  • Credentials never belong in DAG files — the repo is not the secret store.
  • Connections keep secrets in the platform and make rotation a config change, not a code change.
  • Treat every git push as public; scan for keys in CI before merge.
  • A connection id is documentation: it names the system, not the password.
Production debug guideSymptom to Action5 entries
Symptom · 01
Extract task fails with a 401 response
Fix
Get the connection with airflow connections get market_data_api, verify the key and base URL, and replay the request with curl.
Symptom · 02
Transform task raises KeyError on a missing JSON field
Fix
Log the raw payload keys once; API schemas change without notice, so assert on the fields you need.
Symptom · 03
Load task succeeds but the table stays empty
Fix
Check which SQLite file the hook opened — the connection's host or schema may point elsewhere from the file the DAG writes.
Symptom · 04
Rows duplicate on every rerun
Fix
The load is not idempotent; switch to an upsert keyed on (ticker, date).
Symptom · 05
DAG runs locally but fails on the scheduler
Fix
The worker image is missing a dependency; pin requests and pandas in requirements.txt and rebuild.
★ Quick Debug Cheat SheetFast diagnostics for the market ETL DAG.
401 on the extract task
Immediate action
Verify the connection credentials
Commands
airflow connections get market_data_api
curl -s 'https://api.polygon.io/v2/aggs/ticker/AAPL/prev?apiKey=$POLYGON_KEY' | head -c 200
Fix now
Rotate the key in the connection and clear the failed task
Empty table after a green run+
Immediate action
Check which SQLite file the hook used
Commands
airflow connections get market_database_conn
sqlite3 /opt/airflow/data/market.db "SELECT COUNT(*) FROM stock_prices;"
Fix now
Point host and schema in the connection to the same file the DAG writes
Duplicate rows on rerun+
Immediate action
Confirm the load uses an upsert
Commands
sqlite3 /opt/airflow/data/market.db "SELECT ticker, date, COUNT(*) FROM stock_prices GROUP BY 1,2 HAVING COUNT(*) > 1;"
python3 -c "from airflow.hooks.base import BaseHook; print(BaseHook.get_connection('market_database_conn').get_uri())"
Fix now
Rewrite the load with INSERT ... ON CONFLICT (ticker, date) DO UPDATE
ModuleNotFoundError on the worker+
Immediate action
Check the worker's installed packages
Commands
pip list | grep -iE 'requests|pandas|sqlalchemy'
docker compose exec worker pip install requests pandas sqlalchemy
Fix now
Pin requests, pandas, and sqlalchemy in requirements.txt and rebuild the image
ETL vs ELT in Airflow
AspectETL (transform before load)ELT (load then transform)
Transform locationPython/pandas tasks in AirflowWarehouse SQL (dbt, stored procedures)
Warehouse loadSmall, transformed datasetsRaw data first, transform later
ScalingBounded by worker CPUBounded by warehouse compute
Retry semanticsRetry transform + loadRetry transform only
Best forSQLite/small targetsCloud warehouses (Snowflake, BigQuery)
⚙ Quick Reference
4 commands from this guide
FileCommand / CodePurpose
extract_task.pyfrom airflow.decorators import task2. Extract
transform_task.pyfrom airflow.decorators import task3. Transform
load_task.pyfrom airflow.decorators import task4. Load
market_etl_dag.pyfrom datetime import datetime, timedelta5. The Full DAG

Key takeaways

1
Design the ETL contract first
source, destination, schema, dedupe key.
2
Keep extract, transform, and load as three atomic, retryable tasks.
3
Use pandas for flattening and assert on the fields the load expects.
4
Load via SqliteHook with an idempotent upsert on the natural key.
5
Secrets live in connections and secrets backends, never in DAG code.

Common mistakes to avoid

4 patterns
×

Hardcoding API keys in the DAG file

Symptom
A push to the shared repo exposes the key and triggers a vendor leak alert
Fix
Store the key in an Airflow connection backed by a secrets backend; reference the connection id in code.
×

Combining extract and transform in one task

Symptom
A transform bug forces the whole fetch to replay; API rate limits start throttling
Fix
Split into atomic extract and transform tasks; each retries independently.
×

Returning the full DataFrame through XCom

Symptom
The metadata DB bloats and every downstream pull slows down
Fix
Return compact records or write the payload to object storage and pass the URI.
×

Using plain INSERT instead of an upsert

Symptom
Every rerun and backfill duplicates rows in the table
Fix
Upsert on the natural key with ON CONFLICT DO UPDATE so replays are idempotent.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is XCom and how does TaskFlow pass data between tasks in this pipel...
Q02SENIOR
How would you make the load step idempotent, and why does it matter?
Q03SENIOR
This DAG is hardcoded to four tickers. How would you scale it to 500 wit...
Q01 of 03JUNIOR

What is XCom and how does TaskFlow pass data between tasks in this pipeline?

ANSWER
XCom is Airflow's mechanism for passing messages between tasks, stored in the metadata DB. With the TaskFlow API, a task's return value is pushed to XCom automatically, and when another task declares it as a parameter, Airflow pulls it and passes it in. In this DAG, extract returns the payload dict, transform receives it, and so on. Keep payloads small — XCom is for references and summaries, not bulk data.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Why SQLite in a production ETL pipeline?
02
How do I create the connections this DAG needs?
03
When should I use expand() (task mapping) in this pipeline?
04
Why does my task fail with 401 even though the key works in a script?
05
What should I add before calling this a production pipeline?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.

Follow
Verified
production tested
August 01, 2026
last updated
1,750
articles · all by Naren
🔥

That's Airflow. Mark it forged?

4 min read · try the examples if you haven't

Previous
Airflow Conditional Execution
14 / 37 · Airflow
Next
Airflow Providers