Some bugs announce themselves with a loud crash, while the sneakier ones just hand you the wrong answer and let you ship it. Python debugging is how you hunt down both kinds on purpose, and the good news is it comes down to a few small tools you can pick up in an afternoon. This post covers pdb, breakpoint(), and assert, the debugger commands worth memorising, and a calm, repeatable process for finding and fixing bugs.
“Debugging is twice as hard as writing the code in the first place. Therefore, if you write the code as cleverly as possible, you are, by definition, not smart enough to debug it.”
Brian Kernighan
Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Beginner | Reading Time: 17 minutes
Your code crashed, or worse, it ran fine and gave the wrong answer. Now what? Most beginners do the same thing: they sprinkle print() everywhere, re-run, squint at the output, delete the prints, add new ones, and repeat until something works. It is exhausting, and it scales badly. Python debugging is the calmer skill that replaces that flailing. It is just finding and fixing errors on purpose instead of by luck, and it has a handful of tools that make it fast.
Think of a bug like a leak under your kitchen sink. Print statements are the bucket you shove under it: cheap, instant, and fine for a small drip. But when water is going everywhere, you want to shut off the valve, open the cabinet, and look. That is what breakpoint() and the built-in pdb debugger do. They freeze your program in place so you can open the cabinet, point a flashlight at every variable, and walk through the pipes one joint at a time.
This post gives you three tools and one habit. The tools: print() with the f-string = trick for quick peeks, breakpoint() for freezing and stepping through code, and assert for sanity checks that catch a bug the second it appears. The habit: a short, repeatable process (reproduce, read the traceback, guess, test the guess, fix, verify) that turns “no idea why this breaks” into a checklist. By the end you will spend minutes on bugs that used to eat an afternoon.
Table of Contents
Print Debugging, Quick and Dirty
Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.
The diagram shows the whole debugging loop on one page, from the moment a bug appears to the moment you lock it out for good. Read it top to bottom: reproduce the bug, read the traceback, make a guess, then pick a tool. The tool you pick depends on the job. Reach for print() for a quick check, breakpoint() for a tricky bug you need to step through, and assert to verify an assumption. After the tool confirms your guess, you fix it, then add a test so the same bug cannot sneak back. The code examples below walk through each tool in turn.
📄 print_debug.py: the simplest debugging technique
def calculate_discount(price, discount_percent):
print(f"DEBUG: price={price}, discount={discount_percent}") # Debug line
discount = price * discount_percent / 100
print(f"DEBUG: discount amount={discount}") # Debug line
final = price - discount
print(f"DEBUG: final={final}") # Debug line
return final
# Something seems wrong with the output
result = calculate_discount(1000, 20)
print(f"Final price: ₹{result}")
▶ Output
DEBUG: price=1000, discount=20 DEBUG: discount amount=200.0 DEBUG: final=800.0 Final price: ₹800.0
What happened here: each print() shows you a value at one moment in the run, so you can watch the numbers flow through the function. Here the maths is actually correct (a 20% discount on 1000 really does leave 800), but you can see how the technique works: you drop a label next to a variable, run, and read. Print debugging is genuinely useful and I still reach for it daily. The catch is the housekeeping. You add lines, re-run, then have to remember to rip them all out again. For anything past a trivial bug, the real debugger is faster and leaves no mess.
Pro tip: Python 3.8 and up has the = format spec for f-strings, and it is tailor-made for debugging. Instead of typing the variable name twice (once as a label, once as the value), you write it once and Python prints both. Say you are tracking a student named Rahul and his quiz scores:
📄 fstring_debug.py: self-documenting debug prints
x = 42
name = "Rahul"
scores = [88, 92, 75]
# The = spec prints both the expression AND its value
print(f"{x=}")
print(f"{name=}")
print(f"{len(scores)=}")
print(f"{sum(scores)/len(scores)=:.1f}")
▶ Output
x=42 name='Rahul' len(scores)=3 sum(scores)/len(scores)=85.0
What happened here: notice the output prints x=42 and name='Rahul', the expression and its value glued together, no extra typing. It even works for whole expressions, so {len(scores)=} shows you both the call and the result. The quotes around 'Rahul' are a bonus: they tell you at a glance that the value is a string and not the number 42 in disguise. This one trick removes most reasons to write a plain debug print().
breakpoint(), The Modern Way
📄 breakpoint_demo.py: pause execution and inspect state
def process_scores(students):
results = {}
for name, scores in students.items():
avg = sum(scores) / len(scores)
# Pause here, then inspect variables in the debugger
breakpoint() # Drops you into pdb
grade = "A" if avg >= 90 else "B" if avg >= 80 else "C"
results[name] = {"average": avg, "grade": grade}
return results
students = {
"Anvay": [92, 88, 95],
"Aditi": [78, 82, 70],
}
# When you run this, Python will pause at breakpoint()
# You'll see: (Pdb) prompt
# Type variable names to inspect them: name, scores, avg
# Type 'n' to go to next line, 'c' to continue
# result = process_scores(students)
The demo averages test scores for two students, Anvay and Aditi, and pauses inside the loop so you can watch each grade being decided. breakpoint() (Python 3.7 and up) drops you into Python’s built-in debugger, pdb. Picture pressing pause on a video at the exact frame something looks off. The whole scene freezes, and now you can lean in and study it. That is what happens to your program. It stops on that line, and you get an interactive (Pdb) prompt where you can type any Python expression to read variables, test a condition, or call a function, all using the real values your program had at that instant. Nothing moves until you tell it to.
Essential pdb Commands
| Command | Short | What It Does |
|---|---|---|
next | n | Execute current line, move to next |
step | s | Step into a function call |
continue | c | Continue until next breakpoint |
print(expr) | p expr | Print value of expression |
pp expr | pp | Pretty-print (for dicts/lists) |
list | l | Show source code around current line |
where | w | Show call stack (where am I?) |
up | u | Move up one frame in call stack |
down | d | Move down one frame in call stack |
quit | q | Exit the debugger |
break N | b N | Set breakpoint at line N |
clear | cl | Clear all breakpoints |
Think of these commands as the buttons on a video player remote: play, pause, and frame-by-frame, except the movie is your program. You only need four of these on day one: n to run the next line, s to step into a function, c to let the program run on, and q to quit. The rest you pick up as you need them. At the (Pdb) prompt you can also type any variable name to see its value, or a full Python expression like len(scores), type(name), or name.upper(). It is a normal Python prompt, just frozen at that exact point in your program’s run.
Stepping Through Code
📄 stepping_demo.py: n vs s (next vs step)
def add_tax(amount, rate=0.18):
tax = amount * rate
return amount + tax
def process_order(items):
total = 0
for name, price in items:
total += add_tax(price) # 'n' skips over add_tax, 's' steps INTO it
return total
items = [("Laptop", 85000), ("Mouse", 1500)]
# To debug: python -m pdb stepping_demo.py
# Or add breakpoint() before the line you want to inspect
breakpoint()
result = process_order(items)
print(f"Total: ₹{result:.2f}")
The difference between n and s is the one thing people get confused about, so here is a simple way to hold it. Imagine you are reading a recipe and you hit a step that says “make the sauce”. With n (next), you treat that as a single step: the sauce gets made, and you move straight to the next line. With s (step), you flip to the sauce recipe and read it line by line.
n (next): runs the current line and stops on the next line in the same function. If that line calls a function, the call still runs fully, you just do not go inside it.
s (step): if the current line calls a function, s takes you inside it. Use it when you suspect the bug lives in add_tax() and you want to watch that function run.
Conditional Breakpoints
📄 conditional_break.py: only pause when a condition is met
def process_users(users):
for user in users:
score = user["score"]
# Only break when we hit a failing score
if score < 50:
breakpoint() # Inspect why this user is failing
grade = "PASS" if score >= 50 else "FAIL"
print(f" {user['name']}: {score} ({grade})")
users = [
{"name": "Rahul", "score": 92},
{"name": "Anvi", "score": 38}, # This will trigger breakpoint
{"name": "Niranjan", "score": 85},
{"name": "Aviraj", "score": 42}, # This too
]
# Uncomment to debug:
# process_users(users)
Never drop a bare breakpoint() inside a loop that runs hundreds of times. You will hit it on every single pass and spend your afternoon typing c to continue. Wrap it in an if so the program only pauses when something interesting happens. In the example, four students take a test and only two of them, Anvi and Aviraj, score below 50, so the debugger opens exactly twice, on the records you actually care about. Think of it as setting an alarm that rings only when the temperature drops below freezing, not every minute on the hour.
assert, Mini-Testing Built In
📄 assert_basics.py: sanity checks in your code
def calculate_average(scores):
assert len(scores) > 0, "Cannot average an empty list"
assert all(isinstance(s, (int, float)) for s in scores), "All scores must be numbers"
return sum(scores) / len(scores)
# Works fine
print(calculate_average([88, 92, 75]))
# Triggers AssertionError
try:
calculate_average([])
except AssertionError as e:
print(f"Assertion failed: {e}")
try:
calculate_average([88, "ninety", 75])
except AssertionError as e:
print(f"Assertion failed: {e}")
▶ Output
85.0 Assertion failed: Cannot average an empty list Assertion failed: All scores must be numbers
What happened here: assert condition, message raises AssertionError the moment the condition is False, and the message tells you exactly what went wrong. The first call passed, so it printed the average. The two bad calls tripped their asserts, and we caught them to show the messages. Think of an assert as a smoke detector wired into your code: it stays silent while everything is fine, then goes off the second a value is not what you swore it would be. That early alarm is the whole point, because a bug caught at its source is far easier to fix than the same bug discovered three functions later.
assert Patterns for Validation
📄 assert_patterns.py: common assertion patterns
# Pattern 1: Function preconditions
def divide(a, b):
assert b != 0, f"Divisor cannot be zero (got b={b})"
return a / b
# Pattern 2: Post-condition checks
def sort_descending(items):
result = sorted(items, reverse=True)
assert result == sorted(result, reverse=True), "Result is not sorted descending!"
return result
# Pattern 3: Type checking (development only)
def send_email(to, subject, body):
assert isinstance(to, str), f"'to' must be string, got {type(to).__name__}"
assert "@" in to, f"'{to}' doesn't look like an email"
assert len(subject) > 0, "Subject cannot be empty"
print(f" Sending to {to}: {subject}")
send_email("viraj@example.com", "Hello", "Test body")
# Pattern 4: Testing your functions
def add(a, b):
return a + b
# Quick inline tests
assert add(2, 3) == 5, "add(2, 3) should be 5"
assert add(-1, 1) == 0, "add(-1, 1) should be 0"
assert add(0, 0) == 0, "add(0, 0) should be 0"
print("All tests passed!")
▶ Output
Sending to viraj@example.com: Hello All tests passed!
Important caveat: assert statements are like pencil notes in the margin of a draft: helpful while you work, erased before the final print. They vanish completely when Python runs in optimized mode (python -O), as if you never wrote them. So never lean on assert to validate real input in production. If a check absolutely must run every time, like user input or data from an API (Application Programming Interface), use if with raise instead. Keep assert for development-time sanity checks and quick inline tests, the things you want during development but can safely strip in production.
Debugging in VS Code
Once a bug gets tricky, the command line debugger can feel cramped. Moving to VS Code (Visual Studio Code) is like swapping a paper map for GPS navigation: same route, but you can see where you are at a glance. It gives you the same pdb power with a visual layer on top, and for most day-to-day Python debugging it is the friendliest option:
- Click the gutter (left of line numbers) to set a breakpoint (red dot)
- Press F5 to start debugging (select “Python File” if prompted)
- Use the toolbar: Step Over (F10), Step Into (F11), Continue (F5)
- Variables panel shows all local/global variables automatically
- Watch panel lets you add expressions to monitor
- Debug Console lets you type Python expressions while paused
Under the hood the VS Code debugger runs the same pdb you just learned, so the ideas carry straight over: a breakpoint is still a pause point, stepping over is still n, stepping into is still s. The difference is that you click instead of type, and you see every variable laid out in a panel instead of asking for them one by one. For beginners that visual layout makes the whole thing click faster.
How to Search for Solutions
Some bugs are not in your code at all, they are in your understanding of a library. When you hit an error you cannot crack on your own, searching well is as much a part of Python debugging as any breakpoint. It is like describing symptoms to a doctor: the more precise your words, the better the diagnosis. Here is the approach that works:
- Copy the exact error message (the last line of the traceback)
- Search with:
python+ the error message + your context (e.g., “python TypeError: can only concatenate str list”) - Stack Overflow results: read the question first and check that it actually matches your situation. Then read the highest-voted answer, not just the accepted one (the accepted answer can be years out of date)
- Official docs: For library-specific errors, the official documentation often has a troubleshooting section
- Reduce the problem: Create a minimal script that reproduces the error. Remove everything except the failing code. Often, this process itself reveals the bug
A Systematic Python Debugging Process
Pilots do not skip the pre-flight checklist just because they have flown a thousand times, and the same discipline is what separates calm Python debugging from panicked guessing. Run these seven steps in order every time and even the nastiest bug becomes routine:
- Reproduce the bug: find the exact input that triggers it. A bug you cannot trigger on demand is a bug you cannot fix with confidence
- Read the traceback: from bottom to top (see the common errors tutorial). The last line names the error, the lines above show how you got there
- Form a hypothesis: say it out loud, “I think the problem is X because Y”. A guess you can put into words is a guess you can test
- Test the hypothesis: add a breakpoint or a print to check whether X is really true
- Fix and verify: change the code, then run the original failing input again to confirm it really is fixed
- Test edge cases: empty lists, zero, None, negative numbers, the inputs that quietly break things
- Add an assert or test: so the same bug cannot quietly return next month
Common Mistakes
Mistake 1: Leaving breakpoint() in production code
A stray breakpoint() shipped to production will freeze a real server mid-request, and nobody is sitting at a prompt to type c. Before you commit, search the project for breakpoint() and pull them out. If you want a safety net, set the environment variable PYTHONBREAKPOINT=0, which disables every breakpoint without you having to touch the code.
Mistake 2: Using assert for input validation
📄 mistake_assert.py
# BAD: assert is stripped with python -O
# def withdraw(amount):
# assert amount > 0 # GONE in optimized mode!
# GOOD: use raise for production validation
def withdraw(amount):
if amount <= 0:
raise ValueError(f"Amount must be positive, got {amount}")
print(f"Withdrawing ₹{amount}")
withdraw(500)
▶ Output
Withdrawing ₹500
Mistake 3: Debugging by changing random things
This is the big one. When you are stuck, it is tempting to tweak a line, run, tweak another, run again, hoping the bug just goes away. That is poking at a knot of headphone wires until it looks untangled. Sometimes it works, and you have no idea why, which means it will come back. Honest Python debugging means finding out why the code fails first, then making one targeted change. Random changes do not just fail to fix the bug, they quietly add new ones.
Best Practices
- DO reach for
breakpoint()over scattered prints when the bug involves loops, changing state, or code you cannot re-run cheaply - DO read the traceback bottom-to-top first, it usually names the file, line, and error type before you touch a debugger
- DO form a hypothesis before each change, then test that one hypothesis with one targeted edit
- DO keep
assertfor internal sanity checks and raise real exceptions likeValueErrorfor input validation - DON’T leave
breakpoint()calls in committed code, they will freeze production the first time that line runs - DON’T debug by changing random things, every unexplained fix is a bug waiting to come back
- DON’T trust a fix until you have re-run the exact input that failed and watched it pass
Go deeper: the official Python documentation covers every edge case of this topic.
Frequently Asked Questions
What is the best way to debug Python code?
For anything beyond a one-line fix, drop a breakpoint() call where things go wrong and step through with pdb. Print statements are fine for quick checks, but real Python debugging means pausing the program, inspecting variables live, and walking through the code path one line at a time instead of guessing from output.
What does breakpoint() do in Python?
Calling breakpoint() pauses your program at that exact line and drops you into the pdb debugger, where you can print variables, step through code, and continue. It has been built in since Python 3.7, needs no import, and you can silence every call at once by setting PYTHONBREAKPOINT=0 in the environment.
Which pdb commands should I learn first?
Five commands cover most sessions: n (next line), s (step into a function), c (continue to the next breakpoint), p variable (print a value), and q (quit). Add l to list the surrounding code and b to set extra breakpoints once those five feel natural.
Is print debugging bad practice?
No, it is just limited. A quick print() is perfect for confirming one value in a short script. It scales badly when the bug involves loops, state that changes over time, or code you cannot re-run cheaply. That is when a debugger pays off: one pause shows you every variable at once.
Why should I not use assert for input validation?
Python strips every assert statement when it runs with the -O optimization flag, so validation written as asserts silently disappears in that mode. Use assert for internal sanity checks during development, and raise real exceptions like ValueError for anything that validates user or external input.
Interview Questions on Python Debugging
Interviewers rarely ask for definitions. They ask what happens in situations like these.
Q: You get a bug report from production that you cannot reproduce locally. Walk me through your approach.
Reproduce before you fix. Capture the exact inputs, library versions, and environment, then add targeted logging around the suspected path instead of guessing. Once the failure reproduces in a test, narrow it with breakpoint() and pdb, fix it, and keep that failing case as a regression test so the bug cannot quietly return.
Q: When would you reach for breakpoint() over your IDE’s debugger, and vice versa?
breakpoint() works anywhere Python runs: SSH sessions, containers, a teammate’s machine, with zero setup, and drops you into pdb. An IDE debugger is better for visually stepping through unfamiliar code and watching several variables change. Knowing both matters, and setting PYTHONBREAKPOINT=0 disables any stray breakpoint() calls in production.
Q: A script behaves differently when someone runs it with python -O. What is the first thing you check?
The -O flag strips every assert statement. If the code used assertions for input validation or anything with side effects, that logic silently disappeared. That is exactly why asserts are for internal sanity checks during development, while real runtime validation should raise proper exceptions like ValueError.
Q: In pdb, what is the difference between n, s, and c, and how do you print a variable that happens to be named n?
n (next) executes the current line and steps over function calls, s (step) steps into them, and c (continue) runs until the next breakpoint. For a variable that shadows a command, use p n to print it or prefix a statement with !, like !n = 5, so pdb treats it as Python instead of a debugger command.
Q: You add a print statement and the bug disappears. What does that tell you?
That the bug is timing or state dependent, which points at race conditions, shared mutable state, or buffering. The print changed execution timing and flushed buffers, masking the symptom. The move is to switch to logging with timestamps and thread names, and to inspect concurrency and shared state first rather than chasing the line that seemed to fix it.
Related Posts
Previous: Python: Virtual Environments & Dependency Management
Next: Python AI for Beginners: Call an LLM in 25 Lines
Series Home: Python + AI/ML Tutorial Series

No comment