The Story That Explains APIs
An API (Application Programming Interface) is exactly that. Some server, somewhere, has data or the ability to do work. It publishes a "menu" of URLs you can call. You send a request in a standard format. The server does the kitchen work and sends back a plate — usually a JSON document, sometimes XML.
Your Python program is the customer.
requests is the waiter.
The API is the menu. That's the whole idea.
Every modern app talks to APIs — weather, payments, maps, AI models, stock prices, social feeds.
In Python, the standard tool is the requests library. You send HTTP calls in one line,
parse JSON in one line, and you're done. This tutorial takes you from "what is an API"
to "I can build a resilient client for a real service" with diagrams and runnable examples.
An API call is just a URL + a method + optional data. The server answers with a status code + optional data. Everything else — auth, pagination, retries, error handling — is polish around that one exchange.
Visual Diagram — The HTTP Request / Response Cycle
Every API call — no matter how fancy — is one green arrow going right and one orange arrow coming back. The rest is variations on that pattern.
The Three Building Blocks of an API Call
https://api.github.com/users/torvalds
?city=London), JSON body,
form data, headers, cookies, and auth tokens — the specifics of your request.
Your First API Call
# pip install requests
import requests
# Public API — no auth required
r = requests.get("https://api.github.com/users/torvalds")
print(r.status_code) # 200 = success
print(r.headers["Content-Type"]) # application/json; charset=utf-8
data = r.json() # parse JSON → Python dict
print(data["name"])
print(data["public_repos"])
print(data["bio"])
1. It's not in the standard library — always pip install requests.
2. Every call returns a Response object with a
.status_code, .headers, .text, .content,
and .json().
3. Nothing is retried, nothing times out, unless you configure it.
Every serious call needs a timeout=.
The HTTP Methods — What They Mean, When to Use Each
| Method | Purpose | Idempotent? | requests call |
|---|---|---|---|
GET | Read data — no changes on the server | Yes | requests.get(url) |
POST | Create a new resource (or trigger an action) | No | requests.post(url, json=...) |
PUT | Replace a resource entirely | Yes | requests.put(url, json=...) |
PATCH | Partial update — change some fields | Usually | requests.patch(url, json=...) |
DELETE | Remove a resource | Yes | requests.delete(url) |
HEAD | Like GET, but response has no body — check existence | Yes | requests.head(url) |
import requests
BASE = "https://jsonplaceholder.typicode.com" # public sandbox API
# GET — read one post
r = requests.get(f"{BASE}/posts/1", timeout=5)
print(r.json()["title"])
# POST — create a new post
new_post = {"title": "Hello", "body": "World", "userId": 1}
r = requests.post(f"{BASE}/posts", json=new_post, timeout=5)
print(r.status_code, r.json())
# PUT — replace an existing post
r = requests.put(f"{BASE}/posts/1",
json={"id": 1, "title": "Updated", "body": "New", "userId": 1},
timeout=5)
# PATCH — change only the title
r = requests.patch(f"{BASE}/posts/1", json={"title": "Just the title"}, timeout=5)
# DELETE — remove
r = requests.delete(f"{BASE}/posts/1", timeout=5)
print(r.status_code) # 200 or 204
Query Params vs Request Body — When to Use Which
There are two places to send data with a request: the URL (query string) or the body. Getting this wrong is the #1 source of "why won't my POST work" frustration.
| Aspect | Detail |
|---|---|
| Where | After ? in URL |
| Typical use | GET — filters, pagination, search |
| Visible in logs | Yes — never put secrets here |
| Example | ?city=Tokyo&units=metric |
| Aspect | Detail |
|---|---|
| Where | Inside the HTTP body |
| Typical use | POST / PUT / PATCH — new records |
| Visible in logs | Usually not — safer for structured data |
| Example | {"name": "Ada", "age": 30} |
import requests
# Query params — passed via params={}
r = requests.get(
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": 35.68,
"longitude": 139.65,
"current_weather": True,
},
timeout=5,
)
# The URL sent becomes:
# .../forecast?latitude=35.68&longitude=139.65¤t_weather=True
print(r.url)
# JSON body — passed via json={}
r = requests.post(
"https://httpbin.org/post",
json={"name": "Ada", "role": "engineer"},
timeout=5,
)
print(r.json()["json"]) # httpbin echoes back what you sent
Writing url + "?q=" + user_input is a bug generator — spaces, ampersands,
and unicode characters all break it. Always pass params={...} to requests
and it will encode everything correctly. Same rule for JSON bodies: use
json={...}, never hand-craft the JSON string.
Parsing JSON — The Most Common Response Format
~95% of modern APIs speak JSON. requests parses it in one method call —
the returned Python object is a normal dict / list tree.
import requests
r = requests.get("https://api.github.com/repos/python/cpython", timeout=5)
data = r.json() # dict[str, Any]
print(data["full_name"]) # python/cpython
print(data["stargazers_count"]) # 62000+
print(data["language"]) # Python
print(data["license"]["name"]) # nested access is just dict[key]
# Nested / lists in JSON — same rules as normal Python
r = requests.get("https://api.github.com/repos/python/cpython/contributors",
params={"per_page": 3}, timeout=5)
for contributor in r.json():
print(f"{contributor['login']:15s} {contributor['contributions']} commits")
Safe Access to Optional Keys
# The bio might be missing — .get() returns None instead of crashing
bio = data.get("bio") # None if missing
bio = data.get("bio", "no bio available") # custom default
# Nested optional access — walrus + get chains
license_name = (data.get("license") or {}).get("name", "unknown")
Parsing XML — For Legacy & Enterprise APIs
XML still lives on in older or enterprise services — SOAP endpoints, government data feeds,
RSS/Atom, some payment gateways. Python's standard library handles it with
xml.etree.ElementTree. For untrusted XML, use defusedxml instead
to block XXE attacks.
import requests
import xml.etree.ElementTree as ET
# Example: NASA's Astronomy Picture of the Day feed (RSS/XML)
r = requests.get("https://www.nasa.gov/feed/", timeout=5)
root = ET.fromstring(r.content) # root is the outermost <rss> element
# RSS structure: rss > channel > item*
channel = root.find("channel")
for item in channel.findall("item")[:3]:
title = item.findtext("title")
link = item.findtext("link")
date = item.findtext("pubDate")
print(f"• {title}\n {link}\n {date}\n")
XML is a tree. root.find("channel") walks one branch; channel.findall("item") lists all children of that tag. Nested tags = nested nodes.
Authentication — Proving Who You Are
Most useful APIs require you to identify yourself. There are three common patterns. In every case, credentials belong in environment variables — never in code, never in git.
Authorization: Bearer <token> header. Used by GitHub, OpenAI, Stripe,
and most modern APIs.
import os, requests
# 1) API key as query param — the API tells you the parameter name
r = requests.get(
"https://api.openweathermap.org/data/2.5/weather",
params={"q": "Mumbai", "appid": os.environ["OWM_KEY"]},
timeout=5,
)
# 2) API key as header — many modern APIs prefer this
r = requests.get(
"https://api.example.com/data",
headers={"X-API-Key": os.environ["API_KEY"]},
timeout=5,
)
# 3) Bearer token — GitHub, OpenAI, Stripe pattern
token = os.environ["GITHUB_TOKEN"]
r = requests.get(
"https://api.github.com/user",
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
# 4) Basic auth — requests has built-in support
r = requests.get(
"https://api.example.com/private",
auth=(os.environ["USER"], os.environ["PASSWORD"]),
timeout=5,
)
Never paste an API key or token into your source code.
Put them in environment variables, a .env file (git-ignored),
or a secrets manager. Load them with os.environ["KEY"] or
python-dotenv. Leaked keys in git history are one of the most
common security incidents on GitHub.
Handling Status Codes & Errors
The status code is your first check on every response. Ignoring it is how you end up with cryptic bugs at 3 AM.
import requests
r = requests.get("https://api.github.com/repos/does-not/exist", timeout=5)
# Option A: check the code manually
if r.status_code == 200:
print(r.json())
elif r.status_code == 404:
print("repo does not exist")
elif r.status_code == 429:
print("rate limited, wait and retry")
else:
print(f"unexpected: {r.status_code}")
# Option B: let requests raise on any 4xx/5xx
try:
r = requests.get("https://api.github.com/repos/does-not/exist", timeout=5)
r.raise_for_status() # raises HTTPError on non-2xx
data = r.json()
except requests.HTTPError as e:
print(f"HTTP failure: {e}")
except requests.Timeout:
print("server took too long")
except requests.ConnectionError:
print("can't reach server (DNS / offline)")
Retries with Exponential Backoff
Networks fail. Servers hiccup. Rate limits happen. Any real client retries transient errors with an increasing delay so the target has room to recover — this is called exponential backoff.
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# Built-in retry — no manual loop needed
retry_policy = Retry(
total=5, # at most 5 attempts
backoff_factor=1.0, # delays: 1s, 2s, 4s, 8s, 16s
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET", "POST"],
respect_retry_after_header=True,
)
session = requests.Session()
session.mount("https://", HTTPAdapter(max_retries=retry_policy))
# Now every request through this session auto-retries transient errors
r = session.get("https://api.github.com/users/torvalds", timeout=10)
print(r.status_code)
Retry network errors (timeouts, connection resets),
429 Too Many Requests (respect Retry-After), and
5xx Server Errors — these are usually transient.
Never retry 4xx client errors (400, 401, 404) — retrying a wrong
request just wastes both your time and the server's.
Sessions — Connection Pooling for Speed
If you make more than one request to the same host, use a Session.
It reuses the underlying TCP + TLS connection — the second request is dramatically
faster because it skips the DNS lookup, TCP handshake, and TLS negotiation.
| Step | Cost |
|---|---|
| DNS lookup | ~20ms |
| TCP handshake | ~30ms |
| TLS handshake | ~80ms |
| Actual GET | ~50ms |
| Per request | ~180ms |
| Step | Cost |
|---|---|
| DNS lookup | cached |
| TCP handshake | reused |
| TLS handshake | reused |
| Actual GET | ~50ms |
| Per request | ~50ms (3.6× faster) |
import requests
# Bad — new connection every call
for i in range(100):
requests.get(f"https://api.github.com/repos/python/cpython/issues/{i}")
# Good — one session, connections reused
with requests.Session() as s:
s.headers.update({"Authorization": f"Bearer {token}"})
for i in range(100):
r = s.get(f"https://api.github.com/repos/python/cpython/issues/{i}",
timeout=5)
Pagination — Fetching Big Result Sets
APIs return data in pages — you rarely get 10,000 items in one call. Two common patterns:
?page=1, ?page=2 until an empty page arrives.
next URL. Follow it until it's null.
?offset=200&limit=100. Bump offset until fewer results come back than the limit.
def fetch_all_issues(repo: str, token: str) -> list[dict]:
session = requests.Session()
session.headers.update({"Authorization": f"Bearer {token}"})
all_issues, page = [], 1
while True:
r = session.get(f"https://api.github.com/repos/{repo}/issues",
params={"page": page, "per_page": 100, "state": "all"},
timeout=10)
r.raise_for_status()
batch = r.json()
if not batch: # empty page = we're done
break
all_issues.extend(batch)
page += 1
return all_issues
issues = fetch_all_issues("python/cpython", os.environ["GITHUB_TOKEN"])
print(f"fetched {len(issues)} issues")
Common Pitfalls (and Fixes)
| Mistake | What Happens | Fix |
|---|---|---|
No timeout |
Program hangs forever on a slow server | Always pass timeout=5 (or your SLA) |
Ignoring status_code |
Parsing HTML error page as JSON crashes | Check r.ok or call r.raise_for_status() |
| Hardcoded API key | Leaked to git history, credentials stolen | Environment variables + .gitignore |
Fresh get() in a loop |
3–5× slower than necessary | Use requests.Session() |
| String-built URLs | Spaces / unicode / & break the query | Pass params={...} to requests |
Not handling None in JSON |
KeyError on missing optional field | Use data.get(key, default) |
| Retrying 4xx errors | Wasted calls; may trigger bans | Only retry 429 & 5xx |
| Parsing untrusted XML with stdlib | XXE / billion-laughs vulnerability | Use defusedxml for external data |
Real-World Example — Complete GitHub Repo Analyzer
All the pieces together: session, auth, timeout, retries, error handling, JSON parsing, and pagination — in one production-ready client.
import os
from dataclasses import dataclass
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
BASE = "https://api.github.com"
@dataclass(frozen=True)
class RepoStats:
full_name: str
stars: int
forks: int
open_issues: int
language: str | None
top_contributors: list[str]
def build_session(token: str) -> requests.Session:
retry = Retry(total=5, backoff_factor=1.0,
status_forcelist=[429, 500, 502, 503, 504])
s = requests.Session()
s.mount("https://", HTTPAdapter(max_retries=retry))
s.headers.update({
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"User-Agent": "repo-analyzer/1.0",
})
return s
def analyze_repo(session: requests.Session, repo: str) -> RepoStats | None:
try:
r = session.get(f"{BASE}/repos/{repo}", timeout=10)
r.raise_for_status()
info = r.json()
c = session.get(f"{BASE}/repos/{repo}/contributors",
params={"per_page": 5}, timeout=10)
c.raise_for_status()
top = [u["login"] for u in c.json()]
return RepoStats(
full_name = info["full_name"],
stars = info["stargazers_count"],
forks = info["forks_count"],
open_issues = info["open_issues_count"],
language = info.get("language"),
top_contributors = top,
)
except requests.HTTPError as e:
print(f"[{repo}] HTTP error: {e}")
except (requests.Timeout, requests.ConnectionError):
print(f"[{repo}] network problem")
return None
# ── Run it ──────────────────────────────────────────────
REPOS = ["python/cpython", "pallets/flask", "psf/requests"]
with build_session(os.environ["GITHUB_TOKEN"]) as s:
for repo in REPOS:
stats = analyze_repo(s, repo)
if stats:
print(f"\n{stats.full_name}")
print(f" ★ {stats.stars:,} forks {stats.forks:,} issues {stats.open_issues:,}")
print(f" language: {stats.language}")
print(f" top: {', '.join(stats.top_contributors)}")
One session with keep-alive connections. Bearer token from env var — no leaks. Auto-retry on 429/5xx with backoff. Every network call has a timeout. Every response has explicit error handling. Data flows into a typed dataclass. This is the shape of every real API client you'll ever write.
Golden Rules
timeout=. Without one, a stalled server
can hang your program forever. Start with 5–10 seconds for reads.
r.raise_for_status()
or branch on r.status_code. Assuming success is how you get cryptic
"expected dict, got str" errors deep in your parser.
requests.Session() whenever you make more than one call
to the same host. Connection reuse saves ~130ms per subsequent request.
params={}
for query strings and json={} for JSON bodies. Let requests
handle escaping.
Retry-After header.
Never retry 4xx client errors.
defusedxml — not the stdlib xml module —
for XML that comes from anyone but yourself. XXE and billion-laughs attacks are real.