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:

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:

Terminal
$ 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 0 or an empty list [] for fields you'll do math or iteration on.
  • Use None when 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:

Response shape
{
  "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:

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:

Terminal
$ 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:

extract_user.py
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:

Terminal
$ 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.