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.
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 visible02Find 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.
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.
def average(scores):
total = 0
for s in scores:
total += s
breakpoint()
return total / len(scores)
print("average:", average([80, 90, 100]))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.
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.
def apply_discount(price, percent):
assert 0 <= percent <= 100, f"percent out of range: {percent}"
return price * (100 - percent) / 10006Bisect 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).
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)Try it yourself
- 1
Fix, then prove it
In the first example change
if v:toif v is not None:and run it again. Which values are lost now? Explain the difference between the two conditions (Topic 1.4). - 2
Try pdb on your computer
Save
buggy.pyfrom the walkthrough on your computer and run it in a terminal. Tryp total,n,l,wandc. The browser has no keyboard for the debugger, so this step needs your own Python (Topic 0.4). - 3
Bisect a list
In the bisect example, replace
workswith a function that returnsFalsewhen 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
Read the labelled lines, find where the data shrinks, then fix the condition (use v is not None) and run again.
Expected output
data=[5, 0, 7, None, 3]
cleaned=[5, 7, 3]
len(data)=5 len(cleaned)=3
lost=[0, None]Expected output
5
'5\n'
' Asha ' 'Asha'
<class 'str'> False True
count='5' type(count).__name__='str'The failure names the exact value that broke the rule, right where it first appears.
Expected output
10 -> 180.0
50 -> 100.0
150 -> state check failed: percent out of range: 150Seven checks find the first bad version out of 100. Change 64 to another number and see that the count stays about the same.
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 checksThe traceback module gives you the same report that Python prints, as text you can log.
Expected output
lines in the report: 7
last line: ValueError: invalid literal for int() with base 10: 'two'
failing call: return int(text)# 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.pyBreak 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.
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))Break #2
A number that is really text
Add one to a value that looks like a number.
count = "5"
print(count) # looks like a number
print(count + 1)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 devandpython -W errormake hidden problems louder (resource warnings, deprecations), andfaulthandler(enabled by-X faulthandler) prints a traceback on a crash or hang.sys.settraceandsys.monitoring(3.12) are the low-level hooks debuggers and coverage tools are built on. - ▸
breakpoint()callssys.breakpointhook, which defaults to pdb. The environment variablePYTHONBREAKPOINTcan point it at another debugger, for exampleipdbordebugpyfor 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, andpython -m pdb -c continue script.pyruns 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 withpython -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
asynciodebug mode (Topic 12.9) to stepping. Use conditional breakpoints (b 42, i == 998) andcommandsto print values and continue automatically, which gives you print debugging without editing the file.
Remember this
- 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
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=}")), printrepr(value)when you suspect hidden characters ('5'versus5, a trailing'\n'), and print types. Remove the prints afterwards, or better, turn useful ones intologging.debugcalls (Topic 7.9). - 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 variableprints it,pppretty-prints,llists the code around you, andwshows the call stack (the chain of calls, Topic 7.1). You can also move.nruns the next line,ssteps into a call,rruns until this function returns,ccontinues until the next breakpoint, andqquits.b 42sets a breakpoint at line 42. - 4
Anything else you type at the prompt, such as
total * 2, is run as Python and its value is shown. Setting the environment variablePYTHONBREAKPOINT=0(a setting you give the program from outside, before it starts) turns everybreakpoint()call into a no-op. - 5
After a crash, use post-mortem debugging.
python -m pdb script.pystarts 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 wheni == 998") save you from stepping through a thousand loops. - 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 bisectdoes the last one automatically across commits (see the Git course). - 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
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
Describe a systematic way to debug a program that gives a wrong answer.
What does breakpoint() do, and what are the basic pdb commands?
Why is the line shown in a traceback not always where the bug is?
What is bisection and how does it apply to debugging?
Practice
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.
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.
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 andrepr.Done when you can start pdb with
breakpoint()and usep,l,n,s,c,wandq.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.