Module 6: Debugging with Claude Code

Exercise: Real Debugging

Exercise: Real Debugging

Capsule overview

This is the integrative exercise for module 6. So far you've learned log analysis, stack trace interpretation, the systematic debugging process, and when Claude Code doesn't help. Now you're going to apply it all together in a FastAPI application with 5 bugs of different types.

It's not an artificial exercise. The bugs you'll find are the kind AI actually generates: a runtime error from not handling None, a logic error where the code does the opposite of what it should, an edge case that crashes with valid but unexpected inputs, a performance bug from inefficient queries, and a silent error where the data gets corrupted without raising exceptions.

For each bug, you must follow the process: reproduce → isolate → diagnose → fix → verify. And you must document your process — that's as important as the fix.


The Application: TaskFlow API

TaskFlow is a task-management API with users, categories, and statistics. It has 4 main files. Read all the code before you start debugging.

File 1: models.py

from pydantic import BaseModel, Field, field_validator
from typing import Optional
from datetime import datetime, date
from enum import Enum


class Priority(str, Enum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"
    CRITICAL = "critical"


class TaskStatus(str, Enum):
    PENDING = "pending"
    IN_PROGRESS = "in_progress"
    COMPLETED = "completed"
    CANCELLED = "cancelled"


class UserCreate(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)
    email: str
    role: str = "member"


class UserResponse(BaseModel):
    id: int
    name: str
    email: str
    role: str
    created_at: datetime


class CategoryCreate(BaseModel):
    name: str = Field(..., min_length=1, max_length=50)
    color: str = "#3498db"


class CategoryResponse(BaseModel):
    id: int
    name: str
    color: str
    task_count: int = 0


class TaskCreate(BaseModel):
    title: str = Field(..., min_length=1, max_length=200)
    description: Optional[str] = None
    priority: Priority = Priority.MEDIUM
    category_id: Optional[int] = None
    assignee_id: Optional[int] = None
    due_date: Optional[str] = None

    @field_validator('due_date')
    @classmethod
    def validate_due_date(cls, v):
        if v is not None:
            parsed = datetime.strptime(v, "%Y-%m-%d")
            return v
        return v


class TaskUpdate(BaseModel):
    title: Optional[str] = None
    description: Optional[str] = None
    priority: Optional[Priority] = None
    status: Optional[TaskStatus] = None
    category_id: Optional[int] = None
    assignee_id: Optional[int] = None
    due_date: Optional[str] = None


class TaskResponse(BaseModel):
    id: int
    title: str
    description: Optional[str]
    priority: str
    status: str
    category: Optional[str]
    assignee: Optional[str]
    due_date: Optional[str]
    created_at: datetime
    updated_at: Optional[datetime]

File 2: database.py

from datetime import datetime
from typing import Optional


users_db: dict[int, dict] = {}
categories_db: dict[int, dict] = {}
tasks_db: dict[int, dict] = {}

next_user_id = 1
next_category_id = 1
next_task_id = 1


def seed_data():
    """Populate the database with example data."""
    global next_user_id, next_category_id, next_task_id

    # Users
    users = [
        {"name": "Ana García", "email": "ana@taskflow.com", "role": "admin"},
        {"name": "Carlos López", "email": "carlos@taskflow.com", "role": "member"},
        {"name": "María Torres", "email": "maria@taskflow.com", "role": "member"},
    ]
    for u in users:
        users_db[next_user_id] = {
            **u, "id": next_user_id, "created_at": datetime.utcnow()
        }
        next_user_id += 1

    # Categories
    categories = [
        {"name": "Backend", "color": "#e74c3c"},
        {"name": "Frontend", "color": "#3498db"},
        {"name": "DevOps", "color": "#2ecc71"},
    ]
    for c in categories:
        categories_db[next_category_id] = {
            **c, "id": next_category_id
        }
        next_category_id += 1

    # Tasks
    tasks = [
        {
            "title": "Implement JWT authentication",
            "description": "Add login/register with JWT tokens",
            "priority": "high",
            "status": "in_progress",
            "category_id": 1,
            "assignee_id": 1,
            "due_date": "2026-03-20",
        },
        {
            "title": "Design dashboard",
            "description": "Create mockups of the main dashboard",
            "priority": "medium",
            "status": "pending",
            "category_id": 2,
            "assignee_id": 2,
            "due_date": "2026-03-25",
        },
        {
            "title": "Set up CI/CD",
            "description": "GitHub Actions pipeline",
            "priority": "high",
            "status": "completed",
            "category_id": 3,
            "assignee_id": 1,
            "due_date": "2026-03-10",
        },
        {
            "title": "Optimize queries",
            "description": None,
            "priority": "low",
            "status": "pending",
            "category_id": 1,
            "assignee_id": None,
            "due_date": None,
        },
        {
            "title": "Review pull requests",
            "description": "The team's pending PRs",
            "priority": "medium",
            "status": "pending",
            "category_id": None,
            "assignee_id": 3,
            "due_date": "2026-03-15",
        },
    ]
    for t in tasks:
        tasks_db[next_task_id] = {
            **t,
            "id": next_task_id,
            "created_at": datetime.utcnow(),
            "updated_at": None,
        }
        next_task_id += 1

File 3: services.py

from datetime import datetime
from typing import Optional
from database import (
    users_db, categories_db, tasks_db,
    next_user_id, next_category_id, next_task_id,
)


class UserService:
    def get_all(self) -> list[dict]:
        return list(users_db.values())

    def get_by_id(self, user_id: int) -> Optional[dict]:
        return users_db.get(user_id)

    def create(self, data: dict) -> dict:
        global next_user_id
        user = {
            "id": next_user_id,
            "name": data["name"],
            "email": data["email"],
            "role": data.get("role", "member"),
            "created_at": datetime.utcnow(),
        }
        users_db[next_user_id] = user
        next_user_id += 1
        return user


class CategoryService:
    def get_all(self) -> list[dict]:
        return list(categories_db.values())

    def get_by_id(self, cat_id: int) -> Optional[dict]:
        return categories_db.get(cat_id)

    def get_with_task_count(self) -> list[dict]:
        result = []
        for cat in categories_db.values():
            count = 0
            for task in tasks_db.values():
                if task["category_id"] == cat["id"]:
                    count += 1
            result.append({**cat, "task_count": count})
        return result


class TaskService:
    def get_all(
        self,
        status: Optional[str] = None,
        priority: Optional[str] = None,
        assignee_id: Optional[int] = None,
    ) -> list[dict]:
        tasks = list(tasks_db.values())

        if status:
            tasks = [t for t in tasks if t["status"] == status]
        if priority:
            tasks = [t for t in tasks if t["priority"] == priority]
        if assignee_id:
            tasks = [t for t in tasks if t["assignee_id"] == assignee_id]

        return tasks

    def get_by_id(self, task_id: int) -> Optional[dict]:
        return tasks_db.get(task_id)

    def create(self, data: dict) -> dict:
        global next_task_id
        task = {
            "id": next_task_id,
            "title": data["title"],
            "description": data.get("description"),
            "priority": data.get("priority", "medium"),
            "status": "pending",
            "category_id": data.get("category_id"),
            "assignee_id": data.get("assignee_id"),
            "due_date": data.get("due_date"),
            "created_at": datetime.utcnow(),
            "updated_at": None,
        }
        tasks_db[next_task_id] = task
        next_task_id += 1
        return task

    def update(self, task_id: int, data: dict) -> Optional[dict]:
        task = tasks_db.get(task_id)
        if not task:
            return None

        for key, value in data.items():
            if value is not None:
                task[key] = value

        task["updated_at"] = datetime.utcnow()
        return task

    def delete(self, task_id: int) -> bool:
        if task_id in tasks_db:
            del tasks_db[task_id]
            return True
        return False

    def get_stats(self) -> dict:
        all_tasks = list(tasks_db.values())
        total = len(all_tasks)

        by_status = {}
        for task in all_tasks:
            status = task["status"]
            by_status[status] = by_status.get(status, 0) + 1

        by_priority = {}
        for task in all_tasks:
            priority = task["priority"]
            by_priority[priority] = by_priority.get(priority, 0) + 1

        # Calculate completion rate
        completed = by_status.get("completed", 0)
        active = by_status.get("active", 0)
        completion_rate = completed / active * 100

        # Overdue tasks
        overdue = []
        today = datetime.utcnow().strftime("%Y-%m-%d")
        for task in all_tasks:
            if task["due_date"] and task["due_date"] < today:
                if task["status"] not in ["completed", "cancelled"]:
                    overdue.append(task["title"])

        return {
            "total": total,
            "by_status": by_status,
            "by_priority": by_priority,
            "completion_rate": completion_rate,
            "overdue_tasks": overdue,
            "overdue_count": len(overdue),
        }

    def get_user_workload(self) -> list[dict]:
        workload = {}
        for task in tasks_db.values():
            uid = task["assignee_id"]
            if uid not in workload:
                user = users_db.get(uid)
                workload[uid] = {
                    "user_id": uid,
                    "user_name": user["name"],
                    "total_tasks": 0,
                    "pending": 0,
                    "in_progress": 0,
                    "completed": 0,
                }
            workload[uid]["total_tasks"] += 1
            status = task["status"]
            if status in workload[uid]:
                workload[uid][status] += 1

        return sorted(
            workload.values(),
            key=lambda x: x["total_tasks"],
            reverse=True
        )

    def search(self, query: str) -> list[dict]:
        results = []
        query_lower = query.lower()

        for task in tasks_db.values():
            if query_lower in task["title"].lower():
                results.append(task)
            elif query_lower in task["description"].lower():
                results.append(task)

        return results

File 4: main.py

from fastapi import FastAPI, HTTPException, Query
from typing import Optional
from models import (
    UserCreate, UserResponse,
    CategoryCreate, CategoryResponse,
    TaskCreate, TaskUpdate, TaskResponse,
)
from services import UserService, CategoryService, TaskService
from database import seed_data, users_db, categories_db

app = FastAPI(title="TaskFlow API", version="1.0.0")

user_service = UserService()
category_service = CategoryService()
task_service = TaskService()


@app.on_event("startup")
async def startup():
    seed_data()


# --- Users ---

@app.get("/api/users")
async def list_users():
    return user_service.get_all()


@app.post("/api/users", status_code=201)
async def create_user(user: UserCreate):
    new_user = user_service.create(user.model_dump())
    return new_user


# --- Categories ---

@app.get("/api/categories")
async def list_categories():
    return category_service.get_with_task_count()


# --- Tasks ---

@app.get("/api/tasks")
async def list_tasks(
    status: Optional[str] = None,
    priority: Optional[str] = None,
    assignee_id: Optional[int] = None,
):
    tasks = task_service.get_all(status, priority, assignee_id)
    result = []
    for task in tasks:
        category_name = None
        if task["category_id"]:
            cat = categories_db.get(task["category_id"])
            category_name = cat["name"]

        assignee_name = None
        if task["assignee_id"]:
            user = users_db.get(task["assignee_id"])
            assignee_name = user["name"]

        result.append({
            **task,
            "category": category_name,
            "assignee": assignee_name,
        })
    return result


@app.post("/api/tasks", status_code=201)
async def create_task(task: TaskCreate):
    if task.category_id:
        cat = category_service.get_by_id(task.category_id)
        if not cat:
            raise HTTPException(status_code=404, detail="Category not found")

    if task.assignee_id:
        user = user_service.get_by_id(task.assignee_id)
        if not user:
            raise HTTPException(status_code=404, detail="User not found")

    new_task = task_service.create(task.model_dump())
    return new_task


@app.get("/api/tasks/stats")
async def get_stats():
    return task_service.get_stats()


@app.get("/api/tasks/search")
async def search_tasks(q: str = Query(..., min_length=1)):
    return task_service.search(q)


@app.get("/api/tasks/workload")
async def get_workload():
    return task_service.get_user_workload()


@app.get("/api/tasks/{task_id}")
async def get_task(task_id: int):
    task = task_service.get_by_id(task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    return task


@app.patch("/api/tasks/{task_id}")
async def update_task(task_id: int, data: TaskUpdate):
    updated = task_service.update(task_id, data.model_dump(exclude_unset=True))
    if not updated:
        raise HTTPException(status_code=404, detail="Task not found")
    return updated


@app.delete("/api/tasks/{task_id}", status_code=204)
async def delete_task(task_id: int):
    deleted = task_service.delete(task_id)
    if not deleted:
        raise HTTPException(status_code=404, detail="Task not found")

The 5 Bugs

Each bug is described with a user scenario. Your job is to follow the reproduce → isolate → diagnose → fix → verify process for each one.


Bug 1: The Stats Crash

Report: "When I call GET /api/tasks/stats, the server returns a 500 error."

Difficulty level: Easy

Instructions:

  1. Identify what type of error it is
  2. Use the stack trace to diagnose
  3. Apply a fix that doesn't change the business logic
See solution

REPRODUCE:

curl http://localhost:8000/api/tasks/stats
# Response: 500 Internal Server Error

The stack trace shows:

File "/app/services.py", line XX, in get_stats
    completion_rate = completed / active * 100
ZeroDivisionError: division by zero

ISOLATE:

The error always occurs — it doesn't depend on external inputs. It's a bug in the internal logic.

DIAGNOSE:

completed = by_status.get("completed", 0)
active = by_status.get("active", 0)    # BUG: "active" isn't a valid status
completion_rate = completed / active * 100

The valid statuses are: pending, in_progress, completed, cancelled. There's no "active". So active is always 0, causing division by zero.

The correct calculation of "active tasks" should be: total - cancelled or pending + in_progress + completed.

FIX:

completed = by_status.get("completed", 0)
cancelled = by_status.get("cancelled", 0)
total_active = total - cancelled

if total_active == 0:
    completion_rate = 0.0
else:
    completion_rate = round(completed / total_active * 100, 1)

VERIFY:

curl http://localhost:8000/api/tasks/stats
# ✅ Response: 200 with a correct completion_rate
# With the seed data: 1 completed of 4 active = 25.0%

Bug 2: The Search That Explodes

Report: "The search endpoint GET /api/tasks/search?q=optimize works with some tasks but crashes with others. The error seems random."

Difficulty level: Medium

Instructions:

  1. Reproduce the error with different searches
  2. Isolate which tasks cause the crash
  3. Diagnose why some tasks crash and others don't
See solution

REPRODUCE:

curl "http://localhost:8000/api/tasks/search?q=JWT"
# ✅ Works — returns the JWT task

curl "http://localhost:8000/api/tasks/search?q=optimize"
# ❌ 500 Internal Server Error

curl "http://localhost:8000/api/tasks/search?q=dashboard"
# ✅ Works

ISOLATE:

What's special about the "optimize" search? Looking at the seed data, the "Optimize queries" task has description: None.

curl "http://localhost:8000/api/tasks/search?q=review"
# Does it work? Yes — "Review pull requests" has description="The team's pending PRs"

curl "http://localhost:8000/api/tasks/search?q=pipeline"
# Does it work? Yes — "Set up CI/CD" has description="GitHub Actions pipeline"

The bug only appears when the search does NOT match the title but the code tries to search in description — and that task has description=None.

DIAGNOSE:

def search(self, query: str) -> list[dict]:
    results = []
    query_lower = query.lower()

    for task in tasks_db.values():
        if query_lower in task["title"].lower():
            results.append(task)
        elif query_lower in task["description"].lower():  # BUG: description can be None
            results.append(task)

    return results

When task["description"] is None, None.lower() raises AttributeError.

The "optimize" search matches "Optimize queries" by title, so it does NOT reach the elif. But if you search for something that isn't in that task's title, the code tries to search in description and crashes.

Wait — but does "optimize" match the title? "Optimize" in lowercase is "optimize" and the title is "Optimize queries", with "optimize" included. So it does match. Why does it crash?

Looking more carefully: the "optimize" search matches task 4 by title, but then it KEEPS iterating and reaches a task where the search fails on the title and the description is None.

Actually the bug is simpler: when ANY task has description=None, the search crashes when it reaches that task if the query doesn't match its title.

FIX:

def search(self, query: str) -> list[dict]:
    results = []
    query_lower = query.lower()

    for task in tasks_db.values():
        title_match = query_lower in task["title"].lower()
        desc = task.get("description") or ""
        desc_match = query_lower in desc.lower()

        if title_match or desc_match:
            results.append(task)

    return results

VERIFY:

curl "http://localhost:8000/api/tasks/search?q=optimize"
# ✅ Returns the "Optimize queries" task

curl "http://localhost:8000/api/tasks/search?q=pipeline"
# ✅ Returns the "Set up CI/CD" task (searches in description)

curl "http://localhost:8000/api/tasks/search?q=xyz"
# ✅ Returns an empty list (doesn't crash)

Bug 3: Workload with Phantom Users

Report: "The GET /api/tasks/workload endpoint sometimes returns a 500 error. When it works, it shows incorrect data."

Difficulty level: Medium

Instructions:

  1. Reproduce the error
  2. Identify why it fails and why the data is incorrect
  3. Note: this bug has TWO problems — the crash AND the incorrect data
See solution

REPRODUCE:

curl http://localhost:8000/api/tasks/workload
# ❌ 500 Internal Server Error

The stack trace shows:

File "/app/services.py", line XX, in get_user_workload
    workload[uid] = {
        ...
        "user_name": user["name"],
    }
AttributeError: 'NoneType' object has no attribute '__getitem__'

ISOLATE:

Looking at the seed data, task 4 ("Optimize queries") has assignee_id: None. When the code processes that task:

uid = task["assignee_id"]     # uid = None
user = users_db.get(uid)       # user = None (there's no user with id None)
workload[uid] = {
    "user_name": user["name"], # ❌ None["name"] crashes
}

DIAGNOSE:

Problem 1 (crash): The code doesn't handle tasks without an assignee (assignee_id=None). It tries to look up a user with id=None and crashes.

Problem 2 (incorrect data): Even if you fix the crash, the status count has a bug:

if status in workload[uid]:
    workload[uid][status] += 1

This only increments if the status is already a key in the dictionary. The dictionary is initialized with "pending", "in_progress", and "completed", but not "cancelled". If a task has status "cancelled", it simply isn't counted — the total doesn't add up.

FIX:

def get_user_workload(self) -> list[dict]:
    workload = {}
    for task in tasks_db.values():
        uid = task["assignee_id"]

        if uid is None:
            continue

        if uid not in workload:
            user = users_db.get(uid)
            if not user:
                continue

            workload[uid] = {
                "user_id": uid,
                "user_name": user["name"],
                "total_tasks": 0,
                "pending": 0,
                "in_progress": 0,
                "completed": 0,
                "cancelled": 0,
            }

        workload[uid]["total_tasks"] += 1
        status = task["status"]
        if status in workload[uid]:
            workload[uid][status] += 1

    return sorted(
        workload.values(),
        key=lambda x: x["total_tasks"],
        reverse=True
    )

VERIFY:

curl http://localhost:8000/api/tasks/workload
# ✅ 200 OK, returns the workload with no error
# ✅ Doesn't include "None" as a user
# ✅ The counts add up correctly

Bug 4: The Update That Makes Data Disappear

Report: "When I update a task with PATCH /api/tasks/{id}, some fields I didn't send in the request get erased. For example, if I only change the status, the category_id disappears."

Difficulty level: Hard

Instructions:

  1. Reproduce with a PATCH that only sends one field
  2. Verify which other fields change
  3. This is a subtle logic bug — the code "works" but the data gets corrupted silently
See solution

REPRODUCE:

# See the current task
curl http://localhost:8000/api/tasks/1
# Result: {"id": 1, "title": "Implement JWT authentication", 
#   "priority": "high", "status": "in_progress", "category_id": 1, 
#   "assignee_id": 1, "due_date": "2026-03-20", ...}

# Update only the status
curl -X PATCH http://localhost:8000/api/tasks/1 \
  -H "Content-Type: application/json" \
  -d '{"status": "completed"}'

# See the task after the update
curl http://localhost:8000/api/tasks/1
# Is category_id still 1? Is assignee_id still 1?

ISOLATE:

Testing different fields:

# PATCH with only title
curl -X PATCH http://localhost:8000/api/tasks/2 \
  -H "Content-Type: application/json" \
  -d '{"title": "New title"}'

# Verify: are the other fields intact?

DIAGNOSE:

The endpoint uses data.model_dump(exclude_unset=True):

@app.patch("/api/tasks/{task_id}")
async def update_task(task_id: int, data: TaskUpdate):
    updated = task_service.update(task_id, data.model_dump(exclude_unset=True))

exclude_unset=True correctly excludes fields that weren't sent. So the data dictionary only contains {"status": "completed"}.

The service:

def update(self, task_id: int, data: dict) -> Optional[dict]:
    task = tasks_db.get(task_id)
    if not task:
        return None

    for key, value in data.items():
        if value is not None:           # BUG: what if I want 
            task[key] = value            # to set a field to None?

    task["updated_at"] = datetime.utcnow()
    return task

The subtle bug: The condition if value is not None prevents setting a field to None intentionally. If you send {"assignee_id": null} to unassign a task, the null becomes None in Python, and the condition filters it out — the field is NOT updated.

# Try to unassign a task
curl -X PATCH http://localhost:8000/api/tasks/1 \
  -H "Content-Type: application/json" \
  -d '{"assignee_id": null}'

# The assignee_id is STILL 1 — the null was ignored

But wait — the report says the fields "disappear", which is the opposite of what we found (fields that do NOT change when they should). Let's review again...

Looking more carefully at the flow: model_dump(exclude_unset=True) returns only the fields sent. If the PATCH body is {"status": "completed"}, the dict is only {"status": "completed"}. The for loop only modifies status. The other fields should stay intact.

The real bug from the report might be in the response: the list tasks endpoint (GET /api/tasks) enriches the data with category and assignee names, but the individual GET endpoint (GET /api/tasks/{id}) returns the raw data from the dict. Depending on which endpoint the frontend uses to verify, it can look different.

However, there's another bug here: exclude_unset=True + if value is not None creates an inconsistency. If a field has a default of None in TaskUpdate and is NOT sent, exclude_unset=True excludes it (correct). But if it's sent explicitly as null, it includes it in the dict as None, and the if value is not None ignores it.

FIX:

def update(self, task_id: int, data: dict) -> Optional[dict]:
    task = tasks_db.get(task_id)
    if not task:
        return None

    for key, value in data.items():
        task[key] = value

    task["updated_at"] = datetime.utcnow()
    return task

We remove the if value is not None condition because exclude_unset=True already takes care of not including unsent fields. If someone explicitly sends null, it's because they want the field to be None.

VERIFY:

# Unassign a task
curl -X PATCH http://localhost:8000/api/tasks/1 \
  -H "Content-Type: application/json" \
  -d '{"assignee_id": null}'

curl http://localhost:8000/api/tasks/1
# ✅ assignee_id is null

# Update only the status without affecting other fields
curl -X PATCH http://localhost:8000/api/tasks/2 \
  -H "Content-Type: application/json" \
  -d '{"status": "completed"}'

curl http://localhost:8000/api/tasks/2
# ✅ status is "completed", other fields intact

Bug 5: The Date Validation That Doesn't Validate

Report: "I can create tasks with impossible dates like '2026-02-30' without the API giving an error. The task is created but then the endpoints that filter by date behave unpredictably."

Difficulty level: Hard

Instructions:

  1. Reproduce: create a task with an impossible date
  2. Investigate why the validator doesn't catch the invalid date
  3. Diagnose what problems the invalid date causes in other endpoints
See solution

REPRODUCE:

curl -X POST http://localhost:8000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Test", "due_date": "2026-02-30"}'
# Does it return 201 or 422?

ISOLATE:

Looking at the validator in models.py:

@field_validator('due_date')
@classmethod
def validate_due_date(cls, v):
    if v is not None:
        parsed = datetime.strptime(v, "%Y-%m-%d")  # RAISES ValueError
        return v                                      # BUT the error rises as a Pydantic ValidationError
    return v

Hmm — datetime.strptime("2026-02-30", "%Y-%m-%d") should raise ValueError: day is out of range for month. And Pydantic should turn it into a ValidationError and FastAPI should return 422.

Let's test again:

curl -X POST http://localhost:8000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Test", "due_date": "2026-02-30"}'
# If it returns 422 → the validator works for this case
# If it returns 201 → the validator has a bug

If it returns 422, then the validator DOES work for impossible dates, but the question is: does it validate the format but not the semantics? Let's test:

# Date in the correct format but in the past
curl -X POST http://localhost:8000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Test", "due_date": "2020-01-01"}'
# Is it created? → Yes, it doesn't validate that the date is in the future

# Date in an incorrect format
curl -X POST http://localhost:8000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Test", "due_date": "30-02-2026"}'
# Does it return 422? → It should

DIAGNOSE:

The validator's real bugs are:

  1. It doesn't validate that the date is in the future. You can create a task with a due_date in the past.

  2. It parses but doesn't use the result. The validator does parsed = datetime.strptime(v, "%Y-%m-%d") and then return v — it returns the original string, not the datetime. If you wanted to compare with the current date, you'd have to parse again.

  3. Date comparisons as strings are problematic. In get_stats():

if task["due_date"] and task["due_date"] < today:

This compares strings, not dates. The lexicographic comparison of strings in ISO format ("2026-03-15" < "2026-03-20") works correctly for the same format, but it's fragile.

FIX:

@field_validator('due_date')
@classmethod
def validate_due_date(cls, v):
    if v is None:
        return v
    try:
        parsed = datetime.strptime(v, "%Y-%m-%d")
    except ValueError:
        raise ValueError(
            f"Invalid date: '{v}'. Use YYYY-MM-DD format with valid values."
        )

    if parsed.date() < date.today():
        raise ValueError(
            f"Due date cannot be in the past: {v}"
        )

    return v

VERIFY:

# Impossible date
curl -X POST http://localhost:8000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Test", "due_date": "2026-02-30"}'
# ✅ 422: Invalid date

# Date in the past
curl -X POST http://localhost:8000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Test", "due_date": "2020-01-01"}'
# ✅ 422: Due date cannot be in the past

# Valid date
curl -X POST http://localhost:8000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Test", "due_date": "2026-12-31"}'
# ✅ 201: Task created

# No date (optional)
curl -X POST http://localhost:8000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Test"}'
# ✅ 201: Task created with no date

Documentation Format

For each bug you debug, document your process with this format:

## Bug [number]: [descriptive name]

### Reproduce
- Endpoint: [method + URL]
- Input: [body/params]
- Expected result: [what should happen]
- Result obtained: [what actually happens]
- Consistent: [yes/no, N of M attempts]

### Isolate
- Minimum input that causes the error: [...]
- What input DOES work: [...]
- Conclusion: the bug is related to [...]

### Diagnose
- Tool used: [Claude Code / pdb / logging / manual inspection]
- File and line: [...]
- Root cause: [...]
- Did Claude Code help? [yes/no/partially — why]

### Fix
- Changes made: [description]
- Type of fix: [root / patch]
- Code before: [...]
- Code after: [...]

### Verify
- [ ] The original input works correctly
- [ ] Inputs that worked before still work
- [ ] Edge cases tested: [list]
- [ ] Doesn't break other endpoints: [verified]

Connection to the Project

How it connects to the capstone project (Module 8)

This exercise is a simplified version of the capstone project. In module 8, the application will be larger (8-12 files), the bugs will be more varied (including hallucinations and security holes), and you'll need to combine code review with debugging. The process you practiced here — and the documentation you generated — is exactly what you'll do at a larger scale.


Troubleshooting

Problem 1: "I can't get the application to run"

Cause: Missing dependencies or an import error. Solution: Install FastAPI and uvicorn: pip install fastapi uvicorn. Run with uvicorn main:app --reload.

Problem 2: "I found a bug but I don't know if it's one of the 5"

Cause: You might have found an additional bug that isn't on the list. Solution: Document it anyway. If you find extra bugs, that shows a good eye. The list of 5 bugs is the minimum — if you find more, it's a plus.

Problem 3: "My fix for one bug breaks another"

Cause: The bugs can interact with each other. Solution: Fix the bugs in order of independence: start with the ones that don't depend on others (Bug 1 and Bug 2), then the ones that could interact (Bug 3-5).

Problem 4: "I'm not sure if my fix is correct"

Cause: Some bugs have multiple possible fixes. Solution: The correct fix is the one that: (1) resolves the reported problem, (2) doesn't introduce new bugs, (3) handles reasonable edge cases, and (4) is a root-cause fix, not a patch. If your fix meets these 4 criteria, it's correct — even if it's different from the suggested solution.


Summary

In this exercise you applied:

  • The complete systematic process: reproduce → isolate → diagnose → fix → verify on 5 different bugs
  • Log analysis and stack traces: interpreting runtime errors to find the root cause
  • Different types of bugs: runtime crash (ZeroDivisionError), null handling (AttributeError), incorrect logic (corrupted data), incomplete validation
  • Claude Code as a tool: to diagnose known errors, not as an oracle
  • Process documentation: not just the fix but how you reached the fix

This module closes Phase 2. Now you have a complete toolkit: professional code review (modules 4-5) and debugging with AI assistance (module 6). In Phase 3, you'll add advanced tools (subagents, regenerate vs edit) and apply it all in the capstone project.


Additional resources

  1. FastAPI — Complete Tutorial - A reference for understanding the endpoints and validations
  2. Pydantic v2 — Validators - Documentation of validators like the ones used in TaskCreate
  3. Python — datetime Module - A reference for dates and timestamps
  4. Real Python — Python Debugging - Debugging techniques for when Claude Code isn't enough
  5. Anthropic — Claude Code Best Practices - How to use Claude Code effectively for debugging

Debugging & Code Review with Claude Code — Module 6, Capsule 06 Claude Code Agentic Development Path — Guide #6 of 11