Juha-Matti Santala
Community Builder. Dreamer. Adventurer.

Debug with pdb and breakpoint

Batteries included is a blog series about the Python Standard Library. Each day, I share insights, ideas and examples for different parts of the library. Blaugust is an annual blogging festival in August where the goal is to write a blog post every day of the month.

Python comes with good tools in its standard library for debugging software. Debugging is a topic very close to my heart. I have been writing and talking and workshopping about debugging for years. Last year, I gave a series of talks titled Debugging Python that I also wrote in blog form. I've also written debugging newsletter Syntax Error that you can check out if you want to learn more about debugging.

This blog post focuses on The Python Debugger that is part of the standard library.

Starting the debugger

To start a debugger session, all you need to do is to add breakpoint() into your code. No imports, no configurations, nothing.

def my_function(arg1, arg2):
  do_something()
  breakpoint()
  do_something_else()
  
my_function()

When the program execution reaches the line that reads breakpoint(), it will stop the execution and open an interactive session that’s similar to Python REPL and inject you right into the action.

The documentation page has the following example:

def double(x):
    breakpoint()
    return x * 2

val = 3
print(f"{val} * 2 is {double(val)}")

and once we run it, we get a prompt:

➜ python example.py
> /code/testbench/breakpoints/example.py(3)double()
-> return x * 2
(Pdb)

(Pdb) at the beginning of the line is the prompt and a reminder that we are now in debugging session.

Running code and commands

In this session, you can do what you would normally do in the REPL: you can run Python code, examine the state of variables, execute functions and so on:

(Pdb) x
3
(Pdb) val
3
(Pdb) sum([1,2,3,4,5])
15

But in addition to that, you get access to powerful debugging tools:

# args prints out all the arguments of the current function and their values
(Pdb) args
x = 3

# list shows the context: where are we in the code
(Pdb) list
  1  	def double(x):
  2  	    breakpoint()
  3  ->	  return x * 2
  4
  5
  6  	val = 3
  7  	print(f"{val} * 2 is {double(val)}")
  
# where shows us the current stack: how did we end up here
(Pdb) where
  /code/testbench/breakpoints/example.py(7)<module>()
-> print(f"{val} * 2 is {double(val)}")
> /code/testbench/breakpoints/example.py(3)double()
-> return x * 2

# step moves us forward
(Pdb) step
--Return--
> /code/testbench/breakpoints/example.py(3)double()->6
-> return x * 2
(Pdb) step
3 * 2 is 6
--Return--
> /code/testbench/breakpoints/example.py(7)<module>()->None
-> print(f"{val} * 2 is {double(val)}")

For the next command, I created a new example:

def create_message(source):
    message = ""
    breakpoint()
    for message_part in source:
        message = f"{message} {message_part}"
    return message


print(create_message(["Happy", "birthday", "Python"]))

Inside the debugger session, we can then monitor the changes to message variable:

# display command followed by a variable tracks changes to that variable
(Pdb) display message
display message: ''

(Pdb) next
> /Users/juhis/code/testbench/breakpoints/example.py(5)create_message()
-> message = f"{message} {message_part}"

(Pdb) next
> /Users/juhis/code/testbench/breakpoints/example.py(4)create_message()
-> for message_part in source:
display message: ' Happy'  [old: '']

(Pdb) next
> /Users/juhis/code/testbench/breakpoints/example.py(5)create_message()
-> message = f"{message} {message_part}"

(Pdb) next
> /Users/juhis/code/testbench/breakpoints/example.py(4)create_message()
-> for message_part in source:
display message: ' Happy birthday'  [old: ' Happy']

(Pdb) next
> /Users/juhis/code/testbench/breakpoints/example.py(5)create_message()
-> message = f"{message} {message_part}"

(Pdb) next
> /Users/juhis/code/testbench/breakpoints/example.py(4)create_message()
-> for message_part in source:
display message: ' Happy birthday Python'  [old: ' Happy birthday']

This can be handy when you have multiple variables that maybe get changed rarely and you don’t want to manually check them all the time.

There are a bunch of more commands available that you can find in the documentation.

Configuring the debugger with PYTHONBREAKPOINT

Python’s debugger behaviour can be configured with an environment value PYTHONBREAKPOINT:

# Run a different debugger (replace `ipdb` with any other debugger)
PYTHONBREAKPOINT=ipdb.set_trace

# Don’t stop on breakpoint()s, 
# handy for CI pipelines or production to avoid halting your program if 
# a breakpoint accidentally makes it into the code
PYTHONBREAKPOINT=0

# Run default Python Debugger
PYTHONBREAKPOINT=

There are many other debuggers to choose from if you explore beyond the standard library. I like PuDB with its terminal UI and birdseye that records runs and can be examined afterwards.