Lecture 19 - Working with APIs in Practice
2xx status code means it worked, 4xx a bad request, and 5xx a server failure{ } and arrays [ ]current → temperature_2mpython3 -m json.tool makes the replies easier to readcurl -oToday we do this in Python
1. Reading JSON in Python
2. The requests library
requests.getparams3. From JSON to a DataFrame
4. Your project’s pull script
pull_data.pydict) holds a JSON object in Python: {"key": value}course["code"][a, b]years[0]No file from last class? Save wb_gdp_bra.json in a data/ folder next to your notebook, or run this there:
open(...) finds the file and prepares it for readingas f names the open file fwith because an open file may not save everything you writejson.load(f) turns the JSON text into Python objectsjson.load and json.loadsimport json
# The JSON is in a file
with open("data/wb_gdp_bra.json") as f:
wb = json.load(f)
# The JSON is already in a string
text = """
{
"city": "Atlanta",
"temperature": 29.6,
"raining": false
}
"""
data = json.loads(text)
print(type(text))
print(type(data))
print(data["city"], data["raining"])json.load(f) when the JSON is in a file on the diskjson.loads(text) when it is already in your program as a string. The s stands for “string”json.loads('{"city": "Atlanta"}') also works, with no variabler.json() does the same for an API replywb[0] is the metadata, and wb[1] is the list of recordsmeta and records, makes the code easier to readrecords[0] is the newest year, 2025first["country"]["value"] follows the path country → valuewb[1][0]["country"]["value"] is the same path in one goKeyError means the dictionary has no such keyIndexError means the list has no such positionTypeError here means you used a name on a list, which only takes positionsJSON has no .head(), so look at one level at a time
type() tells you if you have a list or a dictionarylen() tells you how many positions a list has.keys() lists the names in a dictionarywb_pop_ury.json into a data/ folderOr replace ??? with Uruguay’s country code (what is it? 😉) and SP.POP.TOTL, then run this:
with open(...) as f: and json.load(f)total in the metadata (position 0)date and value. Records run newest firstcountry and then valueYou should get 12 records, a population of 3,398,968 in 2020, and the name Uruguay
requests libraryrequests.getrequests.get returns a response, which we call rr.status_code is the status coder.text is the reply as plain text. [:40] shows the first 40 charactersr.json() turns that text into a dictionary, like json.loadstimeout=30 gives up after 30 seconds. Without it, a silent server can make your script wait foreverrequests once with pip install requestsrequests documentationrequests build the URLparamsurl holds only the path. No ? and no ¶ms becomes one option in the query stringrequests adds the ? and the & for you: became %3Ar.url shows the full URL that was sent. Paste it into your browser to check itparamsraise_for_status() stops the script on a bad statusHere the path has a typo, countryy instead of country
r.raise_for_status() checks the status code4xx or 5xx, it raises an error, so Python stops and prints a message2xx, it does nothing and the script carries onrequests.get200Here we ask for XYZ, a country code that does not exist
200, so raise_for_status() lets it passif len(payload) < 2: catches it200import json, requests
url = ("https://api.worldbank.org/v2/"
"country/BRA/indicator/NY.GDP.PCAP.KD")
params = {"format": "json", "date": "2014:2025"}
r = requests.get(url, params=params, timeout=30)
r.raise_for_status()
payload = r.json()
if len(payload) < 2 or payload[1] is None:
print("No data:", payload)
else:
with open("data/wb_gdp_bra.json", "w") as f:
json.dump(payload, f, indent=2)
print("Saved", payload[0]["total"], "records")curl -oopen(..., "w") opens the file for writing. It creates the file, or replaces itjson.dump does the reverse of json.load, writing Python objects as JSON textindent=2 makes the file readable, like json.toolpayload[1] is None catches an empty reply, such as [{...}, null] for a year with no dataelse: block runs only when the check passes, so a bad reply is never savedURY, indicator SP.POP.TOTL, file data/wb_pop_ury.jsonr.url, and paste it into your browserXYZ. What does your script print?You should get a URL ending in ?format=json&date=2014%3A2025 and 12 records. With XYZ, you should get the “No data” message and no new file
# Reload Brazil, because Exercise 01 replaced wb
with open("data/wb_gdp_bra.json") as f:
wb = json.load(f)
records = wb[1] # the list of 12 records
rows = []
for record in records:
row = { # build a new, small dictionary
"country": record["country"]["value"],
"year": int(record["date"]),
"value": record["value"],
}
rows.append(row)
print(len(rows))
print(rows[0])rows = [] starts an empty listfor record in records: runs the indented lines once per recordrows.append(row) adds it to the end of the listint(record["date"]) turns the year from text, "2025", into a number, 2025pd.DataFrame(rows) makes one row per dictionarycountry, year and value, become the column namessort_values("year") puts the oldest year firstreset_index(drop=True) renumbers the rows from 0 after sortingdf.shape gives the rows and columns, here 12 and 3pd.json_normalize shortcutjson_normalize keeps all the fields, ten columns herecountry → value becomes country.valuedate is still text here. The loop turned it into a numberjson_normalize documentationurl = ("https://api.worldbank.org/v2/country/"
"BRA;IND;JPN;NGA;USA/indicator/SP.DYN.LE00.IN")
params = {"format": "json", "date": "2000:2024",
"per_page": 1000}
r = requests.get(url, params=params, timeout=30)
r.raise_for_status()
payload = r.json() # [metadata, records]
records = payload[1] # just the records
rows = []
for record in records:
rows.append({
"country": record["country"]["value"],
"year": int(record["date"]),
"value": record["value"],
})
df = pd.DataFrame(rows)
print(df.shape)
print(df["country"].value_counts())BRA;IND;JPN;NGA;USA asks for five countries at onceSP.DYN.LE00.IN is life expectancy at birthper_page=1000 gets them all in one replyurl = ("https://api.worldbank.org/v2/country/"
"BRA;NGA/indicator/SP.DYN.LE00.IN")
params = {"format": "json", "date": "2022:2025"}
r = requests.get(url, params=params, timeout=30)
r.raise_for_status()
payload = r.json()
records = payload[1]
rows = []
for record in records:
rows.append({
"country": record["country"]["value"],
"year": int(record["date"]),
"value": record["value"],
})
recent = pd.DataFrame(rows)
print(recent)
recent = recent.dropna()
print(len(recent))"value": nullnull becomes Python None, and pandas shows it as NaN (“not a number”)dropna() removes the rows with missing valuesThe CSV starts like this:
df[df["year"] == 2024] keeps only the rows for 2024ascending=False puts the highest value firstto_csv writes the table to a file your report can readindex=False leaves out those row labels, which are not dataNY.GDP.PCAP.KD) for ARG;BRA;URY, 2014 to 2025country, year and valuedf["value"].isna().sum()data/gdp_south_america.csvYou should get 36 rows (3 countries × 12 years) and no missing values. Uruguay comes first, then Argentina, then Brazil
pull_data.py, part 1: the settings# The World Bank indicator code. This one is
# life expectancy at birth.
INDICATOR = "SP.DYN.LE00.IN"
# The countries you want, as ISO three-letter
# codes, joined by ";".
COUNTRIES = "BRA;IND;NGA;USA"
# The years to cover, written as "first:last".
YEARS = "2000:2023"
# A short, readable name for the output files.
# If you change it, change the file name in
# report.qmd as well.
OUTPUT_NAME = "life_expectancy"scripts/COUNTRIES uses the ; you saw todaypull_data.py, part 2: the requestdef fetch_indicator(indicator, countries, years):
url = (f"{BASE_URL}/country/{countries}"
f"/indicator/{indicator}")
params = {
"format": "json",
"date": years,
"per_page": 20000,
}
response = requests.get(url, params=params, timeout=60)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list) or len(payload) < 2:
raise RuntimeError(
f"Unexpected response from the API: {payload}")
return payloadComments, type hints and a print line removed, and long lines split, to fit the slide. The file itself explains every line
def creates a function: a named block of code you can run laterreturn payload hands the reply back to whoever called the functionf"..." string fills in the values inside { }. BASE_URL is set near the top of the file: https://api.worldbank.org/v2per_page=20000 fits everything on one pageraise RuntimeError(...) stops the script with a message, like raise_for_status()pull_data.py, part 3: the tableThe starter’s version
Today’s version
for inside the bracketsdropna(subset=["value"]) removes rows with no valuepull_data.py, part 4: running itRun it once, from the top folder of your repository:
The output, with the folder paths shortened and the table cut to three rows:
Requesting https://api.worldbank.org/v2/country/
BRA;IND;NGA;USA/indicator/SP.DYN.LE00.IN
Saved the raw response to
data/raw/life_expectancy_raw.json
Saved 96 tidy rows to
data/raw/life_expectancy.csv
First few rows:
country_code country indicator year value
0 BRA Brazil SP.DYN.LE00.IN 2000 69.584
1 BRA Brazil SP.DYN.LE00.IN 2001 69.980
2 BRA Brazil SP.DYN.LE00.IN 2002 70.396
Now commit both files in data/raw/
to your repository.if __name__ == "__main__":, run the script only when you call it directly.env file, and put .env in .gitignoreper_page=20000. One request is simpler than a loop| File | What it does |
|---|---|
scripts/pull_data.py |
Run it once with python scripts/pull_data.py |
data/raw/<name>_raw.json |
The untouched API reply |
data/raw/<name>.csv |
The tidy table your report reads |
report.qmd |
Reads only the saved copy, 1,500 to 2,500 words |
Dockerfile |
Builds the image your report renders in |
requirements.txt |
Pinned versions, so the build stays the same in the future |
data/raw/. Do not put it in .gitignoreThe starter repository: github.com/danilofreire/datasci350-project-starter
wdi_panel.parquet with the same stepsThe indicators include GDP per capita, population, life expectancy and CO2 emissions per person
The full list and the script are in the appendix
with open(...) as f: opens a file and closes it for youjson.load reads JSON, and json.dump writes itwb[1][0]["value"]type(), len() and .keys() show the shape of a replyrequests.get(url, params=..., timeout=30) sends the requestraise_for_status() and a check on the reply catch the errorsfor loop keeps the fields you need from each recordpd.DataFrame(rows) turns the list into a table; asks for many countries, and per_page gets them in one replynull becomes NaN, and dropna() removes itto_csv(..., index=False) saves the table for your reportpull_data.py does all of this, and you can now read every lineBefore then
python scripts/pull_data.py once.keys() shows the names you can use at each levelwb[0] is the metadata, and total is one of its keys5 (2025 is at 0)1 → 5 → value becomes wb[1][5]["value"]wb[1][0] works as well as any otherimport json, requests
url = ("https://api.worldbank.org/v2/"
"country/URY/indicator/SP.POP.TOTL")
params = {"format": "json", "date": "2014:2025"}
r = requests.get(url, params=params, timeout=30)
r.raise_for_status()
payload = r.json()
if len(payload) < 2 or payload[1] is None:
print("No data:", payload)
else:
with open("data/wb_pop_ury.json", "w") as f:
json.dump(payload, f, indent=2)
print(r.url)
print("Saved", payload[0]["total"], "records")URY, SP.POP.TOTL and the file name changed. The params are the same for every indicatorXYZ, the length check prints the “No data” message, and no file is writtendata/ folder does not exist, open fails. Create the folder firstimport requests
import pandas as pd
url = ("https://api.worldbank.org/v2/"
"country/ARG;BRA;URY/indicator/NY.GDP.PCAP.KD")
params = {"format": "json", "date": "2014:2025", "per_page": 1000}
r = requests.get(url, params=params, timeout=30)
r.raise_for_status()
payload = r.json()
rows = []
for record in payload[1]:
rows.append({
"country": record["country"]["value"],
"year": int(record["date"]),
"value": record["value"],
})
df = pd.DataFrame(rows)
print(df.shape)
print(df["value"].isna().sum())
latest = df[df["year"] == 2025]
latest = latest.sort_values("value", ascending=False)
print(latest)
df.to_csv("data/gdp_south_america.csv", index=False).env, one NAME=value per line, with no quotesNASA_API_KEY=abc123def456
.env to your .gitignore before your first commitpython-dotenv, as in Lecture 14import time
import requests
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()
payload = r.json()
records.extend(payload[1])
print("page", page, "of", payload[0]["pages"])
if page >= payload[0]["pages"]:
break
page = page + 1
time.sleep(0.5)
print(len(records), "records")while True: repeats until a breakpages tells the loop when to stoprecords.extend(...) adds a whole page to the listtime.sleep(0.5) waits half a second, to be polite to the server429 status means you are asking too fast. Wait and try againdata/ (wb_gdppc_2023_page1.json to page3)data/wdi_panel.parquet holds eight indicators for every country (regional and income groups such as “World” 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 |
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 is data/build_wdi_panel.py. It saves the raw replies in data/raw/, so a second run makes no network requests