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:
- None / null values: Missing or unset data that causes crashes when accessed
- Empty collections: Collections with zero elements, including empty strings
- Zero values: Division by zero, zero-length strings, index zero edge cases
- Negative numbers: When only positive values are expected by the logic
- Boundary values: The first and last elements of a sequence, or a value sitting exactly at its min or max limit
- Invalid types: A string where an integer was expected, or vice versa
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: # RiskyOutput:
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.