12.1. API Fundamentals: GET, Params, and Error Handling#
Web APIs let Python programs request data from services over HTTP. In data work, an API is often the difference between copying a file by hand and writing a repeatable data collection step. This section focuses on read-only API calls: choosing an endpoint, passing query parameters, reading JSON, and handling errors without crashing a larger analysis.
12.1.1. What Is an API?#
An application programming interface (API) is a defined way for one program to ask another program for a service. A web API exposes that interface through URLs. A REST-style API usually treats each URL as a resource and uses HTTP methods to say what kind of action the client wants.
Method |
Common purpose |
Example use |
|---|---|---|
|
Retrieve data |
Get current weather for a city |
|
Send new data |
Submit a form or create a record |
|
Update data |
Change a profile setting |
|
Remove data |
Delete a saved item |
Status codes summarize how the request went:
Code range |
Meaning |
Typical action |
|---|---|---|
|
Success |
Parse the response |
|
Redirect |
Usually handled by the client library |
|
Client-side problem |
Check the endpoint, parameters, or authentication |
|
Server-side problem |
Retry later or report failure gracefully |
A useful habit is to describe every API call as endpoint + method + parameters + expected response shape.
12.1.1.1. Worked Example: Read an API Contract#
Suppose a weather endpoint documents this contract:
Field |
Value |
|---|---|
Endpoint |
|
Method |
|
Required params |
|
Response |
JSON object with |
Before writing code, identify the smallest successful request and the keys your code expects back.
api_contract = {
"endpoint": "https://api.example.test/weather",
"method": "GET",
"required_params": ["city", "units"],
"response_keys": ["city", "temperature", "units"],
}
print(api_contract["method"], api_contract["endpoint"])
print("Required params:", ", ".join(api_contract["required_params"]))
print("Expected keys:", ", ".join(api_contract["response_keys"]))
GET https://api.example.test/weather
Required params: city, units
Expected keys: city, temperature, units
12.1.2. Making GET Requests with requests#
The requests library is the common Python tool for HTTP. A basic GET request looks like this:
response = requests.get(url, timeout=10)
response.raise_for_status()
data = response.json()
This notebook avoids live network calls so the book builds reliably offline. The next example uses a tiny response object with the same methods your code uses from a real requests.Response: raise_for_status() and json().
12.1.2.1. Worked Example: Parse a JSON Response#
This example uses a small stand-in for a requests response, so it runs without a network connection. The stand-in has the same raise_for_status() and .json() methods as a real response, so the parsing code is exactly what you would write against a live API.
class DemoResponse:
def __init__(self, payload, status_code=200):
self._payload = payload
self.status_code = status_code
def raise_for_status(self):
if self.status_code >= 400:
raise requests.HTTPError(f"HTTP {self.status_code}")
def json(self):
return self._payload
response = DemoResponse({"city": "Chicago", "temperature": 72.5, "units": "fahrenheit"})
response.raise_for_status()
data = response.json()
print(f"{data['city']}: {data['temperature']} degrees {data['units']}")
Chicago: 72.5 degrees fahrenheit
12.1.2.2. JSON Text and Python Objects#
An API sends its response body over the network as text. JSON (JavaScript Object Notation) looks a lot like Python dictionary and list literals, but until something parses it, a JSON body is just a string. The standard library json module converts in both directions:
Function |
Input |
Output |
|---|---|---|
|
A JSON string |
Python |
|
A Python |
A JSON string |
The s at the end of each name stands for string. Turning Python objects into JSON text is called JSON serialization, and parsing the text back is deserialization. response.json() is a shortcut for the parsing step: it takes the response body text and calls json.loads() on it for you. When JSON text comes from somewhere other than a requests response, such as a saved string, a log line, or a cache, you call json.loads() yourself.
JSON and Python spell a few values differently:
JSON |
Python |
|---|---|
|
|
|
|
|
|
object |
|
array |
|
12.1.2.2.1. Worked Example: Parse JSON Text by Hand#
The string below stands in for a response body. It contains false and null, so it is valid JSON but not valid Python. After json.loads(), you work with an ordinary dictionary, and .get() supplies a default for a key the payload does not include.
raw_body = '{"city": "Chicago", "temperature": 72.5, "raining": false, "alerts": null}'
print(type(raw_body))
weather = json.loads(raw_body)
print(type(weather))
print(weather["raining"], weather["alerts"])
print(weather.get("humidity", "not reported"))
<class 'str'>
<class 'dict'>
False None
not reported
Going the other way, json.dumps() turns a Python object into JSON text, for example to log a summary, save it to a file, or send it in a request body. In the output, False becomes false, None becomes null, and every string uses double quotes. Parsing that text again gives back an equal dictionary.
summary = {"city": weather["city"], "raining": weather["raining"], "alerts": weather["alerts"]}
summary_text = json.dumps(summary)
print(summary_text)
print(type(summary_text))
print(json.loads(summary_text) == summary)
{"city": "Chicago", "raining": false, "alerts": null}
<class 'str'>
True
12.1.2.2.2. Checkpoint: JSON Round Trip#
request_options is a Python dictionary that a program wants to store as JSON text. Before you run any code, predict how False and None will appear in the JSON text. Then convert the dictionary to a JSON string named options_text with json.dumps() and print it. Finally, parse options_text with json.loads() and print whether the result equals request_options.
request_options = {"city": "Chicago", "units": "metric", "include_alerts": False, "max_days": 3, "station": None}
### Your code starts here
### Your code ends here
{"city": "Chicago", "units": "metric", "include_alerts": false, "max_days": 3, "station": null}
True
12.1.3. Query Parameters#
Many GET endpoints use query parameters: key/value pairs after the ? in a URL. Build them with the params= argument instead of manually concatenating strings. requests handles escaping spaces, punctuation, and special characters correctly.
12.1.3.1. Worked Example: Build a Request URL Safely#
Instead of sending the request, this example prepares it and prints the URL that requests builds from params=. Notice that the space in New York is encoded for you, and that parsing the query string recovers the original values.
base_url = "https://api.example.test/weather"
params = {"city": "New York", "units": "fahrenheit"}
request = requests.Request("GET", base_url, params=params).prepare()
print(request.url)
parsed = urlparse(request.url)
print(parse_qs(parsed.query))
https://api.example.test/weather?city=New+York&units=fahrenheit
{'city': ['New York'], 'units': ['fahrenheit']}
Behind params=, requests performs URL encoding, also called percent-encoding: a character with special meaning in a URL is replaced by % followed by its byte value in hexadecimal. The standard library function urllib.parse.urlencode() does the same job for a whole dictionary, and urllib.parse.quote() encodes a single value. A space becomes + in urlencode() output and %20 in quote() output, and servers read both as a space. Characters such as & and = are escaped as %26 and %3D, so a value that contains them cannot split the query string into extra parameters. The first line below joins raw values by hand and shows the problem: the server would read fish & chips as two parameters. You rarely call these functions yourself, because requests does this for you with params=.
from urllib.parse import urlencode, quote
search_params = {"q": "fish & chips", "filter": "price=low", "city": "New York"}
naive = "&".join(f"{key}={value}" for key, value in search_params.items())
print(naive)
print(urlencode(search_params))
print(quote("fish & chips"))
q=fish & chips&filter=price=low&city=New York
q=fish+%26+chips&filter=price%3Dlow&city=New+York
fish%20%26%20chips
12.1.3.2. Checkpoint: Query Parameters#
The endpoint https://api.example.test/search accepts q, page, and limit. Create a prepared GET request for page 2 of a search for python api, limited to 5 results. Print the final URL.
### Your code starts here
search_url = "https://api.example.test/search"
params = {
# Fill in q, page, and limit.
}
prepared = requests.Request("GET", search_url, params=params).prepare()
print(prepared.url)
### Your code ends here
https://api.example.test/search
https://api.example.test/search?q=python+api&page=2&limit=5
12.1.4. Error Handling#
Two categories of errors matter in most API code:
Network errors: timeouts, DNS failures, broken connections, or other exceptions raised before a response arrives.
HTTP errors: the server responds, but the status code signals a problem, such as
404 Not Foundor500 Internal Server Error.
A reliable API helper should use a timeout, call raise_for_status(), and return a predictable value when the request fails.
12.1.4.1. Worked Example: Safe JSON Fetching#
This example wraps both failure checks in one function. raise_for_status() catches HTTP errors, and a ValueError from .json() catches a body that is not valid JSON. Either way, the function returns None instead of crashing.
def parse_json_response(response):
"""Return JSON data from a response-like object, or None if it fails."""
try:
response.raise_for_status()
return response.json()
except requests.HTTPError as error:
print("HTTP error:", error)
except ValueError as error:
print("Invalid JSON:", error)
return None
ok = DemoResponse({"results": ["a", "b"]}, status_code=200)
missing = DemoResponse({"message": "not found"}, status_code=404)
print(parse_json_response(ok))
print(parse_json_response(missing))
{'results': ['a', 'b']}
HTTP error: HTTP 404
None
12.1.4.2. Catching Any Request Failure#
The requests library organizes its exceptions in a hierarchy. requests.RequestException is the base class, and the specific errors inherit from it: requests.ConnectionError (the server cannot be reached), requests.Timeout (no reply arrives within the timeout limit), and requests.HTTPError (raised by raise_for_status()). Because every one of them is a RequestException, the clause except requests.RequestException catches any request failure. Use it when your code handles every failure the same way, and catch a specific subclass such as requests.HTTPError first when one case needs its own handling.
The stand-in function below raises requests.Timeout instead of making a real call, so you can watch the base class catch it without a network connection.
def fake_get(url, timeout):
"""Stand in for requests.get() by simulating a server that never replies."""
raise requests.Timeout(f"No reply from {url} within {timeout} seconds")
def safe_get(url):
try:
return fake_get(url, timeout=5)
except requests.RequestException as error:
print(f"{type(error).__name__}: {error}")
return None
print(issubclass(requests.Timeout, requests.RequestException))
print(issubclass(requests.HTTPError, requests.RequestException))
print(safe_get("https://api.example.com/data"))
True
True
Timeout: No reply from https://api.example.com/data within 5 seconds
None
12.1.5. Weather API Pattern#
A real weather call to Open-Meteo would use this shape:
requests.get(
"https://api.open-meteo.com/v1/forecast",
params={"latitude": 41.88, "longitude": -87.63, "current_weather": True},
timeout=10,
)
For a reproducible notebook, practice with a saved payload that has the same nested structure.
12.1.5.1. Worked Example: Extract Weather Data#
The Open-Meteo response nests the current conditions inside a current_weather dictionary. This example uses a saved sample of that response and reads the temperature and wind speed in two steps: first the inner dictionary, then its fields.
sample_weather = {
"latitude": 41.88,
"longitude": -87.63,
"current_weather": {
"temperature": 21.4,
"windspeed": 13.2,
"weathercode": 2,
},
}
current = sample_weather["current_weather"]
print("Temperature:", current["temperature"])
print("Wind speed:", current["windspeed"])
Temperature: 21.4
Wind speed: 13.2
12.1.5.2. Checkpoint: Weather Payload#
Write a small function summarize_weather(payload) that returns a dictionary with the temperature and wind speed from a weather response payload.
def summarize_weather(payload):
### Your code starts here
return {}
### Your code ends here
print(summarize_weather(sample_weather))
{}
{'temperature': 21.4, 'windspeed': 13.2}
12.1.6. Summary#
API fundamentals are mostly about making contracts explicit. A GET request asks an endpoint for data, params= builds URL-encoded query strings safely, response.json() converts JSON text into Python dictionaries and lists (json.loads() does the same for any JSON string, and json.dumps() goes the other way), and raise_for_status() turns failing HTTP status codes into exceptions your program can handle. For reliable notebooks and tests, separate the request step from the parsing step so parsing logic can be tested with saved payloads.