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.
20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.
- ✓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.
- 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.
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.
| Stage | Source / Destination | Details |
|---|---|---|
| Extract | api.polygon.io/v2/aggs | GET /ticker/{ticker}/prev, Bearer token |
| Transform | pandas DataFrame in memory | Flatten results[], keep ticker + date |
| Load | SQLite via SqliteHook | table stock_prices, upsert on (ticker, date) |
| Auth | connection market_data_api | key, host, extras: {token: ...} |
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.
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.
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.
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.
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.
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.
The API Key That Ended Up in the Repo
- 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.
airflow connections get market_data_apicurl -s 'https://api.polygon.io/v2/aggs/ticker/AAPL/prev?apiKey=$POLYGON_KEY' | head -c 200| File | Command / Code | Purpose |
|---|---|---|
| extract_task.py | from airflow.decorators import task | 2. Extract |
| transform_task.py | from airflow.decorators import task | 3. Transform |
| load_task.py | from airflow.decorators import task | 4. Load |
| market_etl_dag.py | from datetime import datetime, timedelta | 5. The Full DAG |
Key takeaways
Common mistakes to avoid
4 patternsHardcoding API keys in the DAG file
Combining extract and transform in one task
Returning the full DataFrame through XCom
Using plain INSERT instead of an upsert
Interview Questions on This Topic
What is XCom and how does TaskFlow pass data between tasks in this pipeline?
Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.
That's Airflow. Mark it forged?
4 min read · try the examples if you haven't