03 · Extraction Basics (Files, APIs, Databases)¶
Extraction is the "E" in ETL/ELT: getting raw data out of wherever it lives and into a shape your pipeline can work with. This lesson walks through the three most common sources — a local file, an HTTP API, and a database — and the one design rule that applies to all three: extraction should not judge the data, only fetch it.
What actually ran
File and database extraction ran locally against the standard library
(csv, json, sqlite3). The API example uses Python's
urllib.request but is shown against a local stand-in function instead
of a live network call, since this lesson's code must run offline —
the pagination logic itself is real and directly portable to requests
or urllib.
Extracting from a file¶
import csv, io
csv_text = """product_id,name,price,in_stock
1,Widget,9.99,true
2,Gadget,19.99,false
3,Gizmo,14.50,true
"""
def extract_csv(text):
reader = csv.DictReader(io.StringIO(text))
return list(reader)
rows = extract_csv(csv_text)
print(f"Extracted {len(rows)} rows from CSV")
print(rows[0])
Extracted 3 rows from CSV
{'product_id': '1', 'name': 'Widget', 'price': '9.99', 'in_stock': 'true'}
Every field is still a string — price is "9.99", not 9.99, and
in_stock is "true", not True. That's correct behavior for an extract
step: type casting is a transform decision (Level 1, lesson 4), not an
extraction one.
Extracting from an API (with pagination)¶
Real APIs rarely return everything in one response — they paginate. The pattern is always the same: request a page, check whether there's a next page, repeat until there isn't.
# Stand-in for a real API — same shape as calling `requests.get(url, params=...)`
# and reading `.json()`, just without a live network call in this lesson.
_fake_pages = {
1: {"items": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}], "next_page": 2},
2: {"items": [{"id": 3, "name": "Carla"}], "next_page": None},
}
def fetch_page(page_number):
return _fake_pages[page_number]
def extract_api():
all_items = []
page = 1
while page is not None:
response = fetch_page(page)
all_items.extend(response["items"])
page = response["next_page"]
return all_items
items = extract_api()
print(f"Extracted {len(items)} items across pages")
for item in items:
print(" ", item)
Extracted 3 items across pages
{'id': 1, 'name': 'Alice'}
{'id': 2, 'name': 'Bob'}
{'id': 3, 'name': 'Carla'}
With a real API, fetch_page would be:
import urllib.request, json
def fetch_page(page_number):
url = f"https://api.example.com/users?page={page_number}"
with urllib.request.urlopen(url) as resp:
return json.load(resp)
The pagination loop itself doesn't change — that's the point of separating
"how do I know when to stop" (a loop over next_page) from "how do I fetch
one page" (the part that actually varies per API).
Extracting from a database¶
import sqlite3
# Set up a small "production" database to extract from
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE customers (id INTEGER, name TEXT, region TEXT)")
conn.executemany(
"INSERT INTO customers VALUES (?, ?, ?)",
[(1, "Ava Chen", "APAC"), (2, "Ravi Kumar", "APAC"), (3, "Lena Fischer", "EU")],
)
conn.commit()
def extract_db(connection, query):
cursor = connection.execute(query)
columns = [d[0] for d in cursor.description]
return [dict(zip(columns, row)) for row in cursor.fetchall()]
rows = extract_db(conn, "SELECT * FROM customers WHERE region = 'APAC'")
print(f"Extracted {len(rows)} rows from database")
for row in rows:
print(" ", row)
Extracted 2 rows from database
{'id': 1, 'name': 'Ava Chen', 'region': 'APAC'}
{'id': 2, 'name': 'Ravi Kumar', 'region': 'APAC'}
Two things worth noting: the query itself (WHERE region = 'APAC') already
scopes the extraction — pushing filters down to the source is almost always
cheaper than pulling everything and filtering in Python. And extraction from
a live production database should always use a read replica or a dedicated
reporting connection when one exists, never the primary transactional
connection — an expensive analytical query competing with live traffic is a
common way pipelines cause outages.
The "extraction should not judge" rule¶
Across all three sources, the shared discipline is: extraction returns what the source gave you, unmodified, even if it looks wrong. Don't drop a row with a missing field, don't correct a typo, don't cast a type here. Every one of those is a business decision — normalizing them belongs in Transform, where it's visible, testable, and change-controlled in one place, not scattered wherever a source happens to be read.
# Wrong: judging data during extraction
def extract_csv_bad(text):
reader = csv.DictReader(io.StringIO(text))
return [r for r in reader if r["in_stock"] == "true"] # silent filtering!
# Right: extraction stays dumb, filtering is an explicit transform decision
def extract_csv_good(text):
return list(csv.DictReader(io.StringIO(text)))
The "bad" version silently drops out-of-stock products with no record that it happened — six months later, someone asks "why don't we have historical out-of-stock data?" and the answer is buried in an extraction function nobody thought to check.
Traps¶
- Filtering or casting inside extraction. Keeps the pipeline harder to debug and impossible to reprocess differently later.
- Extracting from a production primary database directly. Use a replica, snapshot, or dedicated extract user/connection.
- Assuming an API's first page is representative. Rate limits, partial failures, and inconsistent page sizes are common — always loop until the source's actual "no more data" signal, never a fixed guess at page count.
- Not recording extraction metadata. Not shown above for brevity, but real pipelines should record when an extract ran and how many rows it pulled — that count is the cheapest sanity check available downstream.
Cheat sheet¶
| Source | Extraction unit | Stop condition |
|---|---|---|
| File | Whole file / row iterator | End of file |
| API | One page per request | Empty page / no next-page token |
| Database | Query result set | Query returns 0 rows (or reaches the watermark, Level 2) |
How It Actually Works¶
Extraction is fundamentally about pulling rows through a cursor — a server-side pointer into a result set — and the mechanics of that cursor determine both performance and correctness.
When you issue a query against a source database, the database's query planner produces an
execution plan and starts materializing results, but it does not send the entire result
set over the wire at once. A cursor holds the query's execution state (where it is in the
scan, what rows have been emitted) on the server side, and the client asks for the next
batch (FETCH 10000, or a driver's internal page size) on demand. This is why streaming
extraction from a 500 GB table doesn't require 500 GB of client-side memory: memory usage is
bounded by batch size, not table size, as long as you actually consume the cursor
incrementally instead of calling something like fetchall().
Connection-level mechanics matter too: a long-running extraction holds a database connection
(and, depending on isolation level, a snapshot or read lock) open for the whole duration.
Under MVCC (PostgreSQL, MySQL/InnoDB), your extraction query sees a consistent snapshot
as of the transaction's start — rows updated by other transactions after your snapshot was
taken are invisible to you, which is exactly why a naive extraction plus a separate row-count
check can appear "inconsistent" against a system that kept changing underneath it. Extracting
via an API instead of direct SQL adds another mechanical layer: pagination tokens (cursor-
based, not offset-based, in well-designed APIs) that encode the last-seen sort key server-
side, because offset-based pagination (LIMIT 1000 OFFSET 50000) forces the database to
recompute and discard the first 50,000 rows on every page, which degrades to O(n²) across a
full extraction.
Exercise¶
Combine all three sources into one extract_all() function that returns a
dict {"products": [...], "users": [...], "customers": [...]} using the
three extraction functions above. Add a print statement that reports the row
count for each source, and confirm none of the three functions perform any
filtering, casting, or cleanup — if you find one that does, refactor it so
extraction stays "dumb."