Module 5: Common Error Patterns
Unhandled Edge Cases
Unhandled Edge Cases
Capsule overview
AI generates the "happy path" with consistently high quality. The problem is in everything else: what happens when the input is None? When the list is empty? When the user asks for page 0? When two requests arrive simultaneously at the same resource?
These are the edge cases — the situations that aren't the main flow but that inevitably occur in production. And they're responsible for ~35% of the errors in AI-generated code. AI doesn't think proactively about what can go wrong. It generates items[0] without considering that items could be empty. It divides total / count without checking that count isn't zero. It paginates with offset = (page - 1) * size without validating that page >= 1.
In this capsule you're going to train your eye to detect 7 categories of edge cases that AI typically ignores. Each one with the code AI generates, the scenario that breaks it, and the complete fix.
Category 1: Missing Null/None Handling
The problem
AI generates code that assumes values always exist. It doesn't check for None in database returns, optional fields, or search results.
Code AI generates
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
class UserProfile(BaseModel):
user_id: int
bio: str
avatar_url: str
location: str
users_db: dict[int, dict] = {
1: {
"user_id": 1,
"username": "alice",
"bio": "Developer",
"avatar_url": "https://example.com/alice.jpg",
"location": "NYC",
},
2: {
"user_id": 2,
"username": "bob",
"bio": None,
"avatar_url": None,
"location": None,
},
}
@app.get("/users/{user_id}/profile")
async def get_user_profile(user_id: int) -> dict:
user = users_db.get(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
profile = UserProfile(
user_id=user["user_id"],
bio=user["bio"],
avatar_url=user["avatar_url"],
location=user["location"],
)
greeting = f"Welcome from {profile.location.upper()}"
bio_preview = profile.bio[:50]
avatar_filename = profile.avatar_url.split("/")[-1]
return {
"profile": profile.model_dump(),
"greeting": greeting,
"bio_preview": bio_preview,
"avatar_filename": avatar_filename,
}
Why it looks good at first glance
- It checks that the user exists (the
if not userguard clause) - It uses Pydantic for structure
- The code is clean and readable
- It works perfectly with user 1 (alice)
How it breaks
# With user_id=2 (bob has None fields):
profile.location.upper()
# → AttributeError: 'NoneType' object has no attribute 'upper'
profile.bio[:50]
# → TypeError: 'NoneType' object is not subscriptable
profile.avatar_url.split("/")[-1]
# → AttributeError: 'NoneType' object has no attribute 'split'
Three different crashes with a single user. And Pydantic will validate the str fields receiving None — that also fails before reaching the endpoint logic.
The fix
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
class UserProfile(BaseModel):
user_id: int
bio: str | None = None
avatar_url: str | None = None
location: str | None = None
users_db: dict[int, dict] = {
1: {
"user_id": 1,
"username": "alice",
"bio": "Developer",
"avatar_url": "https://example.com/alice.jpg",
"location": "NYC",
},
2: {
"user_id": 2,
"username": "bob",
"bio": None,
"avatar_url": None,
"location": None,
},
}
DEFAULT_BIO = "This user has no bio."
DEFAULT_AVATAR = "default-avatar.png"
DEFAULT_LOCATION = "Location not specified"
@app.get("/users/{user_id}/profile")
async def get_user_profile(user_id: int) -> dict:
user = users_db.get(user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
profile = UserProfile(
user_id=user["user_id"],
bio=user.get("bio"),
avatar_url=user.get("avatar_url"),
location=user.get("location"),
)
location_display = profile.location.upper() if profile.location else DEFAULT_LOCATION
greeting = f"Welcome from {location_display}"
bio_preview = (profile.bio[:50] + "...") if profile.bio and len(profile.bio) > 50 else (profile.bio or DEFAULT_BIO)
avatar_filename = DEFAULT_AVATAR
if profile.avatar_url:
avatar_filename = profile.avatar_url.split("/")[-1]
return {
"profile": profile.model_dump(),
"greeting": greeting,
"bio_preview": bio_preview,
"avatar_filename": avatar_filename,
}
Key changes:
- A Pydantic model with
str | None = Nonefor the optional fields user.get("bio")instead ofuser["bio"]— doesn't crash if the key is missing- Guard clauses before each operation on potentially None fields
- Explicit default values for the display
if user is Noneinstead ofif not user(avoids false positives with empty dicts)
Warning sign
When you see .upper(), .lower(), .split(), .strip(), [:N], or any direct string operation in AI code, verify that the value can't be None.
Category 2: Empty Lists That Cause Index Errors
The problem
AI accesses items[0], items[-1], or uses max()/min() without checking that the list has elements.
Code AI generates
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class ProductStats(BaseModel):
most_expensive: float
cheapest: float
average_price: float
price_range: float
first_added: str
last_added: str
products_db: list[dict] = []
@app.get("/products/stats")
async def get_product_stats() -> ProductStats:
prices = [p["price"] for p in products_db]
return ProductStats(
most_expensive=max(prices),
cheapest=min(prices),
average_price=sum(prices) / len(prices),
price_range=max(prices) - min(prices),
first_added=products_db[0]["name"],
last_added=products_db[-1]["name"],
)
How it breaks
# With products_db = [] (empty list):
prices = [] # List comprehension over an empty list = empty list
max(prices) # → ValueError: max() arg is an empty sequence
min(prices) # → ValueError: min() arg is an empty sequence
sum(prices) / len(prices) # → ZeroDivisionError: division by zero
products_db[0] # → IndexError: list index out of range
products_db[-1] # → IndexError: list index out of range
Five different errors in a single endpoint. And an empty products_db is the initial state — this endpoint crashes from the very first request.
The fix
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
class ProductStats(BaseModel):
total_products: int
most_expensive: float | None = None
cheapest: float | None = None
average_price: float | None = None
price_range: float | None = None
first_added: str | None = None
last_added: str | None = None
products_db: list[dict] = []
@app.get("/products/stats")
async def get_product_stats() -> ProductStats:
if not products_db:
return ProductStats(total_products=0)
prices = [p["price"] for p in products_db]
return ProductStats(
total_products=len(products_db),
most_expensive=max(prices),
cheapest=min(prices),
average_price=round(sum(prices) / len(prices), 2),
price_range=max(prices) - min(prices),
first_added=products_db[0]["name"],
last_added=products_db[-1]["name"],
)
Key changes:
- A guard clause at the start: if the list is empty, it returns a valid response with
total_products=0 - All numeric fields are
float | None— they can beNonewhen there's no data - The model responds with meaningful data instead of crashing
Warning sign
When you see [0], [-1], max(), min(), or / len() in AI code, ask yourself: "What happens if the collection is empty?"
Category 3: Off-by-One in Pagination
The problem
AI generates pagination with subtle errors at the edges: page 0, the last page with fewer items, or offset calculations that skip or duplicate elements.
Code AI generates
from fastapi import FastAPI, Query
from pydantic import BaseModel
app = FastAPI()
class PaginatedResponse(BaseModel):
items: list[dict]
page: int
total_pages: int
total_items: int
all_items: list[dict] = [{"id": i, "name": f"Item {i}"} for i in range(1, 96)]
@app.get("/items")
async def list_items(
page: int = Query(default=1),
size: int = Query(default=10),
) -> PaginatedResponse:
total_items = len(all_items)
total_pages = total_items // size
offset = (page - 1) * size
items = all_items[offset:offset + size]
return PaginatedResponse(
items=items,
page=page,
total_pages=total_pages,
total_items=total_items,
)
How it breaks
# 95 items, size=10
# Error 1: total_pages is miscalculated
total_pages = 95 // 10 # = 9 (should be 10)
# Page 10 has 5 items but total_pages says it doesn't exist
# Error 2: page=0 is valid but shouldn't be
offset = (0 - 1) * 10 # = -10
all_items[-10:] # Returns the last 10 items — incorrect data with no error
# Error 3: page=-5 also "works"
offset = (-5 - 1) * 10 # = -60
all_items[-60:] # Returns items from the end — absurd data
# Error 4: page=1000 doesn't error
offset = (1000 - 1) * 10 # = 9990
all_items[9990:10000] # = [] — returns an empty list silently
# Error 5: size=0 causes ZeroDivisionError
total_pages = 95 // 0 # → ZeroDivisionError
# Error 6: size=-1 returns unexpected results
offset = (1 - 1) * (-1) # = 0
all_items[0:-1] # Returns all but the last — strange behavior
Six bugs in a "simple" pagination endpoint.
The fix
import math
from fastapi import FastAPI, Query, HTTPException
from pydantic import BaseModel
app = FastAPI()
MIN_PAGE_SIZE = 1
MAX_PAGE_SIZE = 100
DEFAULT_PAGE_SIZE = 10
class PaginatedResponse(BaseModel):
items: list[dict]
page: int
page_size: int
total_pages: int
total_items: int
has_next: bool
has_previous: bool
all_items: list[dict] = [{"id": i, "name": f"Item {i}"} for i in range(1, 96)]
@app.get("/items")
async def list_items(
page: int = Query(default=1, ge=1, description="Page number (1-indexed)"),
size: int = Query(
default=DEFAULT_PAGE_SIZE,
ge=MIN_PAGE_SIZE,
le=MAX_PAGE_SIZE,
description="Items per page",
),
) -> PaginatedResponse:
total_items = len(all_items)
total_pages = math.ceil(total_items / size) if total_items > 0 else 0
if page > total_pages and total_pages > 0:
raise HTTPException(
status_code=404,
detail=f"Page {page} not found. Total pages: {total_pages}",
)
offset = (page - 1) * size
items = all_items[offset:offset + size]
return PaginatedResponse(
items=items,
page=page,
page_size=size,
total_pages=total_pages,
total_items=total_items,
has_next=page < total_pages,
has_previous=page > 1,
)
Key changes:
ge=1in Query validates that page >= 1 (FastAPI returns 422 automatically)ge=1, le=100in size prevents invalid and abusive valuesmath.ceil()instead of//to calculate total_pages correctly- Explicit validation of out-of-range pages
has_nextandhas_previousmake client navigation easier
Warning sign
In pagination, look for: (1) // instead of math.ceil() for total_pages, (2) lack of validation on page and size, (3) lack of handling when page exceeds the total.
Category 4: Division by Zero
The problem
AI generates divisions without checking that the divisor isn't zero. This includes averages, percentages, proportional distribution, and normalization.
Code AI generates
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class TeamStats(BaseModel):
team_name: str
wins: int
losses: int
draws: int
win_rate: float
points_per_game: float
goal_ratio: float
@app.get("/teams/{team_id}/stats")
async def get_team_stats(team_id: int) -> TeamStats:
team = get_team_from_db(team_id)
total_games = team["wins"] + team["losses"] + team["draws"]
win_rate = team["wins"] / total_games
points_per_game = team["total_points"] / total_games
goal_ratio = team["goals_for"] / team["goals_against"]
return TeamStats(
team_name=team["name"],
wins=team["wins"],
losses=team["losses"],
draws=team["draws"],
win_rate=round(win_rate, 3),
points_per_game=round(points_per_game, 2),
goal_ratio=round(goal_ratio, 2),
)
def get_team_from_db(team_id: int) -> dict:
return {
"name": "New Team",
"wins": 0, "losses": 0, "draws": 0,
"total_points": 0,
"goals_for": 5, "goals_against": 0,
}
How it breaks
# New team (0 games):
total_games = 0 + 0 + 0 # = 0
win_rate = 0 / 0 # → ZeroDivisionError
# Team with 0 goals against:
goal_ratio = 5 / 0 # → ZeroDivisionError
The fix
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class TeamStats(BaseModel):
team_name: str
wins: int
losses: int
draws: int
total_games: int
win_rate: float | None = None
points_per_game: float | None = None
goal_ratio: float | None = None
def safe_divide(numerator: float, denominator: float) -> float | None:
"""Returns None if the divisor is zero."""
if denominator == 0:
return None
return numerator / denominator
@app.get("/teams/{team_id}/stats")
async def get_team_stats(team_id: int) -> TeamStats:
team = get_team_from_db(team_id)
total_games = team["wins"] + team["losses"] + team["draws"]
win_rate = safe_divide(team["wins"], total_games)
ppg = safe_divide(team["total_points"], total_games)
goal_ratio = safe_divide(team["goals_for"], team["goals_against"])
return TeamStats(
team_name=team["name"],
wins=team["wins"],
losses=team["losses"],
draws=team["draws"],
total_games=total_games,
win_rate=round(win_rate, 3) if win_rate is not None else None,
points_per_game=round(ppg, 2) if ppg is not None else None,
goal_ratio=round(goal_ratio, 2) if goal_ratio is not None else None,
)
def get_team_from_db(team_id: int) -> dict:
return {
"name": "New Team",
"wins": 0, "losses": 0, "draws": 0,
"total_points": 0,
"goals_for": 5, "goals_against": 0,
}
Key changes:
- The
safe_dividehelper returnsNoneinstead of crashing - All calculated fields are
float | None— the API responds withnullwhen there isn't enough data total_gamesis exposed in the response to give context to the client- The frontend can show "N/A" or "No data" when it receives
null
Warning sign
Look for / in AI code. If the divisor is a calculated value or comes from user data, verify that there's a guard against zero.
Category 5: Concurrent Access Problems
The problem
AI generates code that uses shared mutable state (dictionaries, lists in memory) without considering that multiple requests can access it simultaneously.
Code AI generates
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
inventory: dict[str, int] = {
"laptop": 5,
"mouse": 100,
"keyboard": 50,
}
class PurchaseRequest(BaseModel):
product: str
quantity: int
class PurchaseResponse(BaseModel):
product: str
quantity: int
remaining_stock: int
@app.post("/purchase")
async def purchase_product(request: PurchaseRequest) -> PurchaseResponse:
if request.product not in inventory:
raise HTTPException(status_code=404, detail="Product not found")
current_stock = inventory[request.product]
if current_stock < request.quantity:
raise HTTPException(
status_code=400,
detail=f"Insufficient stock. Available: {current_stock}",
)
inventory[request.product] = current_stock - request.quantity
return PurchaseResponse(
product=request.product,
quantity=request.quantity,
remaining_stock=inventory[request.product],
)
How it breaks
Race condition with 2 simultaneous requests for the last laptop:
Timeline:
Request A: current_stock = inventory["laptop"] → 1
Request B: current_stock = inventory["laptop"] → 1
Request A: 1 >= 1? Yes → continues
Request B: 1 >= 1? Yes → continues
Request A: inventory["laptop"] = 1 - 1 = 0
Request B: inventory["laptop"] = 1 - 1 = 0
Result: 2 laptops sold when there was only 1
Final stock: 0 (should be -1 or the second request should fail)
The TOCTOU pattern (Time of Check to Time of Use): between the moment you check the stock and the moment you update it, another request can change the value.
The fix
import asyncio
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
inventory: dict[str, int] = {
"laptop": 5,
"mouse": 100,
"keyboard": 50,
}
inventory_locks: dict[str, asyncio.Lock] = {
product: asyncio.Lock() for product in inventory
}
class PurchaseRequest(BaseModel):
product: str
quantity: int
class PurchaseResponse(BaseModel):
product: str
quantity: int
remaining_stock: int
@app.post("/purchase")
async def purchase_product(request: PurchaseRequest) -> PurchaseResponse:
if request.product not in inventory:
raise HTTPException(status_code=404, detail="Product not found")
lock = inventory_locks.get(request.product)
if lock is None:
raise HTTPException(status_code=404, detail="Product not found")
async with lock:
current_stock = inventory[request.product]
if current_stock < request.quantity:
raise HTTPException(
status_code=400,
detail=f"Insufficient stock. Available: {current_stock}",
)
inventory[request.product] = current_stock - request.quantity
return PurchaseResponse(
product=request.product,
quantity=request.quantity,
remaining_stock=inventory[request.product],
)
Key changes:
- An
asyncio.Lock()per product guarantees that only one request modifies the stock at a time async with lockensures the lock is released even if there's an exception- The check and update are within the same lock — there's no window for a race condition
Important note: In real production, you'd use a database with transactions (SELECT ... FOR UPDATE) instead of in-memory locks. This pattern is for in-memory stores during development.
Warning sign
When you see shared mutable state (a global dict, a global list) modified in async endpoints, ask yourself: "What happens if two requests arrive at the same time?"
Category 6: Unicode and Encoding Problems
The problem
AI generates code that assumes ASCII for all text operations. It fails with emojis, accented characters, CJK languages, or special characters.
Code AI generates
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class UsernameValidation(BaseModel):
username: str
is_valid: bool
display_length: int
slug: str
def validate_and_process_username(username: str) -> UsernameValidation:
is_valid = (
len(username) >= 3
and len(username) <= 20
and username.isalnum()
)
slug = username.lower().replace(" ", "-")
return UsernameValidation(
username=username,
is_valid=is_valid,
display_length=len(username),
slug=slug,
)
@app.get("/validate-username/{username}")
async def validate_username(username: str) -> UsernameValidation:
return validate_and_process_username(username)
How it breaks
# Usernames with non-ASCII characters:
validate_and_process_username("José")
# is_valid = False → isalnum() returns True, but
# len("José") = 4, which is fine, BUT
# if you truncate to 3 chars: "Jos" loses the é
validate_and_process_username("田中太郎")
# len("田中太郎") = 4 → seems short
# But the display width is 8 (each CJK takes 2 columns)
# The slug will be "田中太郎" — is it valid in a URL?
validate_and_process_username("user👨💻name")
# len("user👨💻name") = 12 → INCORRECT
# The emoji "👨💻" is a ZWJ sequence of 3 code points
# The "visual" length is 1 emoji, but Python counts 5 code points
validate_and_process_username("café")
# The é can be 1 code point (U+00E9) or 2 (e + U+0301)
# Same visual appearance, different len()
The fix
import re
import unicodedata
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
USERNAME_PATTERN = re.compile(r"^[a-zA-Z0-9_\-]+$")
MIN_LENGTH = 3
MAX_LENGTH = 20
class UsernameValidation(BaseModel):
username: str
normalized: str
is_valid: bool
errors: list[str]
slug: str
def normalize_text(text: str) -> str:
"""Normalizes Unicode to NFC (canonical composed form)."""
return unicodedata.normalize("NFC", text)
def validate_and_process_username(username: str) -> UsernameValidation:
normalized = normalize_text(username.strip())
errors: list[str] = []
if len(normalized) < MIN_LENGTH:
errors.append(f"Minimum {MIN_LENGTH} characters required")
if len(normalized) > MAX_LENGTH:
errors.append(f"Maximum {MAX_LENGTH} characters allowed")
if not USERNAME_PATTERN.match(normalized):
errors.append("Only letters, numbers, hyphens, and underscores allowed")
slug = re.sub(r"[^a-zA-Z0-9]+", "-", normalized.lower()).strip("-")
return UsernameValidation(
username=username,
normalized=normalized,
is_valid=len(errors) == 0,
errors=errors,
slug=slug if slug else "invalid",
)
@app.get("/validate-username/{username}")
async def validate_username(username: str) -> UsernameValidation:
return validate_and_process_username(username)
Key changes:
unicodedata.normalize("NFC")converts composed characters to canonical form- An explicit regex defines which characters are valid (instead of
isalnum(), which accepts Unicode) - Descriptive errors instead of just True/False
- A slug generated with a regex that replaces non-alphanumeric characters
Warning sign
When you see len() for text-length validation, isalnum(), isalpha(), or direct string manipulation in AI code, verify how it handles Unicode.
Category 7: Boundary Conditions — Extreme Values
The problem
AI doesn't consider what happens with values at the limits: very large integers, very long strings, dates in the distant past, or negative values where only positives are expected.
Code AI generates
from fastapi import FastAPI
from pydantic import BaseModel
from datetime import datetime, timedelta
app = FastAPI()
class DiscountRequest(BaseModel):
original_price: float
discount_percent: float
quantity: int
class DiscountResponse(BaseModel):
original_price: float
discount_percent: float
discounted_price: float
total: float
savings: float
@app.post("/calculate-discount")
async def calculate_discount(request: DiscountRequest) -> DiscountResponse:
discounted_price = request.original_price * (1 - request.discount_percent / 100)
total = discounted_price * request.quantity
savings = (request.original_price - discounted_price) * request.quantity
return DiscountResponse(
original_price=request.original_price,
discount_percent=request.discount_percent,
discounted_price=round(discounted_price, 2),
total=round(total, 2),
savings=round(savings, 2),
)
class SubscriptionRequest(BaseModel):
months: int
start_date: str
@app.post("/create-subscription")
async def create_subscription(request: SubscriptionRequest) -> dict:
start = datetime.fromisoformat(request.start_date)
end = start + timedelta(days=request.months * 30)
return {
"start_date": start.isoformat(),
"end_date": end.isoformat(),
"months": request.months,
}
How it breaks
# Discount with extreme values:
# discount_percent = 150 → negative price
discounted_price = 100 * (1 - 150/100) # = -50.0 → They pay you to buy!
# discount_percent = -50 → surcharge
discounted_price = 100 * (1 - (-50)/100) # = 150.0 → The discount raises the price!
# original_price = -100 → negative price
total = -100 * 0.9 * 5 # = -450.0 → A negative total?
# quantity = 999999999
total = 100 * 0.9 * 999999999 # = 89999999910.0 → Almost 90 billion?
# Subscription with extreme values:
# months = 999999
end = start + timedelta(days=999999 * 30) # An 82,000-year subscription
# months = -12
end = start + timedelta(days=-12 * 30) # An end date before the start date
# months = 0
# The subscription starts and ends on the same day — does it make sense?
# start_date = "1066-10-14"
# Do we accept subscriptions since the Battle of Hastings?
The fix
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field, model_validator
from datetime import datetime, timedelta, date
app = FastAPI()
MAX_DISCOUNT_PERCENT = 100.0
MAX_QUANTITY = 10_000
MAX_PRICE = 1_000_000.0
MAX_SUBSCRIPTION_MONTHS = 120
class DiscountRequest(BaseModel):
original_price: float = Field(gt=0, le=MAX_PRICE)
discount_percent: float = Field(ge=0, le=MAX_DISCOUNT_PERCENT)
quantity: int = Field(gt=0, le=MAX_QUANTITY)
class DiscountResponse(BaseModel):
original_price: float
discount_percent: float
discounted_price: float
total: float
savings: float
@app.post("/calculate-discount")
async def calculate_discount(request: DiscountRequest) -> DiscountResponse:
discounted_price = request.original_price * (1 - request.discount_percent / 100)
total = discounted_price * request.quantity
savings = (request.original_price - discounted_price) * request.quantity
return DiscountResponse(
original_price=request.original_price,
discount_percent=request.discount_percent,
discounted_price=round(discounted_price, 2),
total=round(total, 2),
savings=round(savings, 2),
)
class SubscriptionRequest(BaseModel):
months: int = Field(gt=0, le=MAX_SUBSCRIPTION_MONTHS)
start_date: date
@model_validator(mode="after")
def validate_start_date(self):
if self.start_date < date.today():
raise ValueError("Start date cannot be in the past")
max_future = date.today() + timedelta(days=365)
if self.start_date > max_future:
raise ValueError("Start date cannot be more than 1 year in the future")
return self
@app.post("/create-subscription")
async def create_subscription(request: SubscriptionRequest) -> dict:
end_date = request.start_date + timedelta(days=request.months * 30)
return {
"start_date": request.start_date.isoformat(),
"end_date": end_date.isoformat(),
"months": request.months,
}
Key changes:
Field(gt=0, le=MAX_PRICE)— Pydantic validates ranges automatically, returns 422 if out of range- Explicit constants define the business limits
- The
model_validatorchecks thatstart_dateisn't in the past or too far in the future dateinstead ofstrfor start_date — Pydantic parses and validates it automatically
Warning sign
When you see numeric fields with no restrictions (int, float without Field(ge=..., le=...)) in Pydantic models of AI code, verify what happens with extreme values: negatives, zero, and very large.
Summary of Warning Signs
Quick edge case checklist:
☐ .upper()/.split()/[:N] on values that can be None
→ Add a guard clause or default
☐ [0], [-1], max(), min() on potentially empty collections
→ Check the length before accessing
☐ Pagination with // instead of math.ceil()
→ Use math.ceil() and validate page/size with ge/le
☐ Division where the divisor can be 0
→ A safe divide helper or a guard clause
☐ Shared mutable state in async endpoints
→ Use locks or move to a database with transactions
☐ len() and isalnum() to validate user text
→ Normalize Unicode and use an explicit regex
☐ Numeric fields without Field(ge=..., le=...)
→ Define valid business ranges
Connection to the Project
The capstone project in module 8 contains 3-4 unhandled edge cases. Look specifically for:
- ✅ At least one access to
[0]without checking for an empty list - ✅ At least one division without a guard against zero
- ✅ Pagination with errors at the edges
- ✅ Fields without range validation
The pattern you practice here — "what happens with the empty/null/extreme case?" — is the question that saves you from bugs in production.
Troubleshooting
"Should I handle ALL possible edge cases?"
No. Apply the Pareto principle: handle the edge cases that are likely in your context. An empty list in a new products endpoint is likely. An integer overflow in Python (where ints are arbitrarily large) is extremely unlikely.
"Doesn't Pydantic handle None automatically?"
Pydantic validates types, but only if you declare the fields as str | None. If you declare str (without | None), Pydantic rejects None with a 422 error. The problem is when AI declares str but the data can be None.
"Are async locks necessary in development?"
In development with uvicorn --reload (1 worker), race conditions are rare but possible with simultaneous requests. In production with multiple workers, the in-memory state isn't shared between workers anyway — you need a database.
"How do I handle edge cases in functions that are already in production?"
Add guards gradually: first the critical ones (null/empty that cause crashes), then the incorrect-data ones (ranges), finally the UX ones (clear messages). Each change should have a test that verifies the edge case.
"When do I use None vs default values?"
Use None when the absence of data is meaningful information ("we don't know"). Use defaults when there's a sensible value ("if you don't specify, we assume this"). Don't use empty strings as "none" — that hides the absence of data.
Exercises
Exercise 1: Identify edge cases in a search function
Find all the unhandled edge cases in this function:
from fastapi import FastAPI
app = FastAPI()
products: list[dict] = [
{"id": 1, "name": "Laptop Pro", "price": 999.99, "tags": ["electronics", "computers"]},
{"id": 2, "name": "Mouse Wireless", "price": 29.99, "tags": ["electronics", "accessories"]},
{"id": 3, "name": "Desk Lamp", "price": 45.00, "tags": ["home", "lighting"]},
]
@app.get("/search")
async def search_products(q: str, min_price: float = 0, max_price: float = 99999) -> dict:
results = [
p for p in products
if q.lower() in p["name"].lower()
and min_price <= p["price"] <= max_price
]
return {
"query": q,
"results": results,
"best_match": results[0],
"price_range": {
"min": min(p["price"] for p in results),
"max": max(p["price"] for p in results),
},
"average_price": sum(p["price"] for p in results) / len(results),
}
See solution
Unhandled edge cases:
- Empty
q(/search?q=) → Matches everything, probably not the intent - No results →
results[0]crashes withIndexError - No results →
min()andmax()crash withValueError - No results →
/ len(results)crashes withZeroDivisionError min_price > max_price→ Returns an empty list with no warning- Negative
min_priceormax_price→ Accepted but meaningless qwith special characters → Works but could cause problems in regex if the implementation is changed
Fix:
from fastapi import FastAPI, Query, HTTPException
app = FastAPI()
@app.get("/search")
async def search_products(
q: str = Query(min_length=1, max_length=100),
min_price: float = Query(default=0, ge=0),
max_price: float = Query(default=99999, ge=0),
) -> dict:
if min_price > max_price:
raise HTTPException(
status_code=400,
detail="min_price cannot be greater than max_price",
)
results = [
p for p in products
if q.lower() in p["name"].lower()
and min_price <= p["price"] <= max_price
]
if not results:
return {
"query": q,
"results": [],
"total": 0,
"best_match": None,
"price_range": None,
"average_price": None,
}
return {
"query": q,
"results": results,
"total": len(results),
"best_match": results[0],
"price_range": {
"min": min(p["price"] for p in results),
"max": max(p["price"] for p in results),
},
"average_price": round(sum(p["price"] for p in results) / len(results), 2),
}
Exercise 2: Add boundary validation
This model accepts any value. Add validation of reasonable ranges:
from pydantic import BaseModel
class EventRegistration(BaseModel):
event_name: str
attendees: int
ticket_price: float
discount_code: str
max_capacity: int
See solution
from pydantic import BaseModel, Field, model_validator
class EventRegistration(BaseModel):
event_name: str = Field(min_length=1, max_length=200)
attendees: int = Field(gt=0, le=100_000)
ticket_price: float = Field(ge=0, le=50_000)
discount_code: str = Field(default="", max_length=50)
max_capacity: int = Field(gt=0, le=500_000)
@model_validator(mode="after")
def validate_attendees_vs_capacity(self):
if self.attendees > self.max_capacity:
raise ValueError(
f"Attendees ({self.attendees}) cannot exceed max capacity ({self.max_capacity})"
)
return self
Each field has explicit ranges, and the model_validator checks the logical relationship between attendees and max_capacity.
Exercise 3: Fix a race condition
This endpoint increments a counter. What happens with concurrent requests? Fix the problem.
from fastapi import FastAPI
app = FastAPI()
counters: dict[str, int] = {"visits": 0, "api_calls": 0}
@app.post("/increment/{counter_name}")
async def increment_counter(counter_name: str) -> dict:
current = counters[counter_name]
counters[counter_name] = current + 1
return {"counter": counter_name, "value": counters[counter_name]}
See solution
import asyncio
from fastapi import FastAPI, HTTPException
app = FastAPI()
counters: dict[str, int] = {"visits": 0, "api_calls": 0}
counter_locks: dict[str, asyncio.Lock] = {
name: asyncio.Lock() for name in counters
}
@app.post("/increment/{counter_name}")
async def increment_counter(counter_name: str) -> dict:
if counter_name not in counters:
raise HTTPException(status_code=404, detail=f"Counter '{counter_name}' not found")
async with counter_locks[counter_name]:
counters[counter_name] += 1
return {"counter": counter_name, "value": counters[counter_name]}
Two fixes:
- A lock per counter to avoid the race condition
- Validation that the counter exists (the original crashes with
KeyErrorfor unknown names)
Exercise 4: Handle an empty list in an aggregation
Fix this function so it doesn't crash with empty data:
def get_sales_summary(sales: list[dict]) -> dict:
total_revenue = sum(s["amount"] for s in sales)
average_sale = total_revenue / len(sales)
largest_sale = max(sales, key=lambda s: s["amount"])
smallest_sale = min(sales, key=lambda s: s["amount"])
return {
"total_revenue": total_revenue,
"average_sale": average_sale,
"num_sales": len(sales),
"largest_sale": largest_sale,
"smallest_sale": smallest_sale,
"top_product": max(
set(s["product"] for s in sales),
key=lambda p: sum(1 for s in sales if s["product"] == p),
),
}
See solution
from collections import Counter
def get_sales_summary(sales: list[dict]) -> dict:
if not sales:
return {
"total_revenue": 0,
"average_sale": None,
"num_sales": 0,
"largest_sale": None,
"smallest_sale": None,
"top_product": None,
}
total_revenue = sum(s["amount"] for s in sales)
average_sale = round(total_revenue / len(sales), 2)
largest_sale = max(sales, key=lambda s: s["amount"])
smallest_sale = min(sales, key=lambda s: s["amount"])
product_counts = Counter(s["product"] for s in sales)
top_product = product_counts.most_common(1)[0][0]
return {
"total_revenue": total_revenue,
"average_sale": average_sale,
"num_sales": len(sales),
"largest_sale": largest_sale,
"smallest_sale": smallest_sale,
"top_product": top_product,
}
A guard clause at the start handles the empty case. Also, Counter.most_common() is more efficient and readable than the nested max(set(...), key=...).
Summary
- Null/None is the most common edge case: check before operating on optional fields
- Empty lists cause
IndexError,ValueError(max/min), andZeroDivisionError(/ len) - Pagination has subtle errors:
//vsmath.ceil(), lack of page/size validation - Division by zero appears in averages, percentages, ratios — use a
safe_dividehelper - Concurrency causes race conditions in shared mutable state — use locks or DB transactions
- Unicode breaks
len(),isalnum(), and direct string manipulation — normalize with NFC - Boundary conditions require
Field(ge=..., le=...)— define the business limits explicitly - The key question is always: "What happens with the empty/null/extreme case?"
Additional resources
- The Billion Dollar Mistake — Tony Hoare - A talk by the inventor of null on why it was a design mistake
- Pydantic Field Validators - Documentation of Field constraints in Pydantic v2
- Python asyncio Synchronization - Locks, Events, and Semaphores for async code
- Unicode in Python — Pragmatic Unicode - The definitive Unicode guide for Python developers
- FastAPI Query Parameters Validation - Automatic validation with Query() in FastAPI
Next capsule: Typical Security Holes — vulnerabilities AI generates that look like correct code.
Debugging & Code Review with Claude Code — Module 5, Capsule 03 Claude Code Agentic Development Path — Guide #6 of 11