Lecture 19 - Working with APIs in Practice
?)GET asks for data. POST sends data200 success, 404 not found, 429 retry later, 500 server error200 for errors too, so status alone doesn’t confirm data receiptrequests.get(url, params=...) → .raise_for_status() → .json(), where params auto-generates the query stringdata/Today picks up where that stopped
get_wdi(), the pull script your project needs1. Authentication and keys
.env, .gitignore, and revoking a leaked key2. Pagination and rate limits
3. From JSON to DataFrame
pd.json_normalize on flat and nested recordsrecord_path and meta when dots are not enough4. Functions, caching, and files
get_wdi()NASA’s signup form: https://api.nasa.gov
Simple, and some APIs offer nothing else
Headers win because URLs get logged. They land in server logs, browser history, proxy caches, and the Referer header sent to the next site. A key in a URL can be used to identify a user, a project, or a specific request
You already ran this routine in Lecture 14, when you talked to a language model
.env, one NAME=value per line, no quotes, no spaces around the =NASA_API_KEY=abc123def456
.env to your .gitignore before your first commitpython-dotenv.env.example with the names and no values, so collaborators know what to createNever hard-code a key in a notebook or script, and never paste one into a slide, a screenshot, or a chat message
Committed a key by accident? Revoke it immediately, then reissue. Deleting the file in the next commit does not help, because git log -p still shows it
.gitignore first, .env.example committed empty, git diff --cached before every commitSource: GitHub Security Blog, The next evolution of GitHub Advanced Security (1 April 2025). Push protection: docs.github.com
Ask for one day’s picture, save the answer, then read the quota headers
import os, json, requests
from dotenv import load_dotenv
load_dotenv()
key = os.getenv("NASA_API_KEY", "DEMO_KEY")
r = requests.get(
"https://api.nasa.gov/planetary/apod",
params={"api_key": key, "date": "2026-08-05"},
timeout=10,
)
r.raise_for_status()
with open("data/nasa_apod.json", "w") as f:
json.dump(r.json(), f, indent=2)
# How much quota is left
print(r.headers["X-RateLimit-Limit"],
r.headers["X-RateLimit-Remaining"])os.getenv(name, default) falls back to DEMO_KEY, so the code runs before anyone has a keyDEMO_KEY 30 requests per IP address per hour and 50 per dayKeyError: 'X-RateLimit-Limit' means this API does not send the header. Use r.headers.get(...) when you are not sure
Read the snapshot and print the two fields we need
apod["url"] also gives the image addressRead the metadata element of the saved first page
total records exist, split into pages pages of per_page records. This response is page pagelastupdated gives the date the World Bank last revised these numbers| Field | Meaning | Why it matters |
|---|---|---|
page |
Which page this is | Your position in the loop |
pages |
How many pages exist | When to stop |
per_page |
Records per page | You can often raise this |
total |
Records in total | Sanity check at the end |
per_page=500 needs one page at per_page=20000per_page=32767. Ask for 32768 and the server answers 400, so 20000 is a safe value for one-page requestsCollect GDP per capita for 2023, 100 rows at a time
import requests, time
url = ("https://api.worldbank.org/v2/country/all"
"/indicator/NY.GDP.PCAP.KD")
records, page = [], 1
while True:
params = {"format": "json", "date": 2023,
"per_page": 100, "page": page}
r = requests.get(url, params=params, timeout=30)
r.raise_for_status()
meta, rows = r.json()
records.extend(rows)
print(f"page {meta['page']} of {meta['pages']}: "
f"{len(rows)} rows")
if page >= meta["pages"]:
break
page += 1
time.sleep(0.5) # be polite
print(f"collected {len(records)} records")pages from the response instead of a hard-coded number, so it stays correct when countries are addedwhile True with a break, because pages does not exist until the first response arrivestotal of 265time.sleep(0.5) adds one and a half seconds in totalOpen page 1, page 2, page 3 from disk instead of the network
import json
records, page = [], 1
while True:
path = f"data/wb_gdppc_2023_page{page}.json"
with open(path) as f:
meta, rows = json.load(f)
records.extend(rows)
print(f"page {meta['page']} of {meta['pages']}: "
f"{len(rows)} rows")
if page >= meta["pages"]:
break
page += 1
print(f"\ncollected {len(records)} records "
f"(metadata said {meta['total']})")total is a cheap check that catches a dropped pageLink header. Follow it until rel="next" disappearsnext_cursor or next_page_token in the body, and you send it in the next requestGitHub’s Link header, from a real request
after= value is an opaque cursor, a bookmark that means something to GitHub and nothing to youtime.sleep(0.5) is invisible to you and helpful to the serverA 429 often includes a Retry-After header with the waiting time
.get("Retry-After", 60) gives a fallback when the server sends no headerSP.POP.TOTL) for all countries and all available yearsper_page=500. After the July 2026 update that gives 35 pages with 17,490 records35 in your loop defeats the exercisepages from the metadata block and stop when you reach ittime.sleep(0.5) inside the loop before you run ittotal field in the metadataWhat to look for
total exactly, with no page missed or collected twiceStuck, or want to compare your loop with mine?
Print the first record of the saved life expectancy file
payload[1]. payload[0] is the metadata block from last classindicator and country arrive as dictionaries nested inside each recordpd.json_normalize: the flat casePass three flat dictionaries and read the table back
pd.DataFrame(flat) returns the same frame here, because there is nothing to flattenpd.DataFrame keeps the dict in the cell. json_normalize expands it into columnsNaN in both, so uneven records do not raise an errorpd.json_normalize: the nested caseRun it on the World Bank records from the previous slide
country.value, indicator.idparent.child, two levels give parent.child.grandchilddf.country.value means something else to Pythonrecord_path and metaEach item holds a list of observations, so name that list
payload_nested = [
{"country": "Brazil", "iso3": "BRA",
"observations": [{"year": 2022, "value": 76.0},
{"year": 2023, "value": 76.4}]},
{"country": "Japan", "iso3": "JPN",
"observations": [{"year": 2022, "value": 84.0},
{"year": 2023, "value": 84.1}]},
]
out = pd.json_normalize(
payload_nested,
record_path="observations", # the list that becomes rows
meta=["country", "iso3"], # carried down onto each row
)
print(out.to_string(index=False))record_path names the list to explode into rowsmeta names the parent fields to repeat on every row from that parentrecord_path you get two rows and a column of listsBuild the four columns we need, with the correct dtypes
country.value is the API’s vocabularyyear and value arrive as strings (str), and a mean over strings fails or gives a wrong answererrors="coerce" turns unreadable values into NaN instead of raising an errorThe project starter’s tidy() does the same flattening with a list comprehension and Polars. json_normalize is the pandas shortcut for this step. The project asks for Polars or DuckDB in the main work
Count the frame, rank 2023, then measure the change since 2000
print(f"{len(tidy)} rows, "
f"{tidy['iso3'].nunique()} countries, "
f"years {tidy['year'].min()}-{tidy['year'].max()}")
print()
# Life expectancy in 2023, highest first
latest = tidy[tidy["year"] == 2023].sort_values(
"value", ascending=False)
print(latest[["country", "value"]].to_string(index=False))
print()
print("Change since 2000:")
wide = tidy.pivot(index="iso3", columns="year",
values="value")
change = (wide.assign(change=lambda d: d[2023] - d[2000])
["change"].round(1)
.sort_values(ascending=False))
print(change.to_string())pivot turns years into columns so two of them can be subtracteddata/wb_life_expectancy_5.json, which holds life expectancy for Brazil, the United States, India, Nigeria, and Japan, 2000 to 2024json_normalize and build a tidy frame with columns country, iso3, year, valuevalue numeric before you compute anything with itWhat to look for
value column with dtype float64, because a mean over strings fails or gives a wrong answerStuck, or want to compare your frame with mine?
get_wdiWrite the signature and the docstring first, with no body
def get_wdi(indicator, countries="all",
start=1990, end=2023):
"""Fetch one World Development Indicator
as a tidy DataFrame.
Parameters
----------
indicator : str WDI code, e.g. "NY.GDP.PCAP.KD"
countries : str "all", or ISO3 codes joined
by ";", e.g. "BRA;USA"
start, end : int First and last year, inclusive
Returns
-------
DataFrame with columns country, iso3, year,
value (one row per country-year)
"""get_wdi, the bodyimport time
import pandas as pd
import requests
def get_wdi(indicator, countries="all", start=1990, end=2023):
"""(docstring as on the previous slide)"""
url = ("https://api.worldbank.org/v2/country/"
f"{countries}/indicator/{indicator}")
params = {"format": "json", "date": f"{start}:{end}",
"per_page": 20000, "page": 1}
records = []
while True:
r = requests.get(url, params=params, timeout=60)
r.raise_for_status()
payload = r.json()
# An error comes back as a dict, not a two-element list
if isinstance(payload, dict) or payload[1] is None:
raise ValueError(f"No data for {indicator}. "
"Has the code been retired?")
meta, rows = payload
records.extend(rows)
if params["page"] >= meta["pages"]:
break
params["page"] += 1
time.sleep(0.5)
# the frame is built on the next slideper_page=20000 usually fits the whole request into one page. The loop still runs when it does not400payload[1] is None catches a valid two-element reply whose record list is emptyisinstance(payload, dict) catches the other error shape the API sendsrecords collects raw dictionaries across pages and stays unchanged until the loop endstimeout=60 is generous, because a 20,000-row page takes a few seconds to buildget_wdi, the body (continued)The last four lines of the function
Call it like any other function
pages, per_page, or a dotted column nameAsk for a code that was removed and read the response
raise_for_status() sees a 200 and raises nothingisinstance(payload, dict) line turns that into one clear error messageEN.GHG.CO2.PC.CE.AR5 replaced it, and this course uses that codeIndicator codes are retired without warning. Read the body of the response on every pull, and treat the status code as half the answer
Check the file before you call the server
from pathlib import Path
def get_wdi_cached(indicator, countries="all",
start=1990, end=2023):
"""get_wdi with a local cache."""
cache = (Path("data") /
f"{indicator}_{countries}_{start}_{end}.parquet")
if cache.exists():
print(f"reading {cache.name} from disk")
return pd.read_parquet(cache)
print(f"fetching {indicator} from the World Bank")
df = get_wdi(indicator, countries, start, end)
cache.parent.mkdir(exist_ok=True)
df.to_parquet(cache, index=False)
return dfEight lines, four benefits
scripts/pull_data.py in the project starter does the same, and saves the raw JSON next to the tidy tabledata/, and read back at render time| CSV | Parquet | |
|---|---|---|
| Format | Text, row-oriented | Binary, columnar, compressed |
| Dtypes | Loses them: int16 → int64, categories → strings |
Keeps exactly what you saved |
| Column selection | Reads all columns, even if you need three | Reads only the columns you ask for |
| Size | Large (numbers stored as digit characters) | Typically 3-6x smaller |
| Ecosystem | Any tool, including spreadsheets | Needs pyarrow; pandas, Polars, DuckDB, R, Spark all read it |
| Human-readable | Open in any text editor | Needs code to read |
The format’s home page: https://parquet.apache.org
pyarrow, so parquet is optional therepyarrow to requirements.txtRead the course panel, then write it again as CSV and compare
import pandas as pd
from pathlib import Path
panel = pd.read_parquet("data/wdi_panel.parquet")
print(f"{len(panel):,} rows, {panel['iso3'].nunique()} countries, "
f"{panel['indicator'].nunique()} indicators, "
f"{panel['year'].min()}-{panel['year'].max()}")
print()
print(panel.head(4).to_string(index=False))
# Write the same data as CSV and compare
panel.to_csv("data/_size_check.csv", index=False)
pq = Path("data/wdi_panel.parquet").stat().st_size
csv = Path("data/_size_check.csv").stat().st_size
Path("data/_size_check.csv").unlink()
print(f"\nparquet: {pq/1024:>7,.0f} KB")
print(f"CSV: {csv/1024:>7,.0f} KB ({csv/pq:.1f}x larger)")Aruba, ABW, and co2_per_capita thousands of times. Parquet stores each value oncecategory and int16 dtypeswdi_panel.parquet in mind. You will see it againThe panel holds eight indicators for 217 countries, 1990 to 2023, in long format: 59,024 rows in 450 KB
The build script lives in the course repository, caches its raw API responses, and makes no network requests on a second run
requests pulls from a web API, the whole of today’s materialdata/raw/, also today| File | What it does |
|---|---|
scripts/pull_data.py |
Runs once: python scripts/pull_data.py |
data/raw/<name>_raw.json |
The untouched API response |
data/raw/<name>.csv |
The tidy table your report reads |
report.qmd |
Reads only the saved copy, 1,500-2,500 words |
Dockerfile |
Builds the image your report renders in |
requirements.txt |
Pinned versions, so the build is the same next month |
data/raw/. Do not put it in .gitignoreThe starter repository: github.com/danilofreire/datasci350-project-starter
.env, and .env goes into .gitignore before your first commitpages from the metadata and loop until you reach itper_page to 20000 turns 35 World Bank requests into oneRetry-After on a 429json_normalize flattens nested dicts into dotted column namesrecord_path and meta turn a nested list into rows and repeat the parent fieldsget_wdi wraps the whole pull, and its error check catches a 200 with an error message insidetutorials/05-web-scraping-tutorial.qmd) covers that, from HTML tables in pandas to BeautifulSoup, with the ethics and the law of scrapingBefore then
python scripts/pull_data.py onceimport requests, time
url = "https://api.worldbank.org/v2/country/all/indicator/SP.POP.TOTL"
records, page = [], 1
while True:
r = requests.get(url, params={"format": "json", "per_page": 500, "page": page}, timeout=30)
r.raise_for_status()
meta, rows = r.json()
records.extend(rows)
if page == 1:
print(f"{meta['total']:,} records across {meta['pages']} pages")
if page >= meta["pages"]:
break
page += 1
time.sleep(0.5)
print(f"collected {len(records):,} records")
assert len(records) == meta["total"], "collected count does not match the metadata"Three things worth copying from this solution
meta["pages"], so the loop is still correct when the World Bank adds a country or a yearassert at the end is a good habitThat run took 41 seconds: 35 requests, each followed by half a second of sleeping. With per_page=20000 the same data arrives in one call. The loop is the skill to learn, and a bigger page is the practical answer
import json
import pandas as pd
with open("data/wb_life_expectancy_5.json") as f:
payload = json.load(f)
# The records are element 1, not element 0
df = pd.json_normalize(payload[1])
tidy = pd.DataFrame({
"country": df["country.value"],
"iso3": df["countryiso3code"],
"year": df["date"].astype("int16"),
"value": pd.to_numeric(df["value"], errors="coerce"),
})
india = tidy[tidy["iso3"] == "IND"]["value"]
print(f"India, mean life expectancy 2000-2024: "
f"{india.mean():.1f} years")
print(f" from {india.min():.1f} to {india.max():.1f}")
print("\nMean by country:")
print(tidy.groupby("country", observed=True)["value"]
.mean().round(1).sort_values().to_string())payload[1] is the step most people miss on the first tryget_wdi from Appendix 04 into a notebook or a scriptdata/ folderPath(...).stat().st_size gives you bytes, and .isna().sum() counts missing valuesPick one of these
SP.RUR.TOTL.ZS rural population, % of totalSL.UEM.TOTL.ZS unemployment, % of labour forceNY.GDP.MKTP.KD.ZG GDP growth, annual %Want to compare your numbers with mine?
from pathlib import Path
df = get_wdi("SL.UEM.TOTL.ZS", countries="all",
start=2000, end=2023)
Path("data").mkdir(exist_ok=True)
df.to_parquet("data/unemployment.parquet", index=False)
df.to_csv("data/unemployment.csv", index=False)
pq = Path("data/unemployment.parquet").stat().st_size
csv = Path("data/unemployment.csv").stat().st_size
print(f"rows: {len(df):,}")
print(f"missing: {df['value'].isna().sum():,} "
f"({df['value'].isna().mean():.1%})")
print(f"parquet: {pq/1024:,.0f} KB")
print(f"CSV: {csv/1024:,.0f} KB ({csv/pq:.1f}x larger)")per_page=20000 fits it into one pageget_wdiCopy this into your project
import time
from pathlib import Path
import pandas as pd
import requests
def get_wdi(indicator, countries="all", start=1990, end=2023):
"""Fetch one World Development Indicator as a tidy DataFrame.
Parameters
----------
indicator : str WDI code, e.g. "NY.GDP.PCAP.KD"
countries : str "all", or ISO3 codes joined by ";", e.g. "BRA;USA"
start, end : int First and last year, inclusive
Returns
-------
DataFrame with columns country, iso3, year, value
"""
url = f"https://api.worldbank.org/v2/country/{countries}/indicator/{indicator}"
params = {"format": "json", "date": f"{start}:{end}", "per_page": 20000, "page": 1}
records = []
while True:
r = requests.get(url, params=params, timeout=60)
r.raise_for_status()
payload = r.json()
if isinstance(payload, dict) or payload[1] is None:
raise ValueError(f"No data for {indicator}. Has the code been retired?")
meta, rows = payload
records.extend(rows)
if params["page"] >= meta["pages"]:
break
params["page"] += 1
time.sleep(0.5)
df = pd.json_normalize(records)
return pd.DataFrame({
"country": df["country.value"],
"iso3": df["countryiso3code"],
"year": df["date"].astype("int16"),
"value": pd.to_numeric(df["value"], errors="coerce"),
})Put this in the same file, below get_wdi
def get_wdi_cached(indicator, countries="all", start=1990, end=2023, cache_dir="data"):
"""Like get_wdi, with the network used once per set of arguments."""
cache = Path(cache_dir) / f"{indicator}_{countries}_{start}_{end}.parquet"
if cache.exists():
return pd.read_parquet(cache)
df = get_wdi(indicator, countries, start, end)
cache.parent.mkdir(exist_ok=True)
df.to_parquet(cache, index=False)
return dfThe cache filename includes every argument, so a new year range fetches a new file instead of returning the old one. Delete the file when you want fresh numbers
data/wdi_panel.parquet is the file used in the size comparison, and again in Lectures 21 and 22
It holds eight indicators for every country (regional and income aggregates such as “World” and “Euro area” are dropped) from 1990 to 2023, in long format
| Column | Type | Meaning |
|---|---|---|
country |
category | Country name |
iso3 |
category | Three-letter country code |
indicator |
category | Short name, one of the eight below |
year |
int16 | 1990 to 2023 |
value |
float64 | The observation, NaN where missing |
| Short name | WDI code |
|---|---|
gdp_per_capita |
NY.GDP.PCAP.KD |
population |
SP.POP.TOTL |
life_expectancy |
SP.DYN.LE00.IN |
co2_per_capita |
EN.GHG.CO2.PC.CE.AR5 |
internet_users_pct |
IT.NET.USER.ZS |
urban_pop_pct |
SP.URB.TOTL.IN.ZS |
fertility_rate |
SP.DYN.TFRT.IN |
primary_enrolment_net |
SE.PRM.NENR |
The script that produced it is data/build_wdi_panel.py. Run python build_wdi_panel.py from that folder to rebuild the file from scratch. It caches raw API responses in data/raw/, so a second run makes no network requests
This is a small, complete example of the collection script your project needs