Module 6: Debugging with Claude Code

When Claude Code Doesn't Help

When Claude Code Doesn't Help

Capsule overview

This is perhaps the most important capsule in the entire module. Knowing when Claude Code does help is useful. Knowing when it doesn't help is essential. Because if you don't know, you're going to spend 30, 60, 90 minutes passing it the same error in different ways expecting a diagnosis that's never going to arrive — while the answer is a pdb.set_trace() away.

Claude Code is a language model that analyzes text. It's extraordinarily good at processing stack traces, interpreting logs, and suggesting fixes for known errors. But it can't run your code, it can't see the state of memory, it can't set breakpoints, and it can't observe the timing of concurrent operations. These aren't bugs that will be fixed in the next version — they're fundamental limitations of the architecture.

In this capsule you're going to learn exactly what type of bugs are out of Claude Code's reach, what tools to use instead, and the golden rule that will save you hours: "If Claude Code suggests the same incorrect fix twice, switch to manual debugging."


The Fundamental Limitations

What Claude Code can do vs what it can't

┌──────────────────────────────────────────────┐
│         CLAUDE CODE CAN                       │
│                                              │
│  ✅ Read and analyze static code             │
│  ✅ Interpret stack traces and logs          │
│  ✅ Suggest fixes for known errors           │
│  ✅ Explain what a function does             │
│  ✅ Identify common error patterns           │
│  ✅ Compare code with documentation          │
│  ✅ Suggest tests to verify fixes            │
│                                              │
├──────────────────────────────────────────────┤
│         CLAUDE CODE CANNOT                   │
│                                              │
│  ❌ Run your code                            │
│  ❌ See variables at runtime                 │
│  ❌ Set breakpoints                          │
│  ❌ Inspect memory                           │
│  ❌ Measure the timing of operations         │
│  ❌ Reproduce race conditions                │
│  ❌ Access your database                     │
│  ❌ Make requests to your services           │
│  ❌ See the state of network connections     │
│  ❌ Monitor CPU/memory usage                 │
│                                              │
└──────────────────────────────────────────────┘

The dividing line is clear: Claude Code works with text. Everything that is text (code, logs, stack traces, configuration) it can analyze. Everything that is runtime state (variables, memory, connections, timing) is out of its reach.


Category 1: Race Conditions and Timing Bugs

Why Claude Code can't help

A race condition occurs when two or more operations compete for the same resource and the result depends on the order in which they run. Claude Code can't simulate timing — it can only read code sequentially.

Example: Double-spend in a purchase endpoint

from fastapi import FastAPI, HTTPException, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User, Product

app = FastAPI()

@app.post("/api/purchase/{product_id}")
async def purchase(product_id: int, user_id: int, db: Session = Depends(get_db)):
    user = db.query(User).get(user_id)
    product = db.query(Product).get(product_id)

    if user.balance < product.price:
        raise HTTPException(status_code=400, detail="Insufficient balance")

    user.balance -= product.price
    product.stock -= 1
    db.commit()

    return {"status": "purchased", "remaining_balance": user.balance}

The bug: If the same user makes two simultaneous requests, both read user.balance = 100 and product.price = 80. Both pass the check 100 < 80 = False. Both subtract: 100 - 80 = 20. But the user only pays once — the second commit overwrites the balance with 20 instead of reaching -60 and failing.

Why Claude Code doesn't detect it: If you pass it the code, it might mention "lack of locking" as a generic possibility. But it can't:

  • Demonstrate that the bug occurs (it needs concurrent execution)
  • Determine the time window where it occurs
  • Confirm that your database engine is susceptible (it depends on the isolation level)

What to use instead

from sqlalchemy import select, update
from sqlalchemy.orm import Session

@app.post("/api/purchase/{product_id}")
async def purchase(product_id: int, user_id: int, db: Session = Depends(get_db)):
    # Option 1: SELECT FOR UPDATE (pessimistic locking)
    user = db.execute(
        select(User).where(User.id == user_id).with_for_update()
    ).scalar_one()

    product = db.execute(
        select(Product).where(Product.id == product_id).with_for_update()
    ).scalar_one()

    if user.balance < product.price:
        raise HTTPException(status_code=400, detail="Insufficient balance")

    user.balance -= product.price
    product.stock -= 1
    db.commit()

    return {"status": "purchased", "remaining_balance": user.balance}

Debugging tools for race conditions:

  • ✅ Logging with high-precision timestamps (microseconds)
  • ✅ Concurrency tests with asyncio.gather() or threading
  • ✅ Database isolation level analysis
  • ✅ Tools like locust for load testing

Category 2: Bugs That Require Deep Business Context

Why Claude Code can't help

Claude Code doesn't know your business rules. It can read the code and tell you what it does, but it can't tell you whether what it does is correct for your business.

Example: Discount calculation

from decimal import Decimal
from typing import Optional

def calculate_discount(
    base_price: Decimal,
    user_tier: str,
    coupon: Optional[str],
    is_first_purchase: bool,
    items_in_cart: int
) -> Decimal:
    discount = Decimal("0")

    # Tier discount
    tier_discounts = {
        "bronze": Decimal("0.05"),
        "silver": Decimal("0.10"),
        "gold": Decimal("0.15"),
        "platinum": Decimal("0.20")
    }
    discount += tier_discounts.get(user_tier, Decimal("0"))

    # First-purchase discount
    if is_first_purchase:
        discount += Decimal("0.10")

    # Volume discount
    if items_in_cart >= 5:
        discount += Decimal("0.05")

    # Coupon discount
    if coupon == "SAVE20":
        discount += Decimal("0.20")

    # Apply the discount
    final_price = base_price * (1 - discount)
    return max(final_price, Decimal("0"))

The business bug: A platinum user with a first purchase, 5+ items, and a SAVE20 coupon gets: 20% + 10% + 5% + 20% = 55% discount. Is that correct?

Claude Code can't answer that question. It doesn't know whether:

  • The discounts should be additive or there should be a maximum cap
  • The coupon should be exclusive (not combinable with other discounts)
  • The first purchase should take priority over the tier
  • There's a business rule that says "maximum 30% discount"

When you need a human, not AI

  • ✅ When the code works technically but the results are incorrect according to business rules
  • ✅ When you need to validate that financial calculations comply with internal policies
  • ✅ When the bug is "the feature doesn't do what the product manager wants"
  • ✅ When you need to decide between two behaviors, both technically valid

Category 3: Performance Issues That Require Profiling

Why Claude Code can't help (completely)

Claude Code can identify performance anti-patterns by looking at the code (N+1 queries, unnecessary loops, lack of indexing). But it can't:

  • Measure how long each operation takes
  • Identify the real bottleneck (it may not be where you think)
  • Determine whether the problem is CPU, I/O, or memory
  • Simulate load to find scalability problems

Example: A slow endpoint

from fastapi import FastAPI, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Task, User, Tag

app = FastAPI()

@app.get("/api/dashboard")
async def get_dashboard(user_id: int, db: Session = Depends(get_db)):
    user = db.query(User).get(user_id)

    # Get all the tasks
    tasks = db.query(Task).filter(Task.owner_id == user_id).all()

    result = []
    for task in tasks:
        # N+1 query: for each task, it runs a query for tags
        tags = db.query(Tag).filter(Tag.task_id == task.id).all()

        # N+1 query: for each task, it runs a query for the assignee
        assignee = db.query(User).get(task.assignee_id) if task.assignee_id else None

        result.append({
            "id": task.id,
            "title": task.title,
            "tags": [t.name for t in tags],
            "assignee": assignee.name if assignee else None
        })

    return {"user": user.name, "tasks": result}

Claude Code can identify: The N+1 queries (one query per task for tags and assignee).

Claude Code CANNOT tell you:

  • Whether the N+1 is really the bottleneck (maybe the initial query is slow due to a missing index)
  • How long each query takes
  • Whether the problem is worse with 10 tasks or only with 1000+
  • Whether the solution should be eager loading, caching, or pagination

Profiling tools that can

# Basic profiling with cProfile
import cProfile
import pstats

def profile_endpoint():
    profiler = cProfile.Profile()
    profiler.enable()

    # Run the function you want to measure
    get_dashboard(user_id=1, db=get_session())

    profiler.disable()
    stats = pstats.Stats(profiler)
    stats.sort_stats('cumulative')
    stats.print_stats(20)
# Profiling SQL queries with SQLAlchemy
# In your configuration:
# SQLALCHEMY_ECHO=True shows every SQL query executed

# With py-spy (without modifying code):
py-spy top --pid $(pgrep -f uvicorn)

# With line_profiler (line by line):
kernprof -l -v app/routers/dashboard.py

Recommended tools by type of problem:

ProblemTool
Slow queriesSQLAlchemy echo + EXPLAIN ANALYZE
CPU-bound codecProfile, py-spy
Memory leakstracemalloc, objgraph
Network latencyhttpx with timing, opentelemetry
Slow endpoint (general)FastAPI middleware with timing

Timing middleware for diagnosis

import time
import logging
from fastapi import FastAPI, Request

app = FastAPI()
logger = logging.getLogger("performance")

@app.middleware("http")
async def timing_middleware(request: Request, call_next):
    start = time.perf_counter()
    response = await call_next(request)
    elapsed = time.perf_counter() - start

    if elapsed > 1.0:
        logger.warning(
            f"SLOW REQUEST: {request.method} {request.url.path} "
            f"took {elapsed:.3f}s"
        )
    else:
        logger.debug(
            f"{request.method} {request.url.path} "
            f"took {elapsed:.3f}s"
        )

    response.headers["X-Process-Time"] = f"{elapsed:.3f}"
    return response

Category 4: State and Memory Bugs

Why Claude Code can't help

Bugs where the state in memory isn't what you expect require runtime inspection. Claude Code only sees the code — it can't see the content of variables while they run.

Example: A memory leak in a cache

from datetime import datetime
from typing import Any

class SimpleCache:
    def __init__(self):
        self._cache: dict[str, Any] = {}
        self._access_log: list[tuple[str, datetime]] = []

    def get(self, key: str) -> Any:
        self._access_log.append((key, datetime.utcnow()))
        return self._cache.get(key)

    def set(self, key: str, value: Any) -> None:
        self._access_log.append((key, datetime.utcnow()))
        self._cache[key] = value

    def delete(self, key: str) -> None:
        self._cache.pop(key, None)

cache = SimpleCache()

The bug: _access_log grows indefinitely. Every get() and set() adds an entry. After millions of requests, the memory is exhausted.

Claude Code can identify this by looking at the code — it's a visible anti-pattern. But there are more subtle cases:

from weakref import WeakValueDictionary

class ConnectionPool:
    def __init__(self):
        self._connections: dict[str, Any] = {}
        self._callbacks: list = []

    def get_connection(self, host: str):
        if host not in self._connections:
            conn = create_connection(host)
            self._connections[host] = conn
            self._callbacks.append(lambda: conn.close())
        return self._connections[host]

The lambdas in _callbacks hold a reference to conn, preventing the garbage collector from cleaning it up even if it's removed from _connections. This type of leak is only seen with memory inspection tools.

Tools for state/memory bugs

# tracemalloc: shows which code allocates the most memory
import tracemalloc

tracemalloc.start()

# ... run code ...

snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')

for stat in top_stats[:10]:
    print(stat)
# pdb: inspect the state at a specific point
def process_request(data):
    result = transform(data)

    import pdb; pdb.set_trace()
    # Here you can inspect:
    # (Pdb) print(result)
    # (Pdb) print(type(result))
    # (Pdb) print(len(self._cache))
    # (Pdb) import sys; print(sys.getsizeof(self._cache))

    return result

Category 5: Environment and Configuration Bugs

Why Claude Code can't help

Claude Code can't see your environment: environment variables, installed versions, operating system configuration, the state of external services.

Common examples

Library version bug:

# Your code uses Pydantic v2 syntax:
from pydantic import BaseModel, field_validator

class User(BaseModel):
    email: str

    @field_validator('email')
    @classmethod
    def validate_email(cls, v):
        ...

# But on the server you have Pydantic v1 installed:
# ImportError: cannot import name 'field_validator' from 'pydantic'

Claude Code can suggest that it's a version problem, but it can't verify which version you have installed.

Environment variable bug:

import os

DATABASE_URL = os.getenv("DATABASE_URL")
# DATABASE_URL is None because the variable isn't configured
# The error appears 50 lines later when you try to connect

Claude Code can't see your environment variables. It can only suggest that you check.

What to do

# Check versions
pip freeze | grep pydantic
python --version

# Check environment variables
echo $DATABASE_URL
env | grep -i database

# Check services
pg_isready -h localhost -p 5432
redis-cli ping
curl http://localhost:8000/health

The Golden Rule: The Two-Attempt Test

"If Claude Code suggests the same incorrect fix twice, switch to manual debugging."

How to apply the rule

Attempt 1:
  You: [pass the error to Claude Code]
  Claude Code: "Add a try/except on line 45"
  You: [add the try/except, but the error persists or changes]

Attempt 2:
  You: [pass the new error with more context]
  Claude Code: "The problem could be X, Y, or Z. Try..."
  You: [try the suggestions, none work]

→ STOP. Switch to manual debugging.

Why this rule works

After two failed attempts, typically one of these things is happening:

  1. Claude Code doesn't have enough information — and giving it more textual information isn't going to change that because it needs runtime information
  2. The bug isn't one of the types Claude Code can diagnose — race condition, state, timing, performance
  3. The problem is in an interaction between components that you can't capture in a prompt

Your manual debugging toolbox

When you switch to manual debugging, choose the tool according to the type of bug:

State/value bug:            → pdb (Python debugger)
Performance bug:            → cProfile, py-spy
Memory bug:                 → tracemalloc, objgraph
Concurrency bug:            → logging with timestamps, concurrent tests
Network/connection bug:     → tcpdump, curl -v, httpx logging
Database bug:               → SQLAlchemy echo, EXPLAIN ANALYZE
Environment bug:            → pip freeze, env, docker inspect

Decision Framework: Claude Code or Manual?

Before passing a bug to Claude Code, evaluate:

                          Do I have the error in text?
                         (stack trace, log, message)
                                    │
                         ┌──────────┴──────────┐
                         │                     │
                        YES                    NO
                         │                     │
                  Is it reproducible?    Add logging
                         │              first
                  ┌──────┴──────┐
                  │             │
                 YES            NO
                  │             │
         Is the error        Is it intermittent
          clear?              or timing-related?
          │                      │
    ┌─────┴─────┐          ┌─────┴─────┐
    │           │          │           │
   YES         NO        YES          NO
    │           │          │           │
  Claude     Claude      Manual     Add
  Code       Code +      (timing,   logging +
  (direct)   relevant    concurr.)  reproduce
             code

Quick rules

If the bug is...Use...
A clear stack trace with a known errorClaude Code
Logs with a visible patternClaude Code
An intermittent error with no patternLogging + manual debugging
Slow but doesn't failProfiler
An incorrect result with no errorpdb + value inspection
Only occurs under loadLoad testing + logging
Only occurs in productionProduction logs + compare with development
Depends on timing between requestsConcurrency tests
An error in a business calculationA human who knows the rules

Example: Switching from Claude Code to Manual

The scenario

Your POST /api/notifications/batch endpoint sometimes loses notifications. Of a batch of 100, only 95-98 are sent. There are no errors in the logs.

Attempt 1 with Claude Code

My batch notifications endpoint loses notifications. Of 100, 
it only sends 95-98. There are no errors in the logs. Everything returns 200.

Code:
"""python
import asyncio
from fastapi import FastAPI
from app.services import notification_service

app = FastAPI()

@app.post("/api/notifications/batch")
async def send_batch(notifications: list[dict]):
    tasks = [
        notification_service.send(n) 
        for n in notifications
    ]
    results = await asyncio.gather(*tasks, return_exceptions=True)

    sent = sum(1 for r in results if not isinstance(r, Exception))
    return {"total": len(notifications), "sent": sent}
"""

Claude Code says: "The return_exceptions=True catches exceptions silently. The notifications that fail are counted as exceptions but aren't logged. Add logging for the exceptions."

Tests: You add logging. The logged exceptions are TimeoutError — the notification service doesn't respond in time for some. But the timeout is 30 seconds, and the notifications are processed in 2-3 seconds.

Attempt 2 with Claude Code

I followed your suggestion. The notifications that fail raise a TimeoutError, 
but the timeout is 30s and they normally take 2-3s. Why do some 
time out?

Claude Code says: "It could be rate limiting from the external service, or a concurrency problem with 100 simultaneous requests."

Tests: You limit it to 10 concurrent requests. It still loses 1-2 of every 100.

Switch to manual debugging (the two-attempt rule)

You use detailed logging with timestamps:

import asyncio
import time
import logging
from fastapi import FastAPI

app = FastAPI()
logger = logging.getLogger("batch")

@app.post("/api/notifications/batch")
async def send_batch(notifications: list[dict]):
    tasks = []
    for i, n in enumerate(notifications):
        logger.debug(f"Creating task {i}: {n.get('type')}")
        tasks.append(send_with_tracking(i, n))

    results = await asyncio.gather(*tasks, return_exceptions=True)

    for i, r in enumerate(results):
        if isinstance(r, Exception):
            logger.error(f"Task {i} failed: {type(r).__name__}: {r}")
        else:
            logger.debug(f"Task {i} success: {r}")

    sent = sum(1 for r in results if not isinstance(r, Exception))
    failed = sum(1 for r in results if isinstance(r, Exception))
    logger.info(f"Batch complete: {sent} sent, {failed} failed")
    return {"total": len(notifications), "sent": sent, "failed": failed}

async def send_with_tracking(index: int, notification: dict):
    start = time.perf_counter()
    try:
        result = await notification_service.send(notification)
        elapsed = time.perf_counter() - start
        logger.debug(f"Task {index} completed in {elapsed:.3f}s")
        return result
    except Exception as e:
        elapsed = time.perf_counter() - start
        logger.error(f"Task {index} failed after {elapsed:.3f}s: {e}")
        raise

What you discover: The tasks that fail are always the last ones in the batch. The notification service has a connection pool of 50 connections. With 100 simultaneous requests, the last 50 wait for an available connection and some time out.

The real fix: Limit concurrency with a semaphore:

import asyncio

CONCURRENCY_LIMIT = 20
semaphore = asyncio.Semaphore(CONCURRENCY_LIMIT)

async def send_with_semaphore(notification: dict):
    async with semaphore:
        return await notification_service.send(notification)

Claude Code would never have reached this diagnosis because it requires observing the real timing of the connections.


Connection to the Project

How it applies to the capstone project (Module 8)

The capstone project includes at least one bug that can't be resolved with Claude Code alone — possibly a business logic bug where you need to understand the requirement to know whether the code is correct, or a performance bug that requires profiling. The ability to recognize "this isn't a case for Claude Code" and switch to manual tools is part of the assessment.


Troubleshooting

Problem 1: "I don't know whether I should keep trying with Claude Code or switch to manual"

Cause: You haven't defined a clear criterion for when to stop. Solution: Apply the two-attempt rule. If Claude Code's second diagnosis doesn't resolve the bug, switch. Don't feel guilty about "abandoning" Claude Code — you're choosing the correct tool.

Problem 2: "pdb is confusing and I don't know which commands to use"

Cause: pdb has a learning curve. Solution: You only need 5 commands to start:

n     → next line (next)
s     → step into a function (step)
c     → continue to the next breakpoint (continue)
p var → print a variable's value (print)
q     → quit (quit)

Problem 3: "The bug only appears in production"

Cause: Differences in environment, data, or load between development and production. Solution: Add exhaustive logging (at DEBUG levels) and deploy. Capture the logs when the error occurs. If you can't add logging (compiled code, external service), use observability tools (Datadog, New Relic, OpenTelemetry).

Problem 4: "I don't know which profiling tool to use"

Cause: There are many options and each has a different use case. Solution: Always start with the simplest:

  1. Slow endpoint: Add the timing middleware shown in this capsule
  2. Slow function: time.perf_counter() at the start and end
  3. Slow query: SQLALCHEMY_ECHO=True to see the queries
  4. If you need more detail: cProfile for CPU, tracemalloc for memory

Exercises

Exercise 1: Classify bugs (Easy)

For each bug, indicate whether Claude Code can help or whether you need manual debugging:

  1. KeyError: 'user_id' with a clear stack trace
  2. An endpoint that returns correct results 95% of the time and incorrect results 5%, with no errors
  3. An endpoint that takes 15 seconds when it normally takes 200ms
  4. ImportError: cannot import name 'field_validator' with a stack trace
  5. An endpoint that works in development but fails in production with the same input
See solution
  1. Claude Code ✅ — A known error with a clear stack trace. Claude Code can diagnose which key is missing and why.

  2. Manual ❌ — No errors, intermittently incorrect results. You need to inspect the values at runtime to understand which condition causes the incorrect result. A possible race condition or a data-dependent logic bug.

  3. Both ⚠️ — Claude Code can identify anti-patterns (N+1 queries, inefficient loops) by looking at the code. But to confirm the real bottleneck you need profiling.

  4. Claude Code ✅ — An import error with a stack trace. Claude Code can tell you that field_validator is from Pydantic v2 and suggest checking the installed version.

  5. Manual ❌ — Environment differences Claude Code can't see. You need to compare: library versions, environment variables, the state of the DB, server configuration.

Exercise 2: Choose the manual tool (Medium)

For each scenario, indicate which manual debugging tool you'd use and why:

  1. A calculation function returns an incorrect result but you don't know where the calculation goes wrong
  2. Your application consumes 2GB of RAM after a few hours of running
  3. Your endpoint is slow but you don't know whether it's the SQL query, the Python processing, or the call to an external API
  4. Two users doing the same operation simultaneously cause inconsistent data
See solution
  1. pdb (Python debugger) — You put a breakpoint at the start of the function and step through it (the n command), inspecting variables (the p variable command) at each point to find where the value goes off the expected track.

  2. tracemalloc — You activate it at the start of the application and take periodic snapshots to identify which objects are growing in memory and which lines of code create them.

  3. Logging with timestamps + timing middleware — You put timestamps around each operation (query, processing, API call) to measure how long each takes. The endpoint middleware gives you the total time, and the internal timestamps tell you where the time goes.

  4. Concurrency tests + logging — You write a test that runs the operation with asyncio.gather() for two users simultaneously and verify that the final data is consistent. Logging with timestamps shows the sequence of operations of each thread/task.

Exercise 3: Apply the two-attempt rule (Medium)

Your PATCH /api/settings endpoint returns 200 but the settings aren't saved. You passed the error to Claude Code twice:

Attempt 1: Claude Code suggested "add db.commit() after modifying the object." You already had it.

Attempt 2: Claude Code suggested "verify that you're using the same DB session to read and write." You verified it — it's the same.

What would you do as the next step? Design your manual debugging plan.

See solution

Manual debugging plan:

Step 1: Verify that the SQL UPDATE runs

# Activate SQLAlchemy logging
import logging
logging.getLogger('sqlalchemy.engine').setLevel(logging.DEBUG)

This shows the exact SQL that runs. If you don't see an UPDATE, the ORM isn't detecting changes.

Step 2: If the UPDATE runs, verify with pdb

@app.patch("/api/settings")
async def update_settings(data: dict, db: Session = Depends(get_db)):
    settings = db.query(Settings).first()
    
    import pdb; pdb.set_trace()
    # Inspect:
    # (Pdb) p settings.__dict__
    # (Pdb) p db.dirty  # modified objects in the session
    
    for key, value in data.items():
        setattr(settings, key, value)
    
    # (Pdb) p db.dirty  # are there dirty objects now?
    
    db.commit()
    
    # (Pdb) p settings.__dict__  # did the values change?

Step 3: If the UPDATE runs but the data doesn't persist

# Check directly in the DB
psql -d mydb -c "SELECT * FROM settings;"
# Are the values updated in the DB?

Possible causes Claude Code can't diagnose:

  • The DB session has autorollback (the changes are undone after the commit)
  • There's a middleware that opens a transaction and rolls it back
  • The endpoint responds before the commit completes (an async issue)
  • The model has an after_update event that reverts the change
  • The DB connection uses a read replica different from the write one

Exercise 4: Identify the type of bug (Hard)

Read this code and the bug report. Determine: (a) whether Claude Code can help, (b) what tool you need, (c) your initial hypothesis.

Report: "The dashboard visit counter shows different numbers each time I reload the page. Sometimes it shows 150, sometimes 148, sometimes 152. It should always show the same number if no one else is using the system."

from fastapi import FastAPI
from datetime import datetime, timedelta

app = FastAPI()
visit_counts = {}

@app.get("/api/dashboard/visits")
async def get_visits():
    now = datetime.utcnow()
    today = now.date().isoformat()

    if today not in visit_counts:
        visit_counts[today] = 0
    visit_counts[today] += 1

    yesterday = (now - timedelta(days=1)).date().isoformat()

    return {
        "today": visit_counts.get(today, 0),
        "yesterday": visit_counts.get(yesterday, 0)
    }
See solution

(a) Can Claude Code help?

Yes, partially. Claude Code can identify the bug by reading the code — it doesn't need runtime access for this case.

(b) What tool do you need?

For this specific bug, reading the code carefully is enough. If it were a case where the counter is stored in a DB or Redis and fluctuates, you'd need logging with timestamps or pdb.

(c) Hypothesis:

The bug is that the endpoint INCREMENTS the counter every time it's called. visit_counts[today] += 1 runs on every request. So every time you reload the page to see the visits, you're creating a new visit.

The "different number each time" is explained because:

  • If you reload fast: +1 per reload
  • If uvicorn has multiple workers: each worker has its own visit_counts in memory, and requests are distributed among workers

Necessary fixes:

  1. Separate "count a visit" from "query visits" (different endpoints)
  2. If you need to count dashboard visits, do it with a middleware, not in the query endpoint
  3. Move visit_counts to a persistent store (Redis, DB) if there are multiple workers

Note: This is a case where Claude Code CAN diagnose the problem because it's visible in the code. But the fluctuation between workers (148 vs 150 vs 152) is only explained with knowledge of how uvicorn handles processes.


Summary

In this capsule you learned:

  • Claude Code works with text — everything that's runtime (memory, timing, connections, state) is out of its reach
  • Race conditions need logging with timestamps and concurrency tests
  • Business logic bugs need a human who knows the rules
  • Performance issues need profiling (cProfile, py-spy, tracemalloc)
  • State/memory bugs need pdb or inspection tools
  • Environment bugs need verification of versions, variables, and services
  • The two-attempt rule: if Claude Code fails twice, switch to manual debugging
  • Your manual toolbox: pdb, cProfile, tracemalloc, logging with timestamps, concurrency tests

Next capsule: Real Debugging Exercise — apply everything you learned to an application with bugs.


Additional resources

  1. Python pdb — The Python Debugger - Official documentation of Python's debugger
  2. Python cProfile - CPU profiling in Python
  3. tracemalloc — Trace Memory Allocations - Memory leak debugging
  4. py-spy — Sampling Profiler for Python - A profiler that doesn't modify code
  5. Locust — Load Testing Tool - Load tests to find concurrency bugs
  6. SQLAlchemy — Logging Configuration - See the SQL queries executed

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