Edge Case Handling

Concepts covered: Guard Clauses

Edge cases are inputs or situations at the boundaries of what your code handles. Empty collections and zero values are the usual ones, with None values and negative numbers close behind, along with extreme values at either end of a range. Code that holds up on real data checks for these cases explicitly. Unhandled edge cases are one of the most common sources of bugs.

Common edge cases

Here are the most common edge cases you should always consider:

Handling None values

None represents the absence of a value. Always check for None before using a value that might not exist:

def get_length(items):
    # Guard against None
    if items is None:
        return 0
    return len(items)

print(get_length([1, 2, 3]))
print(get_length(None))

# Using "is" for None
value = None

# Check for None
if value is None:
    print("Value is None")

if value == None:
    print("Value equals None")

# if not value:  # Risky

Output:

3
0
Value is None
Value equals None

Always use is None rather than == None. The is operator checks identity, which is what you want for None.

Handling empty collections

Python treats any empty collection or string as falsy, but you should often handle them explicitly:

def calculate_average(numbers):
    # Guard against empty list
    if not numbers:
        return 0

    return sum(numbers) / len(numbers)

print(calculate_average([10, 20, 30]))
print(calculate_average([]))

def get_first(items):
    # Guard against empty
    if not items:
        return None
    return items[0]

print(get_first(["a", "b", "c"]))
print(get_first([]))

# Check before accessing
data = {"users": []}
if data.get("users"):
    print("Has users")
else:
    print("No users")

Output:

20.0
0
a
None
No users

Division by zero and index-out-of-range errors are among the most common runtime crashes in data pipelines. A single guard clause at the start of a function is all it takes to make these errors safe and explicit.

None checks deserve special attention. Unlike other edge cases, None can appear anywhere a value is expected: from missing dictionary keys, unset function parameters, and failed lookups. Checking is None before use is a habit that pays off in reliability.

Combining None and empty-collection guards in the right order matters. If you call len(items) before checking items is None, you will get a TypeError before your None guard even has a chance to run.

Boundary Conditions

Pay special attention to boundary values, such as the first and last elements, or a value equal to the minimum or maximum:

def safe_divide(a, b):
    # Guard against division by zero
    if b == 0:
        return None
    return a / b

print(safe_divide(10, 2))
print(safe_divide(10, 0))

def get_element(items, index):
    # Guard against out-of-bounds
    if index < 0 or index >= len(items):
        return None
    return items[index]

data = [10, 20, 30]
print(get_element(data, 1))
print(get_element(data, 10))
print(get_element(data, -5))

Output:

5.0
None
20
None
None

Defensive programming

A defensive programming approach handles edge cases at the start of every function:

def process_records(records):
    """Process a list of records safely."""
    # 1. Handle None
    if records is None:
        print("Error: records is None")
        return []

    # 2. Handle wrong type
    if not isinstance(records, list):
        print("Error: records must be a list")
        return []

    # 3. Handle empty
    if len(records) == 0:
        print("Warning: no records to process")
        return []

    # 4. Main logic - records is non-empty
    processed = []
    for record in records:
        if record is not None:
            processed.append(str(record).upper())

    return processed

print(process_records(["a", "b", "c"]))
print(process_records(None))
print(process_records("not a list"))
print(process_records([]))

Output:

['A', 'B', 'C']
Error: records is None
[]
Error: records must be a list
[]
Warning: no records to process
[]

A validation function that combines the patterns

This data validation function uses every pattern from this lesson, with guard clauses doing most of the edge case handling:

def validate_user_record(record):
    """Validate a user record with comprehensive checks."""
    # Guard: handle None input
    if record is None:
        return {"valid": False, "error": "Record is None"}

    # Guard: check required fields exist
    required = ["name", "email", "age"]
    for field in required:
        if field not in record:
            return {"valid": False, "error": "Missing " + field}

    # Validate name (non-empty string)
    name = record["name"]
    if not name or not isinstance(name, str):
        return {"valid": False, "error": "Invalid name"}

    # Validate email (contains @)
    email = record["email"]
    if not email or "@" not in email:
        return {"valid": False, "error": "Invalid email"}

    # Validate age (reasonable range)
    age = record["age"]
    if not 0 < age < 150:
        return {"valid": False, "error": "Invalid age"}

    # Determine user category
    category = (
        "minor" if age < 18 else
        "adult" if age < 65 else
        "senior"
    )

    return {
        "valid": True,
        "name": name.strip(),
        "email": email.lower(),
        "age": age,
        "category": category
    }

# Test with various inputs
tests = [
    {"name": "Alice", "email": "alice@example.com", "age": 25},
    {"name": "", "email": "test@test.com", "age": 30},
    {"name": "Bob", "email": "invalid", "age": 40},
    {"name": "Carol", "email": "carol@test.com", "age": 200},
    None,
]

for test in tests:
    result = validate_user_record(test)
    if result["valid"]:
        print("Valid:", result["name"], "-", result["category"])
    else:
        print("Invalid:", result["error"])

Output:

Valid: Alice - adult
Invalid: Invalid name
Invalid: Invalid email
Invalid: Invalid age
Invalid: Record is None

Guard clauses at the top reject invalid inputs, and the function returns early for each error case, so the success path sits at the end. A chained comparison checks the age range, and a conditional expression assigns the user category in one line. Adding a requirement usually means adding one more guard without touching the success path.

Filtering records in a batch loop

This loop filters and transforms a dataset with the same patterns. ETL pipelines and data validation systems run this kind of batch processing constantly:

# Process a batch of transaction records
transactions = [
    {"id": 1, "amount": 150, "type": "purchase", "status": "completed"},
    {"id": 2, "amount": -50, "type": "refund", "status": "completed"},
    {"id": 3, "amount": 0, "type": "purchase", "status": "pending"},
    {"id": 4, "amount": 500, "type": "purchase", "status": "completed"},
    {"id": 5, "amount": None, "type": "unknown", "status": "error"},
]

valid_total = 0
invalid_count = 0

for tx in transactions:
    # Guard: skip if amount is invalid
    amount = tx.get("amount")
    if amount is None or amount <= 0:
        invalid_count += 1
        continue

    # Guard: only process completed transactions
    if tx.get("status") != "completed":
        continue

    # Categorize by amount
    size = "large" if amount >= 200 else "small"

    valid_total += amount
    print("Processed tx", tx["id"], ":", amount, "(" + size + ")")

print("---")
print("Valid total:", valid_total)
print("Invalid count:", invalid_count)

Output:

Processed tx 1 : 150 (small)
Processed tx 4 : 500 (large)
---
Valid total: 650
Invalid count: 2

The loop uses guard clauses with continue to skip invalid records, so the main processing logic stays flat. Each guard handles one type of invalid data, which means changing a validation rule touches one guard. Because the checks are independent, you can test each one on its own, and the loop stays readable as a pipeline adds validation steps.

Categorizing values by range

This example uses chained comparisons for range-based validation and categorization in a data pipeline:

def analyze_metrics(metrics):
    """Analyze performance metrics with range-based categorization."""
    results = []

    for metric in metrics:
        name = metric["name"]
        value = metric["value"]

        # Categorize using chained comparisons
        if value < 0:
            status = "error"
            message = "Negative value detected"
        elif 0 <= value < 50:
            status = "critical"
            message = "Below acceptable threshold"
        elif 50 <= value < 80:
            status = "warning"
            message = "Room for improvement"
        elif 80 <= value <= 100:
            status = "healthy"
            message = "Within normal range"
        else:
            status = "error"
            message = "Value exceeds maximum"

        results.append({
            "name": name,
            "status": status,
            "message": message
        })

    return results

# Test the analyzer
metrics = [
    {"name": "CPU Usage", "value": 75},
    {"name": "Memory", "value": 92},
    {"name": "Disk Space", "value": 45},
    {"name": "Network", "value": 88},
]

for result in analyze_metrics(metrics):
    print(result["name"] + ":", result["status"], "-", result["message"])

Output:

CPU Usage: warning - Room for improvement
Memory: healthy - Within normal range
Disk Space: critical - Below acceptable threshold
Network: healthy - Within normal range

The chained comparisons make the range logic immediately clear. Reading "50 <= value < 80" is more intuitive than "value >= 50 and value < 80". This clarity is especially valuable when you return to code after months and need to quickly understand the boundaries. Python evaluates the expression efficiently, checking each comparison in sequence and stopping as soon as one fails.

Guard clauses in an API handler

Functions that handle API requests or user input have the most ways to fail, so they get the most out of guard clauses. This user registration handler is a typical case:

def register_user(request):
    """Handle user registration with comprehensive validation."""
    # Guard: request must exist
    if request is None:
        return {"error": "No request data", "code": 400}

    # Guard: required fields
    if "email" not in request:
        return {"error": "Email is required", "code": 400}

    if "password" not in request:
        return {"error": "Password is required", "code": 400}

    # Guard: email format
    email = request["email"]
    if "@" not in email or "." not in email.split("@")[-1]:
        return {"error": "Invalid email format", "code": 400}

    # Guard: password strength
    password = request["password"]
    if len(password) < 8:
        return {"error": "Password must be at least 8 characters", "code": 400}

    if password.lower() == password:
        return {"error": "Password must contain uppercase letters", "code": 400}

    # Guard: check if email already exists (simulated)
    existing_users = ["alice@example.com", "bob@example.com"]
    if email.lower() in existing_users:
        return {"error": "Email already registered", "code": 409}

    # Success case - all guards passed
    return {
        "success": True,
        "message": "User registered successfully",
        "email": email.lower(),
        "code": 201
    }

# Test with various inputs
test_requests = [
    {"email": "new@example.com", "password": "SecurePass123"},
    {"email": "invalid-email", "password": "Test1234"},
    {"email": "alice@example.com", "password": "Test1234"},
    {"email": "test@example.com", "password": "short"},
]

for req in test_requests:
    result = register_user(req)
    if result.get("success"):
        print("OK:", result["message"])
    else:
        print("Error:", result["error"])

Output:

OK: User registered successfully
Error: Invalid email format
Error: Email already registered
Error: Password must be at least 8 characters

Each guard clause handles one specific validation failure. The function reads from top to bottom like a checklist: "If no request, error. If no email, error. If email invalid, error." This structure makes it easy to add new validations or modify existing ones without affecting other parts of the function. The pattern also makes testing straightforward, as each guard clause can be tested independently with inputs designed to trigger it.

Flat control flow like this is easier to read and easier to change later.

About this lesson

This section is part of the Control Flow: Intermediate lesson on DataDriven, the home of data engineering. Lessons cover SQL, Python, Spark, data modeling and pipeline architecture, and each section pairs its explanation with worked examples and practice you run as you go.