Python Primer·Chapter 13
Getting Data In: Databases and APIs
When the data is not in a file. Connecting to a database without pulling the whole table, writing queries that cannot be injected into, and calling an API politely enough that it keeps answering.
Most real data lives in a database or behind an API rather than in a file someone emailed you. Both have a pattern worth learning once.
Databases
The connection layer is SQLAlchemy, which gives pandas a consistent interface regardless of which database is underneath.
import pandas as pd
from sqlalchemy import create_engine, text
engine = create_engine('postgresql+psycopg://user:pass@host:5432/dbname')
# sqlite:///local.db
# mysql+pymysql://user:pass@host/dbname
df = pd.read_sql("SELECT * FROM orders WHERE created_at >= '2026-01-01'", engine)
Never put credentials in your code
# Wrong. This ends up in git, and then in a public repository.
engine = create_engine('postgresql://admin:hunter2@prod-db/main')
# Right.
import os
engine = create_engine(os.environ['DATABASE_URL'])
Put the value in a .env file, add .env to .gitignore, and load it:
from dotenv import load_dotenv
load_dotenv()
engine = create_engine(os.environ['DATABASE_URL'])
This is not paranoia. Credential leaks through version control are among the most common security incidents there are, and the fix costs three lines.
Parameterise every query
Never build SQL with string formatting.
# Catastrophically wrong
city = user_input
df = pd.read_sql(f"SELECT * FROM customers WHERE city = '{city}'", engine)
If city contains '; DROP TABLE customers; -- you have just deleted the table. This is SQL injection, it is the oldest vulnerability in the book, and f-strings make it easy to write by accident.
# Right — the driver sends the value separately from the query
df = pd.read_sql(
text("SELECT * FROM customers WHERE city = :city"),
engine,
params={'city': city},
)
Parameterised queries are also faster, because the database can reuse the query plan.
Let the database do the work
The most common beginner mistake is pulling everything and filtering in pandas.
# Wrong: transfers ten million rows to compute twelve numbers
df = pd.read_sql("SELECT * FROM orders", engine)
result = df.groupby('region').revenue.sum()
# Right: the database aggregates, twelve rows cross the network
result = pd.read_sql("""
SELECT region, SUM(revenue) AS revenue
FROM orders
WHERE created_at >= :since
GROUP BY region
""", engine, params={'since': '2026-01-01'})
Databases are extremely good at filtering, joining and aggregating. That is what they are for. Move as much of the work as you can across the wire, and bring back the smallest result that answers your question.
Put it this way: you would not empty the entire warehouse onto the pavement to find one box. Ask the warehouse for the box.
flowchart LR
subgraph BAD["Pull then filter"]
B1[("10M rows")] -->|"network"| B2["pandas filters<br/>and aggregates"] --> B3["12 rows"]
end
subgraph GOOD["Filter then pull"]
G1[("10M rows")] --> G2["database filters<br/>and aggregates"] -->|"network"| G3["12 rows"]
end
style BAD fill:#f7e8e8,stroke:#c02020
style GOOD fill:#eef1fc,stroke:#2141c8
Reading a large result in chunks
When you genuinely need many rows:
for chunk in pd.read_sql(query, engine, chunksize=50_000):
process(chunk)
Writing back
df.to_sql('results', engine, if_exists='replace', index=False, chunksize=10_000)
if_exists takes 'fail', 'replace' or 'append'. Be careful: 'replace' drops the table and recreates it, which loses indexes, constraints and permissions. In anything shared, create the table properly in SQL and use 'append'.
SQLite for local work
No server, no credentials, one file. Excellent for intermediate results.
engine = create_engine('sqlite:///analysis.db')
df.to_sql('cleaned', engine, if_exists='replace', index=False)
APIs
An API call is an HTTP request that returns JSON. The library is httpx or requests; the examples below use httpx.
import httpx
r = httpx.get('https://api.example.com/orders',
params={'since': '2026-01-01', 'limit': 100},
headers={'Authorization': f'Bearer {token}'},
timeout=30.0)
r.raise_for_status() # turn a 4xx/5xx into an exception
data = r.json()
Two habits from that snippet. Always set a timeout, because the default in some libraries is to wait forever, and a hung request in a loop is indistinguishable from a crashed script. Always call raise_for_status(), or you will happily parse an error page as if it were data.
Pagination
APIs return results in pages. There are two common styles.
# Offset style
def fetch_all(url, token, page_size=100):
rows, offset = [], 0
while True:
r = httpx.get(url, params={'limit': page_size, 'offset': offset},
headers={'Authorization': f'Bearer {token}'}, timeout=30)
r.raise_for_status()
batch = r.json()['results']
if not batch:
break
rows.extend(batch)
offset += page_size
return rows
# Cursor style — follow the link the API gives you
def fetch_all_cursor(url, token):
rows = []
while url:
r = httpx.get(url, headers={'Authorization': f'Bearer {token}'}, timeout=30)
r.raise_for_status()
payload = r.json()
rows.extend(payload['results'])
url = payload.get('next') # None when finished
return rows
Always have a stopping condition that does not depend on the API behaving. A while True loop against a misbehaving endpoint will run until you notice.
Rate limits and retries
Servers push back. A polite client backs off rather than hammering.
import time, httpx
def get_with_retry(url, *, headers=None, params=None, attempts=5):
for attempt in range(attempts):
r = httpx.get(url, headers=headers, params=params, timeout=30)
if r.status_code == 429: # too many requests
wait = int(r.headers.get('Retry-After', 2 ** attempt))
time.sleep(wait)
continue
if 500 <= r.status_code < 600: # server problem
time.sleep(2 ** attempt) # 1, 2, 4, 8 seconds
continue
r.raise_for_status()
return r
raise RuntimeError(f"giving up on {url} after {attempts} attempts")
Exponential backoff, doubling the wait each time, is the standard approach. Retry on 429 and 5xx. Do not retry on 400 or 404, because those mean your request is wrong and repeating it will not help.
Honour Retry-After when the server sends it. It is the server telling you exactly how long to wait.
Cache while you develop
During development you will run the same fetch many times. Caching responses to disk makes iteration fast and keeps you off the rate limit.
import json, hashlib
from pathlib import Path
CACHE = Path('.cache'); CACHE.mkdir(exist_ok=True)
def cached_get(url, **kwargs):
key = hashlib.sha256((url + json.dumps(kwargs, sort_keys=True)).encode()).hexdigest()
path = CACHE / f"{key}.json"
if path.exists():
return json.loads(path.read_text())
data = get_with_retry(url, **kwargs).json()
path.write_text(json.dumps(data))
return data
Add .cache/ to .gitignore. Delete the directory when you want fresh data.
From JSON to a DataFrame
rows = fetch_all(url, token)
df = pd.json_normalize(rows) # a list of dicts flattens directly
A list of dictionaries with consistent keys is exactly what DataFrame and json_normalize expect, which is why collecting API results into a list is the natural pattern.
Being a Good Client
Four things that cost nothing and keep you welcome.
- Identify yourself. Set a
User-Agentheader with your name or project and a contact address. - Request only what you need. Filter server-side with query parameters.
- Sleep between calls even when you are under the limit. A short pause costs you little and reduces load meaningfully.
- Read the terms. Some APIs prohibit storing results, or require attribution.
What Carries Forward
Credentials come from the environment, never from the code.
Parameterise every query. String-formatted SQL is a security hole, not a style preference.
Push filtering and aggregation into the database. Bring back answers, not tables.
Set a timeout, call raise_for_status, and back off exponentially. Those three turn a fragile script into a reliable one.
Next: cleaning and reshaping, where the data is finally in memory and turns out to be a mess.