Skip to main content
Resources · 7 sections

Python Debugging Guide for Coursework

Most students debug by sprinkling 12 print() calls through the code, rerunning the script, and praying one of them lands near the bug. There is a 2-character upgrade. Type breakpoint() on the suspicious line, run the script, and Python drops you into a live shell with every variable available. This guide covers pdb, the VS Code debugger, the logging module, and 6 bug patterns that show up in every intro Python course. Every code block is runnable.

Section 1

breakpoint() is the modern entry point to pdb

Python 3.7 added breakpoint() as a built-in. Drop the call anywhere in your code, run the script normally, and execution pauses at that line inside the pdb debugger. No imports, no command-line flags, no IDE config.

The 2-character upgrade over print debugging

# hw3/src/analysis.py
def compute_class_average(scores):
    total = 0
    for s in scores:
        breakpoint()        # execution pauses here
        total += s
    return total / len(scores)

if __name__ == "__main__":
    print(compute_class_average([88, 72, 95, 60]))

Run python src/analysis.py. Python prints the file path, the line, and a (Pdb) prompt. Every local variable is accessible. Type s and Enter to see the current score. Type total to see the running sum.

The 9 pdb commands that handle 95% of sessions

Command What it does
n Next line in the current function. Skips into called functions.
s Step into the next called function. Use when the bug lives one frame deeper.
c Continue execution until the next breakpoint() or program end.
l List 11 lines of source around the current line. ll shows the whole function.
p <expr> Print the value of any expression. Works for variables, function calls, slices.
pp <expr> Pretty-print. Use on dicts and lists with more than 8 items.
w Where: print the call stack. Arrow marks the current frame.
q Quit the debugger and exit the program.
h Help. h n explains the n command.

Two further commands earn their keep on rare days: u (up a frame) and d (down a frame). The pair lets you walk back through the call stack to see the caller's locals without restarting.

Disable breakpoints without removing the lines

# Set the env var before running
PYTHONBREAKPOINT=0 python src/analysis.py

# Every breakpoint() call becomes a no-op.
# Useful for assignment submissions: keep
# the breakpoint() lines for next time
# you debug, but ship a clean run.
Section 2

An annotated pdb session on a real bug

Below is the kind of off-by-one bug that turns up in every intro course. A 12-line function reads grades, drops the bottom n scores, and returns the mean. The output is one point off. Walk through it with pdb.

The buggy function

# src/grades.py
def mean_after_drop(scores, drop_lowest):
    """Drop the N lowest scores, then average the rest."""
    sorted_scores = sorted(scores)
    kept = sorted_scores[drop_lowest:]
    breakpoint()
    total = 0
    for s in kept:
        total += s
    return total / len(scores)   # <- bug lives here

if __name__ == "__main__":
    grades = [70, 80, 85, 90, 95]
    print(mean_after_drop(grades, drop_lowest=1))

Expected mean of [80, 85, 90, 95] is 87.5. The function returns 70.0. Time to step through.

The pdb session, line by line

$ python src/grades.py
> /hw3/src/grades.py(6)mean_after_drop()
-> total = 0
(Pdb) l
  1     def mean_after_drop(scores, drop_lowest):
  2         """Drop the N lowest scores, then average the rest."""
  3         sorted_scores = sorted(scores)
  4         kept = sorted_scores[drop_lowest:]
  5         breakpoint()
  6  ->     total = 0
  7         for s in kept:
  8             total += s
  9         return total / len(scores)
 10
 11     if __name__ == "__main__":

(Pdb) p sorted_scores
[70, 80, 85, 90, 95]

(Pdb) p kept
[80, 85, 90, 95]

(Pdb) p len(kept)
4

(Pdb) p len(scores)
5

(Pdb) n
> /hw3/src/grades.py(7)mean_after_drop()
-> for s in kept:

(Pdb) c
> /hw3/src/grades.py(9)mean_after_drop()
-> return total / len(scores)
(Pdb) p total
350

(Pdb) p total / len(scores)
70.0

(Pdb) p total / len(kept)
87.5

(Pdb) q

The bug surfaces at the moment total / len(scores) is computed: the denominator is 5, not 4. Replace len(scores) with len(kept) and the function returns 87.5. The fix took 30 seconds in pdb; a print-debug version of the same hunt would take 4 to 6 reruns.

Section 3

VS Code debugger for graphical step-through

Some bugs read better in a GUI. The VS Code debugger renders the call stack, locals, and a watch panel in three side-by-side columns. Set up the Python extension first (covered in Python Dev Setup for University Coursework).

Set a breakpoint with one click

Click the gutter to the left of any line number. A red dot appears. Press F5. VS Code prompts for a debug configuration; pick Python File. Execution stops on the red-dot line. The left sidebar fills with five panels: Variables, Watch, Call Stack, Breakpoints, and Loaded Scripts.

The top toolbar gives the same controls as pdb in icon form: Continue (F5), Step Over (F10, equivalent to pdb n), Step Into (F11, equivalent to pdb s), Step Out (Shift+F11), Restart, Stop.

.vscode/launch.json for running with arguments

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Python: Current File",
      "type": "debugpy",
      "request": "launch",
      "program": "${file}",
      "console": "integratedTerminal",
      "justMyCode": true
    },
    {
      "name": "Python: Current File with args",
      "type": "debugpy",
      "request": "launch",
      "program": "${file}",
      "args": ["--input", "data/grades.csv", "--drop", "2"],
      "console": "integratedTerminal",
      "justMyCode": true
    },
    {
      "name": "Python: pytest",
      "type": "debugpy",
      "request": "launch",
      "module": "pytest",
      "args": ["-v", "tests/"],
      "console": "integratedTerminal",
      "justMyCode": false
    }
  ]
}

The first config debugs whichever .py file is open. The second feeds command-line arguments to the same file. The third runs pytest under the debugger so you can step into a failing test. justMyCode: false lets you step into pandas, sklearn, or any library you imported; set it to true to stay inside your own files.

Watch expressions beat repeated print()

Right-click any variable in the editor and pick Add to Watch. The expression evaluates on every step. Watch len(kept) alongside len(scores) and the off-by-one bug from section 2 is visible without typing a single p command.

Section 4

The logging module beats print for any program longer than 50 lines

Print is fine for a 20-line script. Once a homework grows past main.py calling 3 helper modules, the logging stdlib module pays for itself. Levels let you silence noise without deleting lines. Format strings encode timestamps. File handlers write to disk while you also see output on stderr.

Minimal logging setup at the top of your script

# src/main.py
import logging

logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
    datefmt="%H:%M:%S",
)

logger = logging.getLogger(__name__)

def load_csv(path):
    logger.debug("reading file %s", path)
    rows = open(path).read().splitlines()
    logger.info("loaded %d rows from %s", len(rows), path)
    if len(rows) == 0:
        logger.warning("file is empty: %s", path)
    return rows

def parse_grade(s):
    try:
        return float(s)
    except ValueError:
        logger.error("could not parse grade: %r", s)
        raise

if __name__ == "__main__":
    rows = load_csv("data/grades.csv")
    grades = [parse_grade(r) for r in rows]

The 5 logging levels and when each fires

Level Use it for
DEBUG Variable values, loop iteration counts, intermediate state. Volume is high; silenced in submission.
INFO Milestones: "loaded 4520 rows", "wrote output to results.csv". Useful in the assignment writeup.
WARNING Recoverable surprises: empty file, missing column with a default, version mismatch.
ERROR A handled exception. Function returned None instead of a result.
CRITICAL The program cannot continue. Used rarely in coursework.

Silence logging at submission time

# In your submission script
import logging

# Show INFO and above only
logging.basicConfig(level=logging.INFO)

# Or silence everything below CRITICAL
# logging.basicConfig(level=logging.CRITICAL)

Print-debug requires deleting every print() before submission, which risks accidentally deleting the lines that produce the assignment's required output. With logging, one line at the top of the file silences debug output without touching the logic. Gradescope autograders ignore stderr, where logging writes by default; print goes to stdout, which the autograder reads as the answer.

Section 5

Print debugging that does not waste your time

Print is the right tool for one-off scripts, Jupyter cells, and any code under 50 lines. The trick is doing it well.

4 rules for print debugging

  1. Print f-strings with the variable name spelled out: print(f"x = {x!r}"). The !r shows quotes around strings so "" and "None" stay distinct.
  2. Print before and after the suspected line. If the value is correct on the way in and wrong on the way out, the bug is between those two prints.
  3. Use the = shorthand: print(f"{kept=}") prints kept=[80, 85, 90, 95]. Saved on Python 3.8+.
  4. Delete every debug print before submission. Gradescope reads stdout; an extra print("here") fails the autograder.

icecream for quality-of-life print debugging

# Requires: pip install icecream
from icecream import ic

def mean_after_drop(scores, drop_lowest):
    sorted_scores = sorted(scores)
    ic(sorted_scores)
    kept = sorted_scores[drop_lowest:]
    ic(kept, len(kept), len(scores))
    total = sum(kept)
    ic(total / len(kept))
    return total / len(kept)

ic() prints the expression and its value with the file path and line number prepended. Output looks like ic| grades.py:4 in mean_after_drop()- kept: [80, 85, 90, 95], len(kept): 4, len(scores): 5. Disable globally with ic.disable(). Third-party, but the install pays for itself the first time you debug a multi-file project.

Section 6

6 bug patterns and the debugger move that finds each

These show up in every intro Python course. Each row pairs the symptom with the diagnostic technique and the fix. Memorize the pattern; recover 30 minutes per assignment.

Off-by-one in range or slice

Symptom: Last element missing, or first element duplicated.
Find it: Drop breakpoint() at the loop top. p len(seq) and p i at the boundary.
Fix: Remember range(n) yields 0 to n-1. a[i:j] includes index i and excludes j.

Mutable default argument

Symptom: A function with def f(x, log=[]) grows the list across calls.
Find it: Call the function 3 times with no log= argument. Print log each time. Length grows.
Fix: Use log=None and create the list inside the function body.

Late-binding closure

Symptom: A list of lambdas all return the final loop value.
Find it: Step into one lambda with s in pdb. Inspect the captured variable.
Fix: Bind the loop variable as a default arg: lambda x, n=n: x + n.

Dict changed during iteration

Symptom: RuntimeError: dictionary changed size during iteration.
Find it: The traceback names the line. The line iterates and mutates simultaneously.
Fix: Iterate over list(d.keys()) or build the result in a new dict.

Infinite recursion

Symptom: RecursionError: maximum recursion depth exceeded.
Find it: Add breakpoint() at the function top. Step through 2 calls; check whether the argument moves toward the base case.
Fix: Add the missing base case or fix the recursive argument so it shrinks.

Missing return statement

Symptom: Function appears correct but every call yields None.
Find it: print(f"{result=}") on the caller side. None means no return.
Fix: Add return to every code path inside the function.

Section 7

Read tracebacks from the bottom up

A Python traceback is a stack trace, printed oldest-call-first, newest-call-last. The bug lives at the bottom. Read it that way.

A real traceback, annotated

Traceback (most recent call last):
  File "src/main.py", line 22, in <module>
    grades = [parse_grade(r) for r in rows]
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "src/main.py", line 22, in <listcomp>
    grades = [parse_grade(r) for r in rows]
              ^^^^^^^^^^^^^^
  File "src/main.py", line 17, in parse_grade
    return float(s)
           ^^^^^^^^
ValueError: could not convert string to float: 'A+'

Three rules for reading this.

  1. The error name + message is the last line. ValueError: could not convert string to float: 'A+' tells you what and the offending value.
  2. The deepest call site is immediately above. src/main.py line 17, return float(s). The bug is here.
  3. Earlier frames show how the bug was reached. The list comprehension on line 22 called parse_grade, which called float(). The fix lives at line 17: handle non-numeric grades before the float() cast.

One more shortcut: pdb on a crash

python -m pdb -c continue src/main.py

# When the program crashes, pdb drops into
# post-mortem mode at the crash frame.
# Every local variable is still in scope.
# Type "p s" to see the string that failed.

Inside a Jupyter notebook the equivalent is the %debug magic. Run the cell, hit the exception, type %debug in a new cell. You land at the failure frame with full pdb access. Works in JupyterLab, Colab, and VS Code notebooks.

Stuck on a bug for more than an hour?

Send the file, the traceback, and the input that triggers it. A named developer diagnoses the bug, ships a tested fix against your Python version, and includes the walkthrough you need for the class discussion. Pay 50% to start, 50% after the code runs on your data.