2. Defensive extraction with .get()
The naive extraction crashed because bracket notation assumes keys exist. The fix is a single dictionary method: .get() returns a default value instead of raising KeyError when a key is missing. Combined with sensible defaults at every nesting level, this one change eliminates most of the crashes from the previous section.
.get() takes two arguments: the key to look for, and the value to return if that key isn't there. If you leave the second argument out, it returns None. That sounds simple, and it is -- the interesting part is picking the right default for the situation.
Bracket notation vs. .get()
Start with a tiny demo that contrasts the two styles on the same missing key. Save this at the project root as bracket_vs_get.py:
# Sample data with missing keys
user_data = {
"username": "alice_coder",
"email": "alice@example.com"
# Note: "age" and "location" are missing
}
print("=== Bracket Notation (Crashes) ===")
try:
age = user_data["age"] # KeyError!
print(f"Age: {age}")
except KeyError as e:
print(f"ERROR: Crash: {e}")
print("\n=== .get() Without Default ===")
age = user_data.get("age") # Returns None
print(f"Age: {age}") # Prints "Age: None"
print("\n=== .get() With Default ===")
age = user_data.get("age", 0) # Returns 0
print(f"Age: {age}") # Prints "Age: 0"
print("\n=== .get() With Meaningful Default ===")
age = user_data.get("age", "Unknown")
print(f"Age: {age}") # Prints "Age: Unknown"
Run it from the project root:
$ python bracket_vs_get.py
=== Bracket Notation (Crashes) ===
ERROR: Crash: 'age'
=== .get() Without Default ===
Age: None
=== .get() With Default ===
Age: 0
=== .get() With Meaningful Default ===
Age: Unknown
The first block crashes; the next three all survive the missing key. What changes is just the default. Picking a sensible default is the main skill, and four rules of thumb cover almost every case:
- Use a descriptive string (
"Unknown","Not specified") for fields you'll display. - Use
0or an empty list[]for fields you'll do math or iteration on. - Use
Nonewhen callers need to distinguish "not provided" from "provided as zero". - Use an empty dict
{}for nested objects you'll chain further.get()calls on.
That last rule is the trick that makes nested extraction work -- if every level returns something dict-shaped even when the key is missing, the next .get() call still has something to call it on.
Walking a nested structure safely
One-level .get() solves missing keys in flat data. The Random User response is four levels deep, and at every level a value could be missing or arrive as a different shape. The pattern for those cases is the same idea applied recursively: at each step, .get() with a default that's the right shape for the next step. One case it does not cover is a key that is present but set to null: .get() hands back that null rather than your default, so the next call still crashes. We close that gap with an explicit type check later in this chapter.
Here's the structure we're navigating. Each annotated level is a place where a crash can happen:
{
"results": [ ← Level 1: Array (could be empty)
{ ← Level 2: User object (could be missing)
"name": { ← Level 3: Name object (could be null)
"first": "...", ← Level 4: Actual value (could be absent)
"last": "..."
},
"location": { ← Level 3: Location object (could be null)
"street": { ← Level 4: Street object (could be null)
"number": 1234, ← Level 5: Actual value
"name": "Queen St"
},
"city": "...", ← Level 4: Actual value
"country": "..."
},
"dob": { ← Level 3: DOB object (could be null)
"age": 34 ← Level 4: Actual value
},
"email": "..." ← Level 3: Direct value
}
]
}
Each level can fail independently. The pattern below guards the array and the user object with isinstance checks and uses shaped .get() defaults for the missing-key case at each nested level. The one gap, a nested key that is present but null, we close later in this chapter. Save it as defensive_user.py:
import requests
# Fetch user data
response = requests.get("https://randomuser.me/api/", timeout=10)
response.raise_for_status()
# Validate content type (Chapter 4 pattern)
content_type = response.headers.get("Content-Type", "")
if "application/json" not in content_type:
print(f"Expected JSON but received {content_type}")
exit(1)
data = response.json()
# SAFE: Check each level defensively
users = data.get("results", []) # Default to empty list
if not isinstance(users, list):
print("Results field is not a list")
exit(1)
if not users:
print("No users found")
exit(0)
# Get first user safely
user = users[0] # Safe now - users is a non-empty list
if not isinstance(user, dict):
print("First user is not an object")
exit(1)
# Extract nested data with defaults at each level
name_obj = user.get("name", {}) # Default to empty dict
first_name = name_obj.get("first", "Unknown")
last_name = name_obj.get("last", "Unknown")
location_obj = user.get("location", {})
city = location_obj.get("city", "Unknown")
country = location_obj.get("country", "Unknown")
dob_obj = user.get("dob", {})
age = dob_obj.get("age", "Unknown")
email = user.get("email", "No email provided")
# Display safely extracted data
print(f"Name: {first_name} {last_name}")
print(f"Email: {email}")
print(f"Age: {age}")
print(f"Location: {city}, {country}")
Run it from the project root with python defensive_user.py. Random User returns a different person each time, so your details will differ; the shape should match:
$ python defensive_user.py
Name: Emma Johnson
Email: emma.johnson@example.com
Age: 34
Location: Auckland, New Zealand
The output looks the same as naive_user.py on a successful run, but the difference shows up the moment the API returns anything less than perfect. Missing results? Empty list gets returned, the if not users check fires, and the script exits cleanly with "No users found" instead of crashing. Wrong-shaped results? The type check stops before users[0] can return something surprising. Missing name? Empty dict gets returned, and name_obj.get("first", "Unknown") yields "Unknown". Every potential crash site has a fallback.
The key trick is that each .get() returns a value of the same shape as whatever you'd be reaching into next. For a nested object, the default is an empty dict so the next .get() still works. For an array, the default is an empty list so the next iteration safely runs zero times. For a leaf value, the default is whatever makes sense to display or compute with. Once that mental model clicks, the rest of the chapter is variations on the same pattern.
One sharp edge worth flagging: you'll sometimes see people write user.get("name") or "Unknown" as a shortcut. That works for strings, but it misbehaves on any value Python considers falsy, including the integer 0, empty strings, and empty lists. A user with a score of 0 would get overwritten to "Unknown". The two-argument form, user.get("name", "Unknown"), only triggers the default when the key is genuinely missing, which is almost always what you want.
A reusable extraction function
Repeating eight lines of .get() chains at every call site is noisy. The cleaner move is to encapsulate the extraction once in a function that takes raw user data and returns a consistent, always-populated dict, or None if the input isn't usable. That gives the rest of your code a single, predictable shape to work with.
Save this as extract_user.py. It defines extract_user_safely() plus a driver that fetches a real user and prints the result:
import requests
def extract_user_safely(user_data):
"""
Extract user information with complete defensive programming.
Args:
user_data: Dictionary containing user information from API
Returns:
Dictionary with guaranteed keys, or None if data is invalid
"""
# Validate input type
if not isinstance(user_data, dict):
return None
# Extract name with multi-level defaults
name_obj = user_data.get("name", {})
if not isinstance(name_obj, dict):
name_obj = {}
first_name = name_obj.get("first", "")
last_name = name_obj.get("last", "")
# Build full name, handling various empty states
if first_name and last_name:
full_name = f"{first_name} {last_name}"
elif first_name:
full_name = first_name
elif last_name:
full_name = last_name
else:
full_name = "Unknown"
# Extract location
location_obj = user_data.get("location", {})
if not isinstance(location_obj, dict):
location_obj = {}
city = location_obj.get("city", "Unknown")
country = location_obj.get("country", "Unknown")
# Extract age with type validation
dob_obj = user_data.get("dob", {})
if not isinstance(dob_obj, dict):
dob_obj = {}
age_value = dob_obj.get("age")
# Handle age being int, string, or None
if isinstance(age_value, int):
age = age_value
elif isinstance(age_value, str) and age_value.isdigit():
age = int(age_value)
else:
age = None
# Extract email with validation
email = user_data.get("email", "")
if not email or not isinstance(email, str):
email = "No email provided"
# Return consistent structure
return {
"full_name": full_name,
"first_name": first_name if first_name else "Unknown",
"last_name": last_name if last_name else "Unknown",
"email": email,
"age": age, # Can be None - caller checks
"city": city,
"country": country,
"location_full": f"{city}, {country}"
}
def fetch_and_display_user():
"""Fetch user with complete error handling."""
try:
# Make request (Chapter 4 pattern)
response = requests.get("https://randomuser.me/api/", timeout=10)
response.raise_for_status()
# Validate content type
content_type = response.headers.get("Content-Type", "")
if "application/json" not in content_type:
print(f"ERROR: Expected JSON but received {content_type}")
return
# Parse JSON
try:
data = response.json()
except ValueError:
print("ERROR: Server returned invalid JSON")
return
# Extract users array
users = data.get("results", [])
if not users or not isinstance(users, list):
print("INFO: No users found in response")
return
# Extract first user safely
user_info = extract_user_safely(users[0])
if not user_info:
print("ERROR: Could not parse user data")
return
# Display extracted information
print("=== User Information ===")
print(f"Name: {user_info['full_name']}")
print(f"Email: {user_info['email']}")
if user_info['age'] is not None:
print(f"Age: {user_info['age']}")
else:
print("Age: Not provided")
print(f"Location: {user_info['location_full']}")
print("\nOK: All data extracted safely")
except requests.exceptions.Timeout:
print("ERROR: Request timed out")
except requests.exceptions.RequestException as e:
print(f"ERROR: Network error: {e}")
# Run the example
fetch_and_display_user()
Run it from the project root. Random User returns a different person each time, so your user details will differ; the extraction shape should match:
$ python extract_user.py
=== User Information ===
Name: Emma Johnson
Email: emma.johnson@example.com
Age: 34
Location: Auckland, New Zealand
OK: All data extracted safely
Two things make this version worth the extra lines. First, the return shape is guaranteed: callers can always read user_info["full_name"], user_info["email"], and so on without checking whether the key is present. Second, None is used meaningfully -- user_info["age"] is None (not 0 or "Unknown") specifically so callers can tell "age wasn't provided" apart from "age is zero". That distinction matters for almost any display or reporting code that runs downstream.
It also does one thing the previous version didn't: it validates that nested fields are actually dicts before treating them that way. If user["name"] came back as a string or a list, the extra isinstance(name_obj, dict) check catches it. That's the first hint of the next section -- type validation is the layer that handles "present but wrong type" after .get() has handled "missing".
The generic pattern this protects against is the one where an optional nested array goes missing and a single bracket access takes the whole flow down. Product-listing endpoints do it with amenities, search endpoints do it with tags, anything with an optional "categories" shape does it eventually. The habit of writing .get("field", []) plus a length check before indexing makes that whole category of bug impossible to hit.