Module 6: Debugging with Claude Code

Systematic Debugging

Systematic Debugging

Capsule overview

The difference between a junior developer and a senior one when debugging isn't intelligence — it's process. The junior sees an error and starts changing things at random: "What if I put a try/except here? And if I change this variable?" The senior follows a disciplined process that leads them to the root cause in less time and with less frustration.

In this capsule you're going to learn that process: reproduce → isolate → diagnose → fix → verify. Five steps, always in that order, without skipping any. And you're going to see how Claude Code fits into each step — not as the complete process, but as a tool within the process. By the end, you're going to apply the complete process to a realistic bug that only appears with certain inputs.


The 5-Step Process

Step 1: REPRODUCE

The question you answer: "Can I make the error occur consistently?"

If you can't reproduce the bug, you can't confirm your fix works. This step is the foundation of everything.

What to do

  1. Identify the exact conditions: which endpoint, what data, which user, what sequence
  2. Reproduce the error at least 2 times to confirm it's consistent
  3. Document the reproduction steps (you'll need them for step 5)

Example

# Bug reproduction
# Endpoint: POST /api/tasks
# Body: {"title": "Test", "due_date": "2026-02-30"}
# Expected: Validation error (invalid date)
# Got: 500 error (server crash)

curl -X POST http://localhost:8000/api/tasks \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"title": "Test", "due_date": "2026-02-30"}'

# Result: 500 Internal Server Error
# Reproduced: Yes, consistent in 3/3 attempts

What if you can't reproduce it

If the error is intermittent:

  • ✅ Look for patterns: does it happen at certain hours? With certain users? After a certain load?
  • ✅ Add more logging to capture the state when it happens again
  • ✅ Review historical logs: is there a pattern you're not seeing?
  • ✅ Consider race conditions: two simultaneous requests?

Where Claude Code helps in this step

Limited. Claude Code can't run your application or make requests. But it can help you:

  • Design a reproduction script based on the error description
  • Suggest which variables/conditions to test to reproduce an intermittent bug
I have a bug that appears sometimes when I create tasks. The error is 
a 500 but it doesn't appear with all inputs. What variations of 
input should I try to identify the pattern?

Model:
"""python
class TaskCreate(BaseModel):
    title: str
    due_date: Optional[datetime] = None
    priority: str = "medium"
    tags: Optional[list[str]] = None
    assignee_id: Optional[int] = None
"""

Step 2: ISOLATE

The question you answer: "What's the minimum input that causes the error?"

Once you can reproduce the bug, simplify it. Reduce the input until you find the smallest version that still causes the error. This tells you exactly which part of the input is problematic.

The reduction technique

Original input that fails:
{
    "title": "Important project X task",
    "due_date": "2026-02-30",
    "priority": "high",
    "tags": ["backend", "urgent"],
    "assignee_id": 42,
    "description": "This task needs to be completed before the sprint"
}

Step 1: Remove optional fields one by one

Without tags → Does it fail? Yes
Without assignee_id → Does it fail? Yes
Without description → Does it fail? Yes
Without priority → Does it fail? Yes
Without due_date → Does it fail? NO ← Found it!

Minimum input that causes the error:
{
    "title": "Test",
    "due_date": "2026-02-30"
}

Conclusion: The bug is related to due_date.

Finer reduction

Is it the date itself or the format?

"due_date": "2026-02-30"  → Fails  (February 30 doesn't exist)
"due_date": "2026-02-28"  → Works
"due_date": "2026-04-31"  → Fails  (April 31 doesn't exist)
"due_date": "2026-13-01"  → Fails  (month 13 doesn't exist)
"due_date": "2026-03-15"  → Works

Conclusion: The bug occurs with invalid dates that 
the parser doesn't handle correctly.

Where Claude Code helps in this step

Moderate. Claude Code can suggest which inputs to test to isolate a bug:

I found a bug that appears with certain due_date values but 
not others. "2026-02-30" fails, "2026-02-28" works. What 
other values should I try to confirm that the problem is 
with invalid dates?

Step 3: DIAGNOSE

The question you answer: "Why does it fail with this input?"

This is the step where Claude Code shines. You already have:

  • The minimum input that causes the error
  • The stack trace or log of the error
  • The relevant code

Now you pass all of this to Claude Code to get a diagnostic hypothesis.

Complete example

My POST /api/tasks endpoint fails with a 500 when I pass 
an invalid date like "2026-02-30". It works with valid dates 
like "2026-03-15".

Stack trace:
"""
Traceback (most recent call last):
  File "/app/routers/tasks.py", line 18, in create_task
    task_data = TaskCreate(**body)
  File "/app/services/task_service.py", line 32, in create
    parsed_date = datetime.strptime(data.due_date, "%Y-%m-%d")
ValueError: day is out of range for month
"""

Service code:
"""python
from datetime import datetime
from typing import Optional

class TaskService:
    def create(self, data) -> dict:
        parsed_date = datetime.strptime(data.due_date, "%Y-%m-%d")
        # ... rest of the code
"""

Input that works: {"title": "Test", "due_date": "2026-03-15"}
Input that fails: {"title": "Test", "due_date": "2026-02-30"}

Why doesn't strptime handle this as a validation error 
instead of a crash?

Evaluating Claude Code's response

Claude Code will probably say:

datetime.strptime() tries to create a datetime object with the date provided. "2026-02-30" is a date that doesn't exist (February only has 28 or 29 days), so Python raises ValueError. The problem is that this error isn't being caught — there's no try/except or prior validation.

I suggest validating the date before processing it, or using Pydantic's datetime type, which does the validation automatically.

Your evaluation of the diagnosis:

  • ✅ Correct: strptime raises ValueError with invalid dates
  • ✅ Correct: There's no error handling
  • ✅ The suggestion of using Pydantic's datetime type is good — it moves the validation to the schema

What NOT to do in this step

  • ❌ Accept the first diagnosis without verifying it
  • ❌ Apply the fix without first understanding why it works
  • ❌ Ignore whether Claude Code suggests something that changes the business logic

Step 4: FIX

The question you answer: "What's the correct solution that doesn't introduce new bugs?"

With the diagnosis verified, you apply the fix. But there are two types of fix, and choosing the wrong one is a common error:

Patch fix vs root-cause fix

# Patch fix: catches the error but doesn't fix the cause
def create(self, data) -> dict:
    try:
        parsed_date = datetime.strptime(data.due_date, "%Y-%m-%d")
    except ValueError:
        parsed_date = None  # ← Is it correct to create a task with no date?
# Root-cause fix: validates before processing
from pydantic import BaseModel, field_validator
from datetime import datetime, date
from typing import Optional

class TaskCreate(BaseModel):
    title: str
    due_date: Optional[date] = None

    @field_validator('due_date', mode='before')
    @classmethod
    def validate_due_date(cls, v):
        if v is None:
            return v
        if isinstance(v, str):
            try:
                return date.fromisoformat(v)
            except ValueError:
                raise ValueError(f"Invalid date format: {v}. Use YYYY-MM-DD with valid values.")
        return v

The root-cause fix:

  • ✅ Validates in the schema (before it reaches the service)
  • ✅ Returns a clear error to the user (422 with a descriptive message)
  • ✅ Doesn't allow creating tasks with invalid states

How to ask Claude Code for a fix

The bug is diagnosed: datetime.strptime doesn't handle invalid 
dates. I need a fix that:
1. Validates the date in the Pydantic schema (not the service)
2. Returns a 422 with a clear message if the date is invalid
3. Accepts None as a valid value (optional field)
4. Doesn't change the behavior for valid dates

Current schema code:
"""python
class TaskCreate(BaseModel):
    title: str
    due_date: Optional[str] = None
"""

Step 5: VERIFY

The question you answer: "Does the fix solve the original problem without creating new problems?"

This is the most-skipped step — and the most important. Verifying isn't just "it doesn't crash anymore":

Verification checklist

□ Does the input that was causing the error now work correctly?
  → curl with "2026-02-30" → 422 with a clear message ✅

□ Does the input that worked BEFORE still work?
  → curl with "2026-03-15" → 201 Created ✅

□ Do related edge cases work?
  → curl with due_date=null → 201 Created (no date) ✅
  → curl without the due_date field → 201 Created (no date) ✅
  → curl with "not-a-date" → 422 with a clear message ✅
  → curl with "2026-02-29" → 422 (2026 isn't a leap year) ✅

□ Does the fix not break other endpoints that use the same class?
  → PATCH /api/tasks/{id} with due_date → works ✅

□ Is the error message useful for the client?
  → "Invalid date format: 2026-02-30. Use YYYY-MM-DD 
     with valid values." ✅

Where Claude Code helps in this step

Moderate. You can ask it to suggest edge cases to test:

I just fixed a date validation bug in a 
FastAPI endpoint. What edge cases should I test to 
make sure the fix is complete?

The field is: due_date: Optional[date] = None

The Complete Process in Action: A Real Case

Let's go through the 5 steps with a more complex bug.

The bug report

"The GET /api/tasks endpoint returns duplicate tasks. Sometimes a task appears 2 or 3 times in the response."

Step 1: REPRODUCE

# Attempt 1: basic request
curl http://localhost:8000/api/tasks
# Result: 15 tasks, no duplicates ← Doesn't reproduce

# Attempt 2: with a status filter
curl "http://localhost:8000/api/tasks?status=pending"
# Result: 8 tasks, no duplicates ← Doesn't reproduce

# Attempt 3: with multiple filters
curl "http://localhost:8000/api/tasks?status=pending&priority=high"
# Result: 5 tasks, task #12 appears twice ← Reproduced!

# Confirm: run it 3 more times
# Result: always duplicates task #12 with these filters

Reproduction documentation:

  • Endpoint: GET /api/tasks?status=pending&priority=high
  • Result: task #12 appears duplicated
  • Consistent: yes (3/3)

Step 2: ISOLATE

# Is it the status filter?
curl "http://localhost:8000/api/tasks?status=pending"
# No duplicates

# Is it the priority filter?
curl "http://localhost:8000/api/tasks?priority=high"
# No duplicates

# Is it the combination of both?
curl "http://localhost:8000/api/tasks?status=pending&priority=high"
# Duplicates!

# Does it happen with other combinations?
curl "http://localhost:8000/api/tasks?status=completed&priority=low"
# No duplicates ← Hmm, only with pending + high?

# Is it specific to task #12?
# Check task #12: it has 2 tags ("backend", "urgent")
# Check task #7 (pending, high, 1 tag): doesn't duplicate
# Check task #3 (pending, high, 3 tags): appears 3 times ← Pattern!

Isolation conclusion: Tasks are duplicated when they have multiple tags AND combined filters are used. The number of duplicates = the number of tags.

Step 3: DIAGNOSE

Now I pass everything to Claude Code:

My GET /api/tasks endpoint returns duplicate results. 
I've isolated the bug:

- It only happens when I use 2+ filters (status + priority)
- Tasks are duplicated N times where N = the number of tags the task has
- With a single filter there are no duplicates
- Without filters there are no duplicates

This suggests a problem with JOINs in the query.

Service code:
"""python
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import and_
from app.models import Task, Tag
from typing import Optional

class TaskService:
    def __init__(self, db: Session):
        self.db = db

    def get_filtered(
        self, 
        status: Optional[str] = None, 
        priority: Optional[str] = None
    ) -> list[Task]:
        query = self.db.query(Task).join(Task.tags)

        if status:
            query = query.filter(Task.status == status)
        if priority:
            query = query.filter(Task.priority == priority)

        return query.all()
"""

Models:
"""python
from sqlalchemy import Column, Integer, String, Table, ForeignKey
from sqlalchemy.orm import relationship, declarative_base

Base = declarative_base()

task_tags = Table(
    'task_tags', Base.metadata,
    Column('task_id', Integer, ForeignKey('tasks.id')),
    Column('tag_id', Integer, ForeignKey('tags.id'))
)

class Task(Base):
    __tablename__ = 'tasks'
    id = Column(Integer, primary_key=True)
    title = Column(String)
    status = Column(String)
    priority = Column(String)
    tags = relationship('Tag', secondary=task_tags, backref='tasks')

class Tag(Base):
    __tablename__ = 'tags'
    id = Column(Integer, primary_key=True)
    name = Column(String)
"""

Why does the JOIN with tags cause duplicates when there are filters?

Claude Code's diagnosis

The problem is the JOIN with the tags table. When you do query(Task).join(Task.tags), SQLAlchemy generates an INNER JOIN with the intermediate task_tags table. If a task has 3 tags, the JOIN produces 3 rows for that task (one for each relationship in task_tags).

Without filters, SQLAlchemy can deduplicate internally. With filters, the deduplication doesn't work correctly and you get the duplicate rows.

Fix: Use .distinct() or change the JOIN to a subquery/exists approach.

Verification of the diagnosis

  • ✅ It makes sense: a JOIN with a many-to-many table produces duplicates
  • ✅ It explains why N tags = N duplicates
  • ✅ It explains why it happens with filters (the query materializes the JOIN rows)

Step 4: FIX

def get_filtered(
    self, 
    status: Optional[str] = None, 
    priority: Optional[str] = None
) -> list[Task]:
    query = self.db.query(Task)

    if status:
        query = query.filter(Task.status == status)
    if priority:
        query = query.filter(Task.priority == priority)

    return query.options(joinedload(Task.tags)).distinct().all()

Changes:

  1. Removed the .join(Task.tags) that caused duplicates
  2. Used .options(joinedload(Task.tags)) to load tags without a JOIN in the main query
  3. Added .distinct() as extra safety

Step 5: VERIFY

# The case that was failing
curl "http://localhost:8000/api/tasks?status=pending&priority=high"
# ✅ No duplicates, task #12 appears once

# Individual filter
curl "http://localhost:8000/api/tasks?status=pending"
# ✅ No duplicates

# Without filters
curl "http://localhost:8000/api/tasks"
# ✅ No duplicates, all tasks present

# Verify that tags are still loaded
curl "http://localhost:8000/api/tasks/12"
# ✅ Task #12 shows its 2 tags correctly

# Edge case: task with no tags
curl "http://localhost:8000/api/tasks/20"  # task with no tags
# ✅ Works, tags: []

Print Debugging vs Logging vs Debugger

Not all debugging tools are the same. Each one has its moment:

Print debugging

def calculate_discount(price, user_tier, coupon_code):
    print(f"DEBUG: price={price}, tier={user_tier}, coupon={coupon_code}")
    
    discount = get_base_discount(user_tier)
    print(f"DEBUG: base_discount={discount}")
    
    if coupon_code:
        coupon_discount = validate_coupon(coupon_code)
        print(f"DEBUG: coupon_discount={coupon_discount}")
        discount += coupon_discount
    
    final_price = price * (1 - discount)
    print(f"DEBUG: final_price={final_price}")
    
    return final_price

When to use: Simple bugs where you need to see values at 2-3 points. Quick to add, quick to remove.

When NOT to use: When you have more than 5 prints — it's time to use logging or a debugger.

Logging

import logging

logger = logging.getLogger(__name__)

def calculate_discount(price: float, user_tier: str, coupon_code: str | None) -> float:
    logger.debug(f"calculate_discount called: price={price}, tier={user_tier}, coupon={coupon_code}")
    
    discount = get_base_discount(user_tier)
    logger.debug(f"Base discount for tier '{user_tier}': {discount}")
    
    if coupon_code:
        coupon_discount = validate_coupon(coupon_code)
        logger.debug(f"Coupon '{coupon_code}' discount: {coupon_discount}")
        discount += coupon_discount
    
    final_price = price * (1 - discount)
    logger.info(f"Discount applied: {discount*100}%, final_price={final_price}")
    
    return final_price

When to use: More complex investigation. You can leave the logging in the code (at DEBUG level) for future bugs.

Advantage over prints: Severity levels, timestamps, can be configured without changing code.

Debugger (pdb)

def calculate_discount(price, user_tier, coupon_code):
    import pdb; pdb.set_trace()
    
    discount = get_base_discount(user_tier)
    if coupon_code:
        coupon_discount = validate_coupon(coupon_code)
        discount += coupon_discount
    
    final_price = price * (1 - discount)
    return final_price

When to use: Complex bugs where you need to inspect the state of multiple variables, navigate the call stack, or run arbitrary code in the context of the error.

When NOT to use: Bugs in production (pdb pauses execution), concurrency bugs (pdb alters the timing).

When to use each tool

SituationTool
"I just need to see a value"print()
"I need to see the complete flow"logging
"I need to explore the state"pdb
"I need to interpret an error"Claude Code + logs
"The bug is timing/concurrency"logging with timestamps
"The bug is performance"profiler (cProfile, py-spy)

The 30-Minute Rule

If you've spent 30 minutes on a step without progress, change your approach:

  • ✅ 30 min on Reproduce: Add more logging and wait for it to happen again
  • ✅ 30 min on Isolate: Ask Claude Code for help suggesting variables to test
  • ✅ 30 min on Diagnose: Open pdb and step through it
  • ✅ 30 min on Fix: Maybe your diagnosis is incomplete — go back to step 3
  • ✅ 30 min on Verify: If the fix doesn't work in edge cases, the fix is incorrect — go back to step 4

The most common trap is getting stuck on Diagnose. If Claude Code doesn't give you a good diagnosis and you don't see it clearly, add more logging, reproduce again, and come back with more data.


Connection to the Project

How it applies to the capstone project (Module 8)

In the capstone project you'll have bugs that require the complete process. You won't be able to simply "paste the error into Claude Code" — you'll need to:

  1. Reproduce each bug with specific inputs
  2. Isolate which input or condition causes the error
  3. Diagnose with Claude Code (passing it logs + code + context)
  4. Apply the correct fix (not a patch)
  5. Verify that you don't break anything

The documentation of the process (what you did in each step) is part of the project's delivery.


Troubleshooting

Problem 1: "I can't reproduce the bug"

Cause: The bug depends on conditions you're not replicating: specific data in the DB, session state, timing. Solution: Add exhaustive logging in the function where the error occurs. Include all the inputs and the relevant state. Wait for it to happen again and use the logs to understand the exact conditions.

Problem 2: "Isolating takes too long with complex inputs"

Cause: The input has many fields and testing each combination is exponential. Solution: Use binary search: divide the fields into two halves, test each half. The group that fails is divided again. For a 10-field input, this reduces from 10 linear tests to ~4 logarithmic tests.

Problem 3: "My fix works but breaks something else"

Cause: The fix changed a behavior that another component depended on. Solution: Before applying a fix, search the code for other uses of the function/class you're modifying. Ask Claude Code: "If I change [X] to [Y], what other components could be affected?"

Problem 4: "Claude Code's diagnosis doesn't apply to my case"

Cause: Claude Code doesn't have enough context about your specific application. Solution: Include more relevant code in your prompt. Not just the function that fails — include the functions that call it and the models it uses. Also include what result you EXPECTED vs what you got.


Exercises

Exercise 1: Identify the steps (Easy)

For each action, indicate which step of the process it belongs to (Reproduce, Isolate, Diagnose, Fix, Verify):

  1. "I ran curl with the same parameters 3 times and it always fails"
  2. "I changed dict[key] to dict.get(key, default_value)"
  3. "I removed fields from the request one by one until I found which one causes the error"
  4. "I tested with the original input that was failing and now it returns 200"
  5. "I passed the stack trace to Claude Code and it told me it's a KeyError because the dictionary doesn't have that key"
  6. "I tested with an empty list to see if the fix handles that edge case"
See solution
  1. Reproduce — Confirms that the error is consistent
  2. Fix — Applies a change in the code
  3. Isolate — Reduces the input to the minimum that causes the error
  4. Verify — Confirms that the fix resolves the original problem
  5. Diagnose — Uses Claude Code to understand the cause
  6. Verify — Tests edge cases to confirm that the fix is complete

Exercise 2: Design the isolation process (Medium)

Your POST /api/orders endpoint returns 500 with this body:

{
    "customer_id": 42,
    "items": [
        {"product_id": 1, "quantity": 2, "price": 29.99},
        {"product_id": 5, "quantity": 1, "price": 0},
        {"product_id": 3, "quantity": -1, "price": 15.50}
    ],
    "shipping_address": {
        "street": "123 Main St",
        "city": "Springfield",
        "zip": "62701"
    },
    "coupon_code": "SAVE20",
    "notes": ""
}

Describe the isolation steps you'd follow to find which part of the input causes the error.

See solution

Strategy: Divide and conquer

Round 1: Is it a top-level field?

# Only customer_id + 1 valid item (no shipping, coupon, notes)
{"customer_id": 42, "items": [{"product_id": 1, "quantity": 2, "price": 29.99}]}
# Does it fail? If not, the bug is in shipping_address, coupon_code, or notes

# Only customer_id + complete items
{"customer_id": 42, "items": [...all items...]}
# Does it fail? If yes, the bug is in the items

Round 2: If the bug is in items, which item?

# Only item 1
{"customer_id": 42, "items": [{"product_id": 1, "quantity": 2, "price": 29.99}]}
# Does it fail? Probably not (normal values)

# Only item 2
{"customer_id": 42, "items": [{"product_id": 5, "quantity": 1, "price": 0}]}
# Does it fail? Possibly (price=0 could cause division by zero in the discount calculation)

# Only item 3
{"customer_id": 42, "items": [{"product_id": 3, "quantity": -1, "price": 15.50}]}
# Does it fail? Possibly (negative quantity)

Round 3: If it's item 2, is it the product_id, quantity, or price?

# price=0 with other normal values
{"product_id": 5, "quantity": 1, "price": 0}
# vs
{"product_id": 5, "quantity": 1, "price": 1}
# If price=0 fails and price=1 works → the bug is price=0

Round 4: If it's item 3, is it the negative quantity?

{"product_id": 3, "quantity": -1, "price": 15.50}
# vs
{"product_id": 3, "quantity": 1, "price": 15.50}
# If quantity=-1 fails and quantity=1 works → the bug is the negative quantity

Possible bugs found:

  • price=0 → probable ZeroDivisionError in the percentage discount calculation
  • quantity=-1 → probable negative value in the total calculation (or a validation error)
  • coupon_code="SAVE20" + price=0 → a problematic combination
  • notes="" → could cause an error if the code expects None instead of an empty string

Exercise 3: Write the diagnosis prompt (Medium)

After isolating the bug from the previous exercise, you determine that the error is price=0. The stack trace is:

Traceback (most recent call last):
  File "/app/services/order_service.py", line 55, in calculate_total
    discount_per_unit = (original_price - item.price) / original_price * 100
ZeroDivisionError: float division by zero

Write the prompt you'd give Claude Code to diagnose and get a fix.

See solution
My POST /api/orders endpoint fails with a ZeroDivisionError when 
an item has price=0. The error is in the per-unit discount 
calculation.

Stack trace:
"""
Traceback (most recent call last):
  File "/app/services/order_service.py", line 55, in calculate_total
    discount_per_unit = (original_price - item.price) / original_price * 100
ZeroDivisionError: float division by zero
"""

The calculation tries to determine what discount percentage each 
item has by comparing item.price with the product's original price.

Context:
- original_price comes from the products table (catalog price)
- item.price comes from the request (the price the customer is paying)
- If item.price = 0, it's a free item (promotion)
- If original_price = 0, it's a data error (there shouldn't be a free product in the catalog)

Questions:
1. Is the ZeroDivisionError because original_price=0 or because 
   I'm calculating the discount wrong?
2. What's the correct way to handle both cases 
   (a free promotional item vs a product with price 0 in the catalog)?
3. Should I validate price > 0 in the request's Pydantic schema?

Why this prompt is effective:

  • ✅ It includes the exact stack trace
  • ✅ It explains the business context (what the values mean)
  • ✅ It distinguishes two cases: a free item (valid) vs a product without a price (an error)
  • ✅ It asks specific questions instead of just "what do I do?"

Exercise 4: Complete process on paper (Hard)

Read this scenario and design the 5 steps of the process without running anything. Document what you'd do in each step.

The bug: Your GET /api/users/{id}/tasks endpoint returns tasks from OTHER users mixed in with those of the requested user. The report says "sometimes it returns tasks that aren't mine."

from fastapi import FastAPI, Depends
from sqlalchemy.orm import Session
from typing import Optional
from app.database import get_db
from app.models import Task

app = FastAPI()

tasks_cache = {}

@app.get("/api/users/{user_id}/tasks")
async def get_user_tasks(
    user_id: int,
    status: Optional[str] = None,
    db: Session = Depends(get_db)
):
    cache_key = f"user_tasks_{status}"
    
    if cache_key in tasks_cache:
        return tasks_cache[cache_key]
    
    query = db.query(Task).filter(Task.owner_id == user_id)
    
    if status:
        query = query.filter(Task.status == status)
    
    tasks = query.all()
    tasks_cache[cache_key] = [t.to_dict() for t in tasks]
    
    return tasks_cache[cache_key]
See solution

Step 1: REPRODUCE

I'd make two requests with different user_ids:
1. GET /api/users/1/tasks?status=pending → save result A
2. GET /api/users/2/tasks?status=pending → does it return result A?

If the second request returns user 1's tasks, 
the bug is reproducible.

Step 2: ISOLATE

Is it the user_id or the status filter?
- GET /api/users/1/tasks (no status) → correct result?
- GET /api/users/2/tasks (no status) → correct result?
- GET /api/users/1/tasks?status=pending → correct result?
- GET /api/users/2/tasks?status=pending → user 1's result?

Hypothesis: the bug appears when I use the same status filter 
for different users.

Step 3: DIAGNOSE

Looking at the code, the bug is obvious:

cache_key = f"user_tasks_{status}"  # ← Doesn't include user_id

The cache key is "user_tasks_pending" for ALL users. When user 1 makes the request, it's cached. When user 2 makes the same request, user 1's cache is returned.

Step 4: FIX

cache_key = f"user_tasks_{user_id}_{status}"

But it should also consider:

  • Is the cache invalidated when tasks are created/modified?
  • Does the cache size grow indefinitely?
  • Should it use a TTL?

A more robust fix:

from functools import lru_cache
from datetime import datetime, timedelta

CACHE_TTL = timedelta(minutes=5)
tasks_cache = {}

def get_cached(key):
    if key in tasks_cache:
        value, timestamp = tasks_cache[key]
        if datetime.utcnow() - timestamp < CACHE_TTL:
            return value
        del tasks_cache[key]
    return None

def set_cached(key, value):
    tasks_cache[key] = (value, datetime.utcnow())

@app.get("/api/users/{user_id}/tasks")
async def get_user_tasks(
    user_id: int,
    status: Optional[str] = None,
    db: Session = Depends(get_db)
):
    cache_key = f"user_tasks_{user_id}_{status}"
    cached = get_cached(cache_key)
    if cached is not None:
        return cached

    query = db.query(Task).filter(Task.owner_id == user_id)
    if status:
        query = query.filter(Task.status == status)
    
    result = [t.to_dict() for t in query.all()]
    set_cached(cache_key, result)
    return result

Step 5: VERIFY

□ GET /api/users/1/tasks?status=pending → user 1's tasks ✅
□ GET /api/users/2/tasks?status=pending → user 2's tasks ✅
□ GET /api/users/1/tasks → user 1's tasks (no filter) ✅
□ GET /api/users/2/tasks → user 2's tasks (no filter) ✅
□ Same user, different status → different results ✅
□ After creating a task for user 1:
  → GET /api/users/1/tasks may not show it until the cache expires
  → Is it acceptable? If not, I need cache invalidation

Summary

In this capsule you learned:

  • The 5-step process (reproduce → isolate → diagnose → fix → verify) is the backbone of professional debugging
  • Reproduce is the foundation: if you can't reproduce the bug, you can't confirm the fix
  • Isolate reduces the problem to the minimum input — essential for precise diagnoses
  • Diagnose is where Claude Code helps most, but it needs good data (logs + code + context)
  • Fix should be a root-cause fix, not a patch — fix the cause, not the symptom
  • Verify includes the original case + edge cases + regression
  • The 30-minute rule: if you're stuck, change your approach or go back to the previous step
  • Print vs logging vs debugger: each tool has its moment

Next capsule: When Claude Code Doesn't Help — the real limitations and when to use manual tools.


Additional resources

  1. Debugging: The 9 Indispensable Rules - A classic book on debugging principles
  2. Python pdb — The Python Debugger - Official pdb documentation
  3. Real Python — Python Debugging with pdb - A practical pdb tutorial
  4. SQLAlchemy — FAQ: Sessions and Queries - FAQ about queries (useful for duplicate bugs)
  5. FastAPI — Testing - How to write tests to verify fixes

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