Right, so print() statements have failed you. They always do. Welcome to the big leagues. When your code is doing something so profoundly idiotic that you can’t even begin to guess why, you need to stop it mid-execution, climb inside its brain, and have a look around. That’s what pdb, the Python debugger, is for. It’s your surgical tool for figuring out what the hell is actually happening, not what you think is happening.

Think of pdb as a time-freezing superpower. You can pause your program at any line, poke at all the variables, see exactly where you are in the call stack, and step through the code one line at a time until the moment of catastrophic failure reveals itself. It’s not just for crashes; it’s for when that function returns None and you swear on your favorite mug it should be returning a list.

The Most Important Command: import pdb; pdb.set_trace()

This is the incantation. You don’t need to set up a fancy IDE or configure anything. Just drop this line of code exactly where you want your program to pause.

def some_buggy_function(data):
    result = []
    for item in data:
        # Let's see what 'item' is right before things go sideways
        import pdb; pdb.set_trace()  # This is your breakpoint
        processed = complicated_processing(item)
        result.append(processed)
    return result

When the Python interpreter hits that line, it will screech to a halt, hand over control to you in the terminal, and dump you into a (Pdb) prompt. Congratulations, you’re now inside your program’s head. The line it’s about to execute is shown. Now what?

Your First pdb Session: Don’t Panic

You’re at the (Pdb) prompt. It’s waiting for your command. Here are the essentials you need to survive:

  • l (list): Shows you the code around your current line. This is your map. Use it constantly.
  • n (next): Execute the current line and move to the next line in the same function. If the current line is a function call, it will execute the whole function and pause at the next line. It “steps over” function calls.
  • s (step): Execute the current line. If the current line is a function call, s will dive into that function, pausing at its first line. It “steps into” function calls.
  • c (continue): Release the break and let the program run normally until it hits the next breakpoint (or ends).
  • q (quit): Nuke the entire program from orbit. It’s the only way to be sure. Exits immediately.

Let’s see it in action. Run this script.

# script.py
def calculate_average(numbers):
    total = sum(numbers)
    import pdb; pdb.set_trace()  # Break here
    average = total / len(numbers)
    return average

scores = [10, 20, 30, 40, 50]
result = calculate_average(scores)
print(f"The average is: {result}")

You’ll land at the (Pdb) prompt after the total = ... line has run. Now, let’s explore:

(Pdb) l  # List the code. See where we are?
  1     # script.py
  2     def calculate_average(numbers):
  3         total = sum(numbers)
  4         import pdb; pdb.set_trace()  # Break here
  5  ->     average = total / len(numbers)
  6         return average
  7
  8     scores = [10, 20, 30, 40, 50]
  9     result = calculate_average(scores)
 10     print(f"The average is: {result}")
(Pdb) p total  # 'p' for PRINT. Let's see the value of 'total'
150
(Pdb) p len(numbers)  # And the length
5
(Pdb) n  # NEXT - run the average calculation line
(Pdb) p average  # Now let's see the result
30.0
(Pdb) c  # CONTINUE - let the program finish
The average is: 30.0

See? You just inspected the program’s state as it ran. This is infinitely more powerful than a print(total) statement.

Why p? And Inspecting Everything

The p command, short for print, is how you ask questions. p <variable_name> is the most common command you’ll use. You can print anything in the current scope: local variables, global variables, object attributes, the results of function calls.

# Let's say you're in a more complex function
def messy_function(user_id, data_list):
    import pdb; pdb.set_trace()
    user = User.objects.get(id=user_id)
    # ... more code

# At the (Pdb) prompt:
(Pdb) p user_id  # Check the input
42
(Pdb) p [d.name for d in data_list]  # Run a list comprehension to see names
['alice', 'bob', 'charlie']
(Pdb) p user  # Whoops, we haven't run the line that creates 'user' yet!
*** NameError: name 'user' is not defined
(Pdb) n  # Okay, let's run that line
(Pdb) p user.username  # Now we can inspect the user object's attributes
'admin'
(Pdb) p user.has_perm('blog.post')  # You can even call methods!
True

The pp command (“pretty print”) is fantastic for deeply nested dictionaries or long lists, saving you from terminal vomit.

Advanced Breakpoint Fu: b (break)

Typing import pdb; pdb.set_trace() everywhere gets old fast. The b (break) command is the professional’s choice. You can set breakpoints from within the debugger without modifying your source code.

  • b: Show all current breakpoints.
  • b <line_number>: Set a breakpoint at this line in the current file.
  • b <filename>:<line_number>: Set a breakpoint at a specific line in a specific file. This is crucial for debugging larger projects.
  • b <function_name>: Set a breakpoint at the first line of a function.

Let’s say you’re debugging and realize you need to break inside a function in another module.

(Pdb) b utils/helpers.py:42  # Break at line 42 in helpers.py
Breakpoint 1 at /path/to/project/utils/helpers.py:42
(Pdb) b calculate_average  # Break at the start of the calculate_average function
Breakpoint 2 at /path/to/project/script.py:2
(Pdb) b  # List all breakpoints
Num Type         Disp Enb   Where
1   breakpoint   keep yes   at /path/to/project/utils/helpers.py:42
2   breakpoint   keep yes   at /path/to/project/script.py:2

Now you can c (continue), and your program will run until it hits any of these breakpoints. This is how you trace a problem across multiple files without littering them with pdb.set_trace() calls. To clear a breakpoint, use clear <breakpoint_num>.

Common Pitfalls and The “Aha!” Moment

  1. You’re in the wrong frame: Your breakpoint might be in a function that’s called 100 times, and you only care about the 99th time. Use w (where) to see the call stack—the list of functions that led you to this point. You can then use u (up) and d (down) to move “up” to the caller or “down” to a deeper function, and inspect the variables in that specific frame. This is a killer feature.
  2. You missed the crash: If your program is crashing, run it with python -m pdb script.py. This starts the debugger from the very first line. Then type c to run until the crash happens. When it does, you’ll be dropped into the debugger at the exact line that caused the exception. Type w to see how you got there. This is often the fastest way to find a nasty AttributeError or KeyError.
  3. Stepping too far: It’s easy to hit n one too many times and blow right past the problem. That’s why setting precise breakpoints with b is better than relying on s and n for everything. Use n to move through the current function quickly, and s only when you need to dive into a specific call you suspect.

pdb feels clunky at first because it’s a command-line tool from a different era. But its power is raw and immediate. Mastering it means you’re never truly stuck. You can always crack the shell of your running program and see what’s inside. Now go find that bug. It doesn’t stand a chance.