At some point, every Python developer hits the same wall: the code runs, but the output is wrong… or it crashes in a way that makes no sense. Debugging and testing are the skills that turn you from “I hope this works” into “I know this works.”
In this lesson, you’ll learn practical debugging techniques (print vs pdb), logging basics, assertions, an intro to unit testing, plus the habits that make your code clean and professional: Pythonic style, PEP 8, and docstrings.
1) Debugging Mindset: Make Bugs Smaller
Debugging is not about guessing. It’s about narrowing the problem until it becomes obvious. A simple workflow:
- Reproduce the bug consistently
- Reduce the input (smallest case that still fails)
- Inspect variables at the point of failure
- Fix the cause, not just the symptom
- Add a test so the bug never returns
2) Print Debugging (Fast, Simple, Limited)
print() is fine for quick checks, especially when learning. The problem is that it gets messy fast and you often forget to remove it.
Example: Quick inspection
def calculate_discount(price, percent):
print("price:", price, "percent:", percent)
return price - (price * percent / 100)
print(calculate_discount(100, 20))
Better print debugging tips
- Print labels, not just values
- Print types when unsure (
type(x)) - Print at boundaries: inputs, outputs, and decision points
- Remove prints after fixing, or replace with logging
3) Debugging with pdb (Real Debugger)
pdb is Python’s built-in interactive debugger. It allows you to pause execution at any point, inspect variables, move through the code line by line, and understand exactly how your program is behaving at runtime.
Compared to print(), pdb gives you control. You don’t guess what happened — you stop the program and look.
Using breakpoint() (Recommended)
Starting from Python 3.7, the built-in breakpoint() function is the preferred way to invoke the debugger. It automatically launches pdb without importing anything.
def calculate_discount(price, percent):
breakpoint() # execution pauses here
discount = price * percent / 100
final_price = price - discount
return final_price
calculate_discount(100, 20)
When Python reaches breakpoint(), execution stops and you enter an interactive debugging shell. At this point, the program is paused and you can type different commands to inspect variables, move through the code, and control execution step by step.
Common Debugging Commands
p variable— print the value of a variable (for example:p price)n— execute the next line of code (step over)s— step into a function calll— list the surrounding source codec— continue execution until the next breakpointq— quit the debugger and stop the program
The debugger does not print values automatically. You ask questions, and pdb answers them. This is what makes pdb powerful for understanding how your code behaves at runtime.
Debugging in Jupyter Notebooks (%debug)
When working in Jupyter notebooks (including the VS Code Jupyter extension), breakpoint() does not behave the same way as it does in a normal Python script.
In notebooks, the recommended approach is to let the code fail and then use the magic command %debug to inspect the program state at the point where the error occurred.
Example
def calculate_discount(price, percent):
return price - (price * percent / 100)
# This will raise an error
calculate_discount(100, "20")
After the error appears, run the following command in a new cell:
%debug
This opens an interactive debugging session at the exact line where the exception occurred. From here, you can inspect variables just like in pdb.
Common commands inside %debug
p variable— print the value of a variablel— list the surrounding source coden— move to the next lineq— exit the debugger
Rule of thumb:
Use breakpoint() when running .py files from the terminal.
Use %debug when working inside Jupyter notebooks.
Inspecting Variables
Once inside pdb, you can inspect variables at that exact moment:
(Pdb) p price
100
(Pdb) p percent
20
(Pdb) p discount
20.0
This is extremely useful when values don’t match your expectations.
Stepping Through Code
Instead of running everything at once, you can move step by step.
n— execute the next line (step over)s— step into a function callc— continue execution until the next breakpoint
Example flow:
(Pdb) n
(Pdb) n
(Pdb) p final_price
80.0
This helps you see how values change after each line.
Listing and Navigating Code
You can view surrounding code while debugging:
(Pdb) l
This shows the lines before and after the current execution point.
Debugging Conditional Logic
pdb is especially useful inside conditional branches.
def withdraw(balance, amount):
breakpoint()
if amount > balance:
return "Insufficient balance"
return balance - amount
withdraw(1000, 1500)
Inside pdb, you can test conditions manually:
(Pdb) p balance
1000
(Pdb) p amount
1500
(Pdb) p amount > balance
True
This confirms why a specific branch was taken.
Exiting the Debugger
c— continue executionq— quit debugger and stop the program
(Pdb) q
Multiple Breakpoints
You can place breakpoint() anywhere in your code — inside loops, functions, or error-prone sections.
for i in range(5):
breakpoint()
print("Value:", i)
This allows you to inspect each iteration of a loop.
When to Use pdb vs print()
- Use
print()for very quick checks - Use
pdbwhen:- The logic is complex
- You need to inspect multiple variables
- The bug only appears at runtime
- You want to step through code line by line
Tip: In modern Python, breakpoint() is cleaner and more flexible than writing import pdb; pdb.set_trace(), and it can be customized or disabled via environment variables.
Mastering pdb dramatically reduces debugging time and helps you understand Python execution at a deeper level.
4) Logging Basics (Better Than Print)
Logging is like print debugging, but professional: you can control severity, format, and where logs go (console, file, etc.). In real projects, logs are how you investigate issues in production.
Basic logging setup
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(message)s"
)
logging.info("App started")
logging.warning("This is a warning")
logging.error("Something went wrong")
Logging levels
DEBUG— detailed info for debuggingINFO— normal app flowWARNING— something unexpected, but not fatalERROR— operation failedCRITICAL— serious failure
Log exceptions with stack trace
import logging
try:
x = 10 / 0
except ZeroDivisionError:
logging.exception("Division failed")
5) Assertions (Quick Sanity Checks)
Assertions are simple checks that help catch impossible states early. They’re great for verifying assumptions inside your code while developing.
Example
def withdraw(balance, amount):
assert amount > 0, "amount must be positive"
assert amount <= balance, "insufficient balance"
return balance - amount
print(withdraw(1000, 200))
Important: assertions can be disabled in optimized mode, so don’t use them as a replacement for real validation in production.
6) Intro to Unit Testing (unittest)
Testing is how you protect your code from breaking when you change it. A unit test checks a small piece of logic (a function or method) and confirms it returns the expected output.
Create a file: test_math_utils.py
import unittest
def add(a, b):
return a + b
class TestMathUtils(unittest.TestCase):
def test_add(self):
self.assertEqual(add(2, 3), 5)
self.assertEqual(add(-1, 1), 0)
if __name__ == "__main__":
unittest.main()
Run tests
python -m unittest
Common assertions
assertEqual(a, b)assertTrue(x)/assertFalse(x)assertIn(item, container)assertRaises(ErrorType)
Test exceptions
import unittest
def divide(a, b):
if b == 0:
raise ValueError("b cannot be 0")
return a / b
class TestDivide(unittest.TestCase):
def test_divide_raises(self):
with self.assertRaises(ValueError):
divide(10, 0)
7) Pythonic Style & PEP 8 (Write Code Humans Like)
PEP 8 is Python’s style guide. Following it makes your code consistent, readable, and easier to maintain.
Naming conventions
- Variables and functions:
snake_case - Classes:
PascalCase - Constants:
UPPER_CASE
Examples
MAX_RETRIES = 3
def calculate_total(items):
return sum(items)
class Invoice:
pass
Pythonic patterns (clean code habits)
- Prefer early returns over deep nesting
- Use list/dict comprehensions when they improve readability
- Use
enumerateinstead of manual counters - Use
zipto loop over multiple lists
Example: enumerate
names = ["Ali", "Ammar", "Sara"]
for i, name in enumerate(names, start=1):
print(i, name)
8) Docstrings (Explain the “Why”)
Docstrings document what your function/class does, what parameters mean, and what it returns. They’re not comments about every line — they explain intent.
Function docstring example
def convert_currency(amount, rate):
"""
Convert an amount using the given exchange rate.
Args:
amount (float): Amount to convert.
rate (float): Exchange rate.
Returns:
float: Converted amount.
"""
return amount * rate
Class docstring example
class BankAccount:
"""
Simple bank account model.
Attributes:
owner (str): Account owner name.
balance (float): Current balance.
"""
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
9) Mini Exercise (Practice)
Try this small task:
- Write a function
is_even(n) - Add a docstring
- Add an assertion that input is an integer
- Write 3 unit tests for it
- Add logging when the function is called
Key Takeaways
- Use
print()for quick checks, but switch to logging for real projects breakpoint()+pdbhelps you inspect code step-by-step- Assertions catch wrong assumptions early during development
- Unit tests protect your code from breaking later
- PEP 8 and Pythonic patterns make your code readable and maintainable
- Docstrings document intent and help others (and future you)
Leave a comment
Your email address will not be published. Required fields are marked *
