Learning Goals
3 min- Spot the four parts of a REST URL: scheme, host, path, query.
- Pass query parameters with the
params=kwarg (safer than f-string concatenation). - Read response headers — Content-Type, X-RateLimit, Cache-Control.
- Decode status codes into the right reaction.
Warm-Up · Read This URL
5 minhttps://api.github.com/repos/python/cpython/issues?state=open&per_page=5 └──┬──┘ └────┬──────┘└────────────┬───────┘└─────────────┬─────────┘ scheme host path query
Four parts. Most APIs use the same shape:
- Path parameters identify which resource —
/repos/python/cpython/issues. - Query parameters modify the request —
state=open,per_page=5.
The path picks the resource; the query refines it. Build the URL by combining the two and let requests assemble them safely.
New Concept · params, headers, status
14 minQuery parameters with params=
NEVER build the URL with string concatenation. Pass a dict to params=:
import requests r = requests.get( "https://api.github.com/repos/python/cpython/issues", params={"state": "open", "per_page": 5}, timeout=10, ) print(r.url) # → https://api.github.com/.../issues?state=open&per_page=5 issues = r.json() print(f"got {len(issues)} issues") for it in issues: print(f" #{it['number']:>5} {it['title']}")
The dict is URL-encoded for you — spaces, special characters, all safe.
Path parameters with f-strings
When part of the URL identifies the resource (a username, a country code), use an f-string:
username = "torvalds" r = requests.get(f"https://api.github.com/users/{username}", timeout=5) print(r.json()["public_repos"])
Useful response headers
print(r.headers["Content-Type"]) # → application/json; charset=utf-8 print(r.headers.get("X-RateLimit-Remaining")) # → "59" print(r.headers.get("Date")) # → "Thu, 28 May 2026 06:33:14 GMT"
Headers tell you the response's shape (Content-Type), the API's rules (X-RateLimit-*), and metadata (Date). Use .get() for headers that may be missing.
Status code → action
200 OK → use the body 201 Created → POST succeeded 204 No Content → success, no body 301 Moved → follow the new URL 400 Bad Request → your inputs were wrong 401 Unauthorized → missing or bad token 403 Forbidden → token has no rights here 404 Not Found → wrong URL 429 Too Many → slow down (rate limit) 500 Server Error → not your fault, maybe retry 503 Unavailable → temporary outage
The simplest rule: r.raise_for_status() turns any 4xx or 5xx into an exception. Catch it and choose what to do.
Worked Example · GitHub Stars Tracker
12 min# stars.py — list a user's top 5 repos by stars import requests USERNAME = "fastapi" # try any GitHub user/org r = requests.get( f"https://api.github.com/users/{USERNAME}/repos", params={"sort": "stars", "direction": "desc", "per_page": 5}, headers={"Accept": "application/vnd.github+json"}, timeout=10, ) r.raise_for_status() repos = r.json() remaining = r.headers.get("X-RateLimit-Remaining", "?") print(f"⭐ Top repos for {USERNAME} ({remaining} req remaining this hour)") print("-" * 50) for repo in repos: print(f" {repo['stargazers_count']:>7} {repo['name']}")
Sample output
⭐ Top repos for fastapi (54 req remaining this hour)
--------------------------------------------------
78934 fastapi
5012 typer
3287 sqlmodel
902 asyncer
431 full-stack-fastapi-templateRead the diff
Three deliberate choices: params= instead of f-string building, headers=Accept to tell GitHub which API version we expect, and reading X-RateLimit-Remaining to know how many more calls we can make this hour. That last one matters — burning your rate budget mid-loop is a classic junior mistake.
Try It Yourself
13 minCall https://restcountries.com/v3.1/name/{name} for a country of your choice. Print capital, region, population.
Hint
import requests r = requests.get("https://restcountries.com/v3.1/name/malaysia", timeout=5) r.raise_for_status() c = r.json()[0] print("Capital :", c["capital"][0]) print("Region :", c["region"]) print("Population:", c["population"])
Use https://api.frankfurter.app/latest?from=MYR&to=USD,SGD,EUR to get exchange rates. Convert RM 100 to all three currencies.
Hint
import requests r = requests.get("https://api.frankfurter.app/latest", params={"from": "MYR", "to": "USD,SGD,EUR"}, timeout=5) rates = r.json()["rates"] amount = 100 for ccy, rate in rates.items(): print(f" RM {amount} → {ccy} {amount*rate:.2f}")
The GitHub repos endpoint returns 30 per page. Loop with page=1, 2, 3, ... until you get an empty list. Print the total count.
Hint
import requests, time all_repos = [] for page in range(1, 100): r = requests.get( "https://api.github.com/users/fastapi/repos", params={"per_page": 30, "page": page}, timeout=10, ) r.raise_for_status() batch = r.json() if not batch: break all_repos.extend(batch) time.sleep(0.5) print(f"total: {len(all_repos)} repos")
The cap at 100 pages is a safety net so you don't loop forever if the API misbehaves.
Mini-Challenge · Hacker News Top 10
8 minHacker News exposes its data at https://hacker-news.firebaseio.com/v0/topstories.json (returns a list of IDs) and https://hacker-news.firebaseio.com/v0/item/{id}.json (returns one story). Print the top 10 stories — title and points.
Show one possible solution
# hn_top10.py import requests, time BASE = "https://hacker-news.firebaseio.com/v0" ids = requests.get(f"{BASE}/topstories.json", timeout=5).json()[:10] print(f"📰 Hacker News — top {len(ids)}\n") for i, sid in enumerate(ids, 1): s = requests.get(f"{BASE}/item/{sid}.json", timeout=5).json() print(f" {i:>2}. [{s.get('score', '?'):>4}] {s.get('title')}") time.sleep(0.1)
Non-negotiables: fetch IDs first, then fetch each story, use .get() for fields that may be missing (some HN items have no title), be polite with sleeps.
Recap
3 minPath parameters identify the resource; query parameters refine it. Use params= to add queries safely. Headers carry metadata you can read (Content-Type, X-RateLimit). Status codes tell you what to do — raise_for_status() is the safety belt. Tomorrow we send data with POST.
Vocabulary Card
- path parameter
- Part of the URL that names the resource. Built with f-strings.
- query parameter
- Key-value pairs after
?. Built with theparams=dict. - X-RateLimit-Remaining
- How many more calls you can make in the current window. Check it.
- pagination
- Walking page=1, 2, 3... to fetch the full list when responses are capped.
Homework
4 minPick a topic you care about (movies, games, footballers, K-pop). Find a free no-key API for it (TVMaze, Frankfurter, OpenLibrary, Pokémon, etc.). Build topic_browser.py:
- Accept a search term from the user.
- Call the API with that term as a query parameter.
- Pretty-print the top 5 results with the most relevant fields.
- Show how many requests you have left if the API exposes a rate-limit header.
Sample · topic_browser.py (TVMaze)
# topic_browser.py — search TV shows on TVMaze import requests q = input("show name: ").strip() r = requests.get( "https://api.tvmaze.com/search/shows", params={"q": q}, timeout=10, ) r.raise_for_status() shows = r.json()[:5] print(f"\n📺 top {len(shows)} matches for {q!r}") for s in shows: show = s["show"] print(f" · {show['name']} ({show.get('premiered', '?')[:4]})") print(f" rating: {show.get('rating', {}).get('average', '?')}") if show.get("genres"): print(f" genres: {', '.join(show['genres'])}")
Non-negotiables: user input → query param, 5 results, defensive .get() for missing fields.