Command Palette

Search for a command to run...

Hectal
PHASE 7Intermediate ~31 min· topic 10 of 12

Topic 7.10

Debugging: breakpoint(), pdb and Reading State

In one line

Debugging is finding the first place where what your program holds differs from what you believed. You can do it with well-labelled prints, with breakpoint() and the pdb debugger, with your editor's debugger, and with a systematic method such as bisection.

Think of it like this

A doctor with a patient. A good doctor does not guess. They ask where it hurts, measure the temperature, test one idea at a time, and narrow the cause step by step. Debugging a program is the same: observe the real state, form one hypothesis, test it, and repeat. Random changes ("maybe if I add this line...") are like giving every medicine in the cupboard.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Debugger
A tool that lets you pause a running program and look at or change its state.
pdb
Python's built-in command-line debugger.
Breakpoint
A marked line where the program pauses and hands control to the debugger.
Step
Move the debugger forward by one line (or into a function call).
Call stack
The chain of function calls that led to the current line.
Watch expression
A value or formula the debugger shows after every step.
Bisection
Finding a bug by repeatedly testing the middle of the possibilities and discarding half.
Minimal reproducible example
The smallest program and input that still shows the bug.

Step by step

01Label your prints and use the = form

A bare print(x) leaves you guessing which line produced it. Naming the value fixes that. f"{x=}" prints both the expression and its value, and repr shows the exact contents of strings.

main.pywhole filepython
data = [5, 0, 7]
print(f"{data=}")
print(f"{len(data)=}")
value = "5\n"
print(value)           # looks like 5
print(repr(value))     # '5\n': the newline is visible

02Find the first wrong value

Suppose a function should keep every number but loses the zeros. Print the input, then the output, then compare. The difference tells you the function is the culprit, and the lost items tell you the rule that is wrong. Here if v: is false for 0 as well as for empty values (these are called falsy, Topic 1.4), so the zero is dropped.

main.pywhole filepython
def clean(values):
    result = []
    for v in values:
        if v:                 # bug: 0 is falsy, so it is dropped
            result.append(v)
    return result

print(clean([5, 0, 7]))       # [5, 7]

03Stop with breakpoint()

Put breakpoint() on the line before the suspicious one and run the script from a terminal. The debugger opens at that line. The transcript below is a real session; the file path is shortened. p prints a value, l lists the source (the arrow marks the next line to run), n runs one line, w shows the call stack and c continues to the end.

buggy.pywhole filepython
def average(scores):
    total = 0
    for s in scores:
        total += s
    breakpoint()
    return total / len(scores)


print("average:", average([80, 90, 100]))
terminal
$ python buggy.py
── expected output ──
> buggy.py(6)average()
-> return total / len(scores)
(Pdb) p total
270
(Pdb) p scores
[80, 90, 100]
(Pdb) l
1 def average(scores):
2 total = 0
3 for s in scores:
4 total += s
5 breakpoint()
6 -> return total / len(scores)
7
8
9 print("average:", average([80, 90, 100]))
[EOF]
(Pdb) n
--Return--
> buggy.py(6)average()->90.0
-> return total / len(scores)
(Pdb) w
buggy.py(9)<module>()
-> print("average:", average([80, 90, 100]))
> buggy.py(6)average()->90.0
-> return total / len(scores)
(Pdb) c
average: 90.0

04The pdb commands you need

Learn these eight and you can debug most things. Any other text you type at the prompt is run as Python in the current frame, so you can call functions, change variables and test a fix before editing the file.

The pdb commands you needdiagram
Rendering diagram…

05Check state with assertions

An assert that states what must be true turns a vague wrong answer later into an exact failure at the place where the state first goes wrong (Topic 7.2). Use them while debugging and for internal assumptions.

main.pywhole filepython
def apply_discount(price, percent):
    assert 0 <= percent <= 100, f"percent out of range: {percent}"
    return price * (100 - percent) / 100

06Bisect to find where it breaks

Imagine 100 versions of a program where version 64 and later are broken. Testing the middle each time finds the first broken one in about seven checks instead of up to a hundred. The same idea works on a list of inputs or the steps of a pipeline. The while loop (Topic 2.3) keeps cutting the range low to high in half; // is whole-number division (Topic 1.7).

main.pywhole filepython
def works(version):
    return version < 64

low, high = 1, 100           # low is known good (or the start), high is bad
while low < high:
    mid = (low + high) // 2
    if works(mid):
        low = mid + 1
    else:
        high = mid
print("first bad version:", low)
Bisect to find where it breaksdiagram
Rendering diagram…

Try it yourself

  1. 1

    Fix, then prove it

    In the first example change if v: to if v is not None: and run it again. Which values are lost now? Explain the difference between the two conditions (Topic 1.4).

  2. 2

    Try pdb on your computer

    Save buggy.py from the walkthrough on your computer and run it in a terminal. Try p total, n, l, w and c. The browser has no keyboard for the debugger, so this step needs your own Python (Topic 0.4).

  3. 3

    Bisect a list

    In the bisect example, replace works with a function that returns False when a number is 777 or larger and search 1..1000. How many checks does it need? What is the formula? (About log2 of the size.)

Code & diagrams

Print debugging done well New tab

Read the labelled lines, find where the data shrinks, then fix the condition (use v is not None) and run again.

Sign in to run this example in your browser.

Expected output

data=[5, 0, 7, None, 3]
cleaned=[5, 7, 3]
len(data)=5 len(cleaned)=3
lost=[0, None]
repr shows what print hides New tab
Sign in to run this example in your browser.

Expected output

5

'5\n'
' Asha ' 'Asha'
<class 'str'> False True
count='5'  type(count).__name__='str'
Assertions catch the first wrong state New tab

The failure names the exact value that broke the rule, right where it first appears.

Sign in to run this example in your browser.

Expected output

10 -> 180.0
50 -> 100.0
150 -> state check failed: percent out of range: 150
Bisecting a bug New tab

Seven checks find the first bad version out of 100. Change 64 to another number and see that the count stays about the same.

Sign in to run this example in your browser.

Expected output

check 1: version 50 is ok
check 2: version 75 is broken
check 3: version 63 is ok
check 4: version 69 is broken
check 5: version 66 is broken
check 6: version 65 is broken
check 7: version 64 is broken
first bad version: 64 after 7 checks
Read a traceback in code New tab

The traceback module gives you the same report that Python prints, as text you can log.

Sign in to run this example in your browser.

Expected output

lines in the report: 7
last line: ValueError: invalid literal for int() with base 10: 'two'
failing call: return int(text)
Starting the debugger from a terminalbash
# Stop at every breakpoint() in the script
python script.py

# Run the whole script under the debugger (post-mortem on a crash)
python -m pdb script.py

# Turn every breakpoint() call into a no-op, for example in production
PYTHONBREAKPOINT=0 python script.py

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

A state check that fails

Call the discount function with an impossible value.

main.pywhole filepython
def apply_discount(price, percent):
    assert 0 <= percent <= 100, f"percent out of range: {percent}"
    return price * (100 - percent) / 100

print(apply_discount(200, 150))
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
File "main.py", line 5, in <module>
print(apply_discount(200, 150))
~~~~~~~~~~~~~~^^^^^^^^^^
File "main.py", line 2, in apply_discount
assert 0 <= percent <= 100, f"percent out of range: {percent}"
^^^^^^^^^^^^^^^^^^^
AssertionError: percent out of range: 150

Break #2

A number that is really text

Add one to a value that looks like a number.

main.pywhole filepython
count = "5"
print(count)          # looks like a number
print(count + 1)
terminal
$ python main.py
── what you'll see ──
5
Traceback (most recent call last):
File "main.py", line 3, in <module>
print(count + 1)
~~~~~~^~~
TypeError: can only concatenate str (not "int") to str

Myth vs fact

Myth

Good programmers don't need debuggers or print statements.

Fact

Everyone debugs. The skill is doing it with a method rather than by guessing.

Myth

The line in the traceback is where the bug is.

Fact

It is where the error was noticed. The wrong value may have been created many lines or calls earlier.

Myth

Debuggers are for big programs only.

Fact

A debugger helps most when you are learning, because you can watch each line change the variables.

Myth

If I change code until it works, I have fixed the bug.

Fact

Without knowing the cause you may have hidden it. Explain why the failure happened and add a test that fails before the fix and passes after.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    python -X dev and python -W error make hidden problems louder (resource warnings, deprecations), and faulthandler (enabled by -X faulthandler) prints a traceback on a crash or hang. sys.settrace and sys.monitoring (3.12) are the low-level hooks debuggers and coverage tools are built on.

  • ▸

    breakpoint() calls sys.breakpointhook, which defaults to pdb. The environment variable PYTHONBREAKPOINT can point it at another debugger, for example ipdb or debugpy for remote debugging of code in a container.

  • ▸

    For intermittent bugs, log (Topic 7.9) rather than pause: record inputs and outputs of the suspicious step with a request id, and reproduce from the log. Debugging a program that calls an AI model starts by saving the exact prompt and response, since they are the inputs that you cannot recreate.

  • ▸

    Heisenbugs (bugs that disappear when you look) usually come from timing, shared state or unseeded randomness. Seed random generators and remove concurrency first, then add it back.

  • ▸

    Post-mortem on demand: import pdb; pdb.pm() in a REPL opens the debugger on the last uncaught exception, and python -m pdb -c continue script.py runs the script and stops in the debugger only if it crashes. Python 3.14 can also attach pdb to a process that is already running with python -m pdb -p PID (the process must belong to you), which helps with a hung worker you cannot restart. Keep that in mind as a security boundary: anyone able to attach to a process can run code in it.

  • ▸

    A debugger changes timing, so for async code and threads prefer logging and asyncio debug mode (Topic 12.9) to stepping. Use conditional breakpoints (b 42, i == 998) and commands to print values and continue automatically, which gives you print debugging without editing the file.

Remember this

  1. 1

    The first rule: a bug is the first place where what you believe is true and what the program actually holds differ. Your job is to find that place. Read the traceback first (Topic 0.7): it tells you the line where the error appeared. Often the real mistake is earlier, where a wrong value was created. (A traceback is the error report Python prints.) So work backwards from the symptom: where did this value come from?

  2. 2

    Print debugging is the oldest and still useful tool. Do it well: label every print (print("after clean:", data)), use the f-string = form which shows the name and the value (print(f"{count=}")), print repr(value) when you suspect hidden characters ('5' versus 5, a trailing '\n'), and print types. Remove the prints afterwards, or better, turn useful ones into logging.debug calls (Topic 7.9).

  3. 3

    **breakpoint() pauses the program at that line and opens the interactive debugger, pdb**, in your terminal. At the (Pdb) prompt you can look at anything. p variable prints it, pp pretty-prints, l lists the code around you, and w shows the call stack (the chain of calls, Topic 7.1). You can also move. n runs the next line, s steps into a call, r runs until this function returns, c continues until the next breakpoint, and q quits. b 42 sets a breakpoint at line 42.

  4. 4

    Anything else you type at the prompt, such as total * 2, is run as Python and its value is shown. Setting the environment variable PYTHONBREAKPOINT=0 (a setting you give the program from outside, before it starts) turns every breakpoint() call into a no-op.

  5. 5

    After a crash, use post-mortem debugging. python -m pdb script.py starts the program under the debugger and drops you into it at the point of an uncaught exception, with all the variables still alive. In an editor like VS Code, the same power comes with a mouse. Click in the margin to set a breakpoint, press F5, and inspect variables. Step with the toolbar and add watch expressions. Conditional breakpoints ("stop only when i == 998") save you from stepping through a thousand loops.

  6. 6

    Bisection is the most powerful method when you do not know where the bug is. Take the path from "it worked" to "it is broken" and test the middle. If the middle is fine, the bug is in the second half. If not, it is in the first. Each test halves the search, so a thousand steps take about ten checks. You can bisect code (comment out half), data (does it fail with the first half of the input?), steps of a pipeline (print the state midway), and history. git bisect does the last one automatically across commits (see the Git course).

  7. 7

    Make the bug small and repeatable first. Reduce the input to the shortest one that still fails (a minimal reproducible example). Fix the random seed (the starting number that makes random choices repeat). Write the failing case down as a test (Phase 11) so the bug cannot come back.

  8. 8

    Finally try the rubber-duck method: explain the code line by line, out loud, to a rubber duck or a friend. Saying it forces you to notice the step you assumed. This matters in AI programs, where the code around a model is exact and testable while the model's own output varies. Check your inputs and parsing first.

Explain it without notes

01

Describe a systematic way to debug a program that gives a wrong answer.

02

What does breakpoint() do, and what are the basic pdb commands?

03

Why is the line shown in a traceback not always where the bug is?

04

What is bisection and how does it apply to debugging?

Practice

01

Write a function first_negative(numbers) that returns the first negative number, or None if there is none. Call it with [3, 8, -2, 5] and print the result with print(f"{result=}"). It should print result=-2.

02

The function def total(items): return sum(item["price"] * item["qty"] for item in items) fails with KeyError: 'qty' on one item. Take a list of three dictionaries where one has no "qty" key. Write a loop that prints the position (counting from 0) of each item that is missing qty. Hint: enumerate gives you the position.

03

Using the bisect idea, find the first whole number from 1 to 1000 whose square is at least 50000. Count the checks as you go, and print the number and the count on one line. Hint: copy the while low < high loop and change the test to mid * mid >= 50000.

Trade-offs

  • ↔

    Prints versus a debugger. Prints are quick, work anywhere (servers, notebooks, containers) and leave a record, but you must predict what to print. A debugger lets you explore freely, at the cost of setup and pausing the program.

  • ↔

    Asserts versus logging. Assertions stop at the first broken assumption, which is ideal while developing. In production you often prefer a logged error and a graceful response over a crash.

Done when you can

  • Done when you can read a traceback and work backwards to the line that created the wrong value.

  • Done when you can debug with labelled prints, the = f-string form and repr.

  • Done when you can start pdb with breakpoint() and use p, l, n, s, c, w and q.

  • Done when you can explain bisection and use it to narrow a bug.

  • Done when you can reduce a bug to a minimal example and turn it into a test.