Debugging
Learn how to find and fix bugs in Python using print debugging, the logging module, and pdb.
Debugging
Every programmer spends a significant amount of time debugging — finding out why code is not doing what they expected. Python gives you several tools for this, from the simplest print() statement to a full interactive debugger.
There are three layers:
- print() debugging — quick, dirty, everyone does it
- logging — the professional, structured way
- pdb — Python's built-in interactive debugger for serious investigation
Print debugging
The simplest approach. When something is wrong, add print() statements to see what is happening:
def calculate_total(items):
total = 0
for item in items:
print(f"Processing item: {item}") # what item are we on?
print(f"Item price: {item['price']}") # what is the price?
total += item['price']
print(f"Running total: {total}") # what is the total so far?
return total
orders = [
{"name": "Book", "price": 15.99},
{"name": "Pen", "price": 2.49},
{"name": "Bag", "price": 29.99},
]
print(calculate_total(orders))Output:
Processing item: {'name': 'Book', 'price': 15.99}
Item price: 15.99
Running total: 15.99
Processing item: {'name': 'Pen', 'price': 2.49}
Item price: 2.49
Running total: 18.48
Processing item: {'name': 'Bag', 'price': 29.99}
Item price: 29.99
Running total: 48.47
48.47You can see exactly what is happening at each step.
The f-string = specifier — best print debugging trick
Python 3.8+ gives you f"{variable=}" which prints both the name and value in one shot:
name = "Ali"
age = 22
scores = [88, 92, 79]
print(f"{name=}") # name='Ali'
print(f"{age=}") # age=22
print(f"{scores=}") # scores=[88, 92, 79]
print(f"{len(scores)=}") # len(scores)=3
print(f"{sum(scores) / len(scores)=}") # sum(scores) / len(scores)=86.33333333333333This is faster than writing print(f"name: {name}") every time — you never have to type the variable name twice.
Problems with print debugging
It works for small problems but falls apart quickly:
- You have to remember to remove all
print()statements before shipping - It clutters your code
- No timestamps, no severity levels, no way to turn it off without deleting it
- In production, you cannot add prints without redeploying
This is where logging comes in.
logging — the professional way
The logging module gives you structured, configurable output that you can control without touching your code. You can turn it on or off, filter by severity, write to files, and format messages properly.
import loggingLog levels
Every log message has a severity level. From lowest to highest:
| Level | When to use |
|---|---|
DEBUG | Detailed information for diagnosing problems |
INFO | Confirmation that things are working as expected |
WARNING | Something unexpected happened but the program continues |
ERROR | A serious problem — something failed |
CRITICAL | A very serious error — program may not be able to continue |
import logging
logging.debug("This is a debug message")
logging.info("Server started successfully")
logging.warning("Disk space is running low")
logging.error("Failed to connect to database")
logging.critical("System is shutting down")By default, Python only shows WARNING and above. To see all levels, configure the logging level:
import logging
logging.basicConfig(level=logging.DEBUG)
logging.debug("Starting the calculation")
logging.info("Processing 100 records")
logging.warning("3 records had missing values")
logging.error("Failed to process record 47")Output:
DEBUG:root:Starting the calculation
INFO:root:Processing 100 records
WARNING:root:3 records had missing values
ERROR:root:Failed to process record 47Formatting log messages
The default format is plain. Make it useful by adding timestamps and more context:
import logging
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s | %(levelname)-8s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
logging.info("Server started")
logging.warning("High memory usage detected")
logging.error("Database connection failed")Output:
2026-06-02 14:35:01 | INFO | Server started
2026-06-02 14:35:02 | WARNING | High memory usage detected
2026-06-02 14:35:03 | ERROR | Database connection failedCommon format codes:
| Code | Meaning |
|---|---|
%(asctime)s | Timestamp |
%(levelname)s | Level name — DEBUG, INFO, etc. |
%(message)s | The log message |
%(name)s | Logger name |
%(filename)s | Python filename |
%(lineno)d | Line number |
%(funcName)s | Function name |
Writing logs to a file
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)-8s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
filename="app.log",
filemode="a" # append — do not overwrite
)
logging.info("Application started")
logging.warning("Config file not found — using defaults")
logging.error("Failed to load user data")app.log will grow over time:
2026-06-02 14:35:01 | INFO | Application started
2026-06-02 14:35:01 | WARNING | Config file not found — using defaults
2026-06-02 14:35:02 | ERROR | Failed to load user dataNamed loggers — the right way for larger projects
Instead of using the root logger directly, create named loggers for different parts of your app:
import logging
# create a logger with a name
logger = logging.getLogger("myapp.database")
# configure it
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter(
"%(asctime)s | %(name)s | %(levelname)s | %(message)s"
))
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
# use it
logger.debug("Connecting to database")
logger.info("Connection established")
logger.error("Query failed")Output:
2026-06-02 14:35:01 | myapp.database | DEBUG | Connecting to database
2026-06-02 14:35:01 | myapp.database | INFO | Connection established
2026-06-02 14:35:02 | myapp.database | ERROR | Query failedThe name myapp.database tells you exactly which part of the app logged the message — invaluable in large projects.
Log to both file and console at the same time
import logging
logger = logging.getLogger("myapp")
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter(
"%(asctime)s | %(levelname)-8s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
# console handler
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO) # INFO and above in console
console_handler.setFormatter(formatter)
# file handler
file_handler = logging.FileHandler("app.log", encoding="utf-8")
file_handler.setLevel(logging.DEBUG) # DEBUG and above in file
file_handler.setFormatter(formatter)
logger.addHandler(console_handler)
logger.addHandler(file_handler)
logger.debug("Detailed debug info") # file only
logger.info("Server running") # both
logger.error("Something failed") # bothConsole shows INFO and above. The log file captures everything including DEBUG. Perfect for production — you do not flood the console with debug noise, but it is all saved to the file.
Logging exceptions
Log an exception with its full traceback using logger.exception():
import logging
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger("myapp")
def divide(a, b):
try:
return a / b
except ZeroDivisionError:
logger.exception("Division failed")
return None
divide(10, 0)Output:
ERROR:myapp:Division failed
Traceback (most recent call last):
File "app.py", line 8, in divide
return a / b
ZeroDivisionError: division by zerologger.exception() logs at ERROR level and automatically includes the full traceback. Use it inside except blocks.
pdb — the interactive debugger
Sometimes print() and logging are not enough. You need to pause the program mid-execution, look around, and step through code line by line. That is what pdb — Python's built-in debugger — is for.
Setting a breakpoint
The modern way — just call breakpoint() anywhere in your code (Python 3.7+):
def calculate_discount(price, percent):
discount = price * (percent / 100)
breakpoint() # program pauses here
final = price - discount
return final
result = calculate_discount(1000, 15)When Python hits breakpoint(), it pauses and drops you into an interactive shell:
> /home/ali/app.py(4)calculate_discount()
-> final = price - discount
(Pdb)You are now inside the program at that exact point.
pdb commands
| Command | What it does |
|---|---|
n | next — run the next line, stay in current function |
s | step — step into a function call |
c | continue — run until the next breakpoint |
q | quit — stop the program |
l | list — show the current code around this line |
p variable | print — print a variable's value |
pp variable | pretty print — print formatted output |
w | where — show the call stack |
b 15 | break — set a breakpoint at line 15 |
u | up — move up in the call stack |
d | down — move down in the call stack |
h | help — list all commands |
A real debugging session:
(Pdb) p price
1000
(Pdb) p percent
15
(Pdb) p discount
150.0
(Pdb) n
> /home/ali/app.py(5)calculate_discount()
-> return final
(Pdb) p final
850.0
(Pdb) cYou can see every variable at every step. No guessing.
Setting breakpoints without changing code
You can also start pdb from the terminal without modifying your code at all:
python3 -m pdb app.pyThis starts your script in debug mode from the very first line.
Post-mortem debugging — debug after a crash
If your program crashes, you can inspect the state at the moment of the crash:
import pdb
def risky():
data = [1, 2, 3]
return data[10] # IndexError
try:
risky()
except IndexError:
pdb.post_mortem() # drop into debugger at the crash pointThis is incredibly useful — you see exactly what was in memory when things went wrong.
Choosing the right tool
| Situation | Tool |
|---|---|
| Quick check — what is this variable? | print(f"{var=}") |
| Tracing program flow in development | print() |
| Production apps — recording what happened | logging |
| Need timestamps and severity levels | logging |
| Writing logs to a file | logging |
| Program crashes and you do not know why | pdb / breakpoint() |
| Complex logic you need to step through | pdb / breakpoint() |
| Crash already happened — inspect the state | pdb.post_mortem() |
A good workflow — use print(f"{var=}") for quick checks while developing. Switch to logging once the code is more stable. Reach for pdb when you genuinely cannot figure out what is happening.
A real example — debugging a data pipeline
import logging
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s | %(levelname)-8s | %(funcName)s | %(message)s",
datefmt="%H:%M:%S"
)
logger = logging.getLogger("pipeline")
def load_data(filepath):
logger.info(f"Loading data from {filepath}")
try:
with open(filepath, "r", encoding="utf-8") as f:
lines = f.readlines()
logger.debug(f"Loaded {len(lines)} lines")
return lines
except FileNotFoundError:
logger.error(f"File not found: {filepath}")
return []
def clean_data(lines):
logger.info("Cleaning data")
cleaned = []
skipped = 0
for i, line in enumerate(lines):
stripped = line.strip()
if not stripped:
logger.debug(f"Skipping empty line {i + 1}")
skipped += 1
continue
cleaned.append(stripped)
logger.info(f"Cleaned {len(cleaned)} lines, skipped {skipped} empty lines")
return cleaned
def process_data(lines):
logger.info("Processing data")
results = []
for line in lines:
try:
value = float(line)
results.append(value)
except ValueError:
logger.warning(f"Could not convert '{line}' to float — skipping")
logger.info(f"Processed {len(results)} values")
return results
def run_pipeline(filepath):
logger.info("Pipeline started")
raw = load_data(filepath)
if not raw:
logger.critical("No data loaded — aborting pipeline")
return
cleaned = clean_data(raw)
results = process_data(cleaned)
if results:
logger.info(f"Average: {sum(results) / len(results):.2f}")
logger.info(f"Max: {max(results)}")
logger.info(f"Min: {min(results)}")
logger.info("Pipeline complete")
run_pipeline("numbers.txt")Output:
14:35:01 | INFO | run_pipeline | Pipeline started
14:35:01 | INFO | load_data | Loading data from numbers.txt
14:35:01 | DEBUG | load_data | Loaded 8 lines
14:35:01 | INFO | clean_data | Cleaning data
14:35:01 | DEBUG | clean_data | Skipping empty line 3
14:35:01 | INFO | clean_data | Cleaned 7 lines, skipped 1 empty lines
14:35:01 | INFO | process_data | Processing data
14:35:01 | WARNING | process_data | Could not convert 'N/A' to float — skipping
14:35:01 | INFO | process_data | Processed 6 values
14:35:01 | INFO | run_pipeline | Average: 72.33
14:35:01 | INFO | run_pipeline | Max: 95.0
14:35:01 | INFO | run_pipeline | Min: 45.0
14:35:01 | INFO | run_pipeline | Pipeline completeEvery step is recorded. You know exactly what happened, when, and where.
Summary
| Tool | Use for | Remove before shipping? |
|---|---|---|
print(f"{var=}") | Quick variable inspection | Yes |
logging.debug() | Detailed development info | No — just change level |
logging.info() | Normal operation events | No |
logging.warning() | Unexpected but non-fatal | No |
logging.error() | Failures | No |
logging.exception() | Exceptions with traceback | No |
breakpoint() | Interactive step-through | Yes |
pdb.post_mortem() | Debug after a crash | Yes |