Python Advance 📂 Important concepts · 2 of 6 43 min read

Working with APIs in Python

Master API programming in Python with the requests library. Learn HTTP methods, query params vs JSON bodies, parsing JSON and XML responses, four authentication patterns, status code handling, retries with exponential backoff, connection-pooled sessions, and pagination. Includes 4 visual diagrams (request/response cycle, XML tree, Bearer auth flow, status code decision tree) and a complete production-shaped GitHub client with dataclass, retries, and typed error handling.

Section 01

The Story That Explains APIs

Ordering Food Without Entering the Kitchen
You walk into a restaurant. You don't march into the kitchen, chop vegetables, and cook. You read a menu, tell the waiter what you want, and a plate arrives. You don't know how the chef stores ingredients, which stove they use, or whether they imported the pasta from Italy. The menu is the contract: these dishes exist, this is what you ask for, this is what you get back.

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.

🧠
The Core Insight

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.


Section 02

Visual Diagram — The HTTP Request / Response Cycle

📧 Diagram — One HTTP Round-Trip, End to End
YOUR PYTHON PROGRAM requests.get(...) INTERNET DNS • TCP • TLS API SERVER (remote) api.example.com REQUEST GET /weather?city=London Authorization: Bearer <key> method + path + headers + optional body RESPONSE 200 OK • application/json {"city": "London", "temp": 14.2} status + headers + body (JSON / XML / bytes)

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.


Section 03

The Three Building Blocks of an API Call

🔗
1 — Endpoint (URL)
what you're asking about
A specific URL — often called an endpoint — that identifies a resource.
https://api.github.com/users/torvalds
🛠️
2 — Method (Verb)
what you want to do
GET (read), POST (create), PUT/PATCH (update), DELETE (remove). The method tells the server your intent — read-only vs. modifying.
📦
3 — Data (Payload)
the details
Query strings (?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"])
OUTPUT
200 application/json; charset=utf-8 Linus Torvalds 7 None
💡
Three Facts About requests

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=.


Section 04

The HTTP Methods — What They Mean, When to Use Each

MethodPurposeIdempotent?requests call
GETRead data — no changes on the serverYesrequests.get(url)
POSTCreate a new resource (or trigger an action)Norequests.post(url, json=...)
PUTReplace a resource entirelyYesrequests.put(url, json=...)
PATCHPartial update — change some fieldsUsuallyrequests.patch(url, json=...)
DELETERemove a resourceYesrequests.delete(url)
HEADLike GET, but response has no body — check existenceYesrequests.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

Section 05

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.

🔍 Query Parameters (URL)
AspectDetail
WhereAfter ? in URL
Typical useGET — filters, pagination, search
Visible in logsYes — never put secrets here
Example?city=Tokyo&units=metric
💼 Request Body (JSON)
AspectDetail
WhereInside the HTTP body
Typical usePOST / PUT / PATCH — new records
Visible in logsUsually 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&current_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
⚠️
Never Build URLs with String Concatenation

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.


Section 06

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")
OUTPUT
python/cpython 62483 Python Python Software Foundation License gvanrossum 1230 commits brettcannon 912 commits serhiy-storchaka 876 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")

Section 07

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")
🌲 Diagram — XML as a Tree
<rss> <channel> <item> #1 <item> #2 <item> #3 <title> <link> <pubDate>

XML is a tree. root.find("channel") walks one branch; channel.findall("item") lists all children of that tag. Nested tags = nested nodes.


Section 08

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.

🔑
API Key
simplest — a shared secret
Passed as a query param or header. Common with weather, translation, and simple SaaS APIs. Rotate periodically.
🎟️
Bearer Token
OAuth2 / JWT — modern standard
A token you obtain (often via login) and send in an Authorization: Bearer <token> header. Used by GitHub, OpenAI, Stripe, and most modern APIs.
👤
Basic Auth
username + password (HTTPS only!)
Legacy. Credentials base64-encoded in the header — safe ONLY over HTTPS. Still common with internal enterprise 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,
)
🔐 Diagram — Bearer Token Auth Flow
CLIENT your program AUTH SERVER login endpoint API SERVER protected data 1. POST creds 2. token 3. GET /data + Bearer <token> 4. JSON response Token is obtained ONCE, then reused for many API calls. When it expires, request a new one — usually via a refresh_token.
🔒
Never Commit Secrets

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.


Section 09

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.

📈 Diagram — HTTP Status Code Decision Tree
RESPONSE status code = ? 2xx SUCCESS 200, 201, 204 use the data 3xx REDIRECT 301, 302, 304 requests follows auto 4xx CLIENT 400, 401, 404, 429 YOUR fault — fix request 5xx SERVER 500, 502, 503, 504 THEIR fault — retry later 200 OK everything worked 401 Unauthorized missing / bad token 404 Not Found wrong URL / id 429 Too Many rate limited — back off Retry 5xx and 429 automatically. Never blindly retry 4xx — the request itself is wrong.
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)")

Section 10

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 What, Exactly?

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.


Section 11

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.

❌ Fresh Connection Every Time
StepCost
DNS lookup~20ms
TCP handshake~30ms
TLS handshake~80ms
Actual GET~50ms
Per request~180ms
✅ Session (Reused Connection)
StepCost
DNS lookupcached
TCP handshakereused
TLS handshakereused
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)

Section 12

Pagination — Fetching Big Result Sets

APIs return data in pages — you rarely get 10,000 items in one call. Two common patterns:

📌 Pagination Styles
Style 1
Page numbers — request ?page=1, ?page=2 until an empty page arrives.
Style 2
Cursor / next-URL — response includes a next URL. Follow it until it's null.
Style 3
Offset / limit?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")

Section 13

Common Pitfalls (and Fixes)

MistakeWhat HappensFix
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

Section 14

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)}")
OUTPUT
python/cpython ★ 62,483 forks 29,912 issues 8,120 language: Python top: gvanrossum, brettcannon, serhiy-storchaka, tiran, benjaminp pallets/flask ★ 68,200 forks 16,342 issues 12 language: Python top: davidism, ThiefMaster, methane, rduplain, untitaker psf/requests ★ 52,014 forks 9,352 issues 210 language: Python top: kennethreitz, sigmavirus24, Lukasa, nateprewitt, ssbarnea
🏆
What This Client Does Right

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.


Section 15

Golden Rules

📡 Working with APIs — Non-Negotiable Rules
1
Always set a timeout=. Without one, a stalled server can hang your program forever. Start with 5–10 seconds for reads.
2
Always check the status code. Either use 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.
3
Use requests.Session() whenever you make more than one call to the same host. Connection reuse saves ~130ms per subsequent request.
4
Never build URLs with string concatenation. Pass params={} for query strings and json={} for JSON bodies. Let requests handle escaping.
5
Secrets in environment variables, never in code, never in git. Rotate credentials that leak — assume they're being scraped.
6
Retry only transient errors — 429, 500, 502, 503, 504, and network failures. Use exponential backoff and honor the Retry-After header. Never retry 4xx client errors.
7
Use defusedxml — not the stdlib xml module — for XML that comes from anyone but yourself. XXE and billion-laughs attacks are real.
8
Model the response with a dataclass or TypedDict. Raw dicts leak throughout your code and every consumer becomes fragile to API changes. One typed boundary is much easier to update than fifty.