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 user guard 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 = None for the optional fields
  • user.get("bio") instead of user["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 None instead of if 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 be None when 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=1 in Query validates that page >= 1 (FastAPI returns 422 automatically)
  • ge=1, le=100 in size prevents invalid and abusive values
  • math.ceil() instead of // to calculate total_pages correctly
  • Explicit validation of out-of-range pages
  • has_next and has_previous make 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_divide helper returns None instead of crashing
  • All calculated fields are float | None — the API responds with null when there isn't enough data
  • total_games is 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 lock ensures 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_validator checks that start_date isn't in the past or too far in the future
  • date instead of str for 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:

  1. Empty q (/search?q=) → Matches everything, probably not the intent
  2. No results → results[0] crashes with IndexError
  3. No results → min() and max() crash with ValueError
  4. No results → / len(results) crashes with ZeroDivisionError
  5. min_price > max_price → Returns an empty list with no warning
  6. Negative min_price or max_price → Accepted but meaningless
  7. q with 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:

  1. A lock per counter to avoid the race condition
  2. Validation that the counter exists (the original crashes with KeyError for 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), and ZeroDivisionError (/ len)
  • Pagination has subtle errors: // vs math.ceil(), lack of page/size validation
  • Division by zero appears in averages, percentages, ratios — use a safe_divide helper
  • 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

  1. The Billion Dollar Mistake — Tony Hoare - A talk by the inventor of null on why it was a design mistake
  2. Pydantic Field Validators - Documentation of Field constraints in Pydantic v2
  3. Python asyncio Synchronization - Locks, Events, and Semaphores for async code
  4. Unicode in Python — Pragmatic Unicode - The definitive Unicode guide for Python developers
  5. 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