Debug with print and logging
Learn to debug code with print and logging in Python. Step-by-step tutorial with hands-on exercise, troubleshooting, and next steps.
Focus: debug code with print and logging
Every developer has been there — your code runs without errors, but the output is completely wrong. You stare at the screen, re-read the logic, and still can't spot where things went sideways. The fastest way to fix this is to peek inside your program while it runs. This lesson shows you two essential techniques for debugging Python code: using print() for quick inspections and the logging module for production-ready diagnostics.
The problem this lesson solves
Bugs don't always crash your program. Sometimes they silently produce incorrect results, mangle data, or slow down execution. Without visibility into what's happening at runtime, you're guessing. The classic approach — unhelpful print() statements everywhere — quickly becomes messy and hard to manage. You need a systematic way to:
- Inspect variable values at specific points in your code.
- Trace program flow through conditional branches and loops.
- Record errors and warnings without stopping execution.
- Control verbosity — show details during development, hide them in production.
Both print() and logging solve these problems, but they serve different purposes. Understanding when to reach for each tool is the key to efficient debugging.
Core concept / mental model
Think of debugging like a detective investigation. You have two main tools:
print()— your instant-print Polaroid camera. Quick, easy, disposable. Use it when you need a one-off peek at a variable or a rough trace of execution order.logging— your forensic notebook with timestamped, categorized entries. Use it when you need a permanent, configurable record of your program's behavior.
The logging module categorises messages by severity (from most to least critical):
| Level | Numeric Value | Use Case |
|---|---|---|
| CRITICAL | 50 | Program cannot continue |
| ERROR | 40 | A specific operation failed |
| WARNING | 30 | Something unexpected but not fatal |
| INFO | 20 | Confirmation things are working |
| DEBUG | 10 | Detailed diagnostic information |
By default, logging only shows messages at WARNING level and above. This is a feature — you can annotate your code with detailed debug messages, then suppress them in production without touching a single line.
How it works step by step
Step 1: Quick debugging with print()
The simplest debug: insert a print() statement to reveal what's happening. For example, tracking a loop:
def calculate_average(numbers):
total = 0
for i, num in enumerate(numbers):
total += num
# Debug: print each iteration
print(f"Step {i}: added {num}, running total = {total}")
average = total / len(numbers)
print(f"Final total = {total}, average = {average}")
return average
# Test data
scores = [85, 92, 78, 95]
print("Average score:", calculate_average(scores))
Expected output:
Step 0: added 85, running total = 85
Step 1: added 92, running total = 177
Step 2: added 78, running total = 255
Step 3: added 95, running total = 350
Final total = 350, average = 87.5
Average score: 87.5
When to remove these prints: once you understand the behavior, delete them or comment them out. They clutter the output and can slow performance.
Step 2: Setting up basic logging
Replace temporary print() statements with logging for a more professional approach:
import logging
# Configure logging — typically done once at the top of your script
logging.basicConfig(
level=logging.DEBUG, # Show DEBUG and above
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
def calculate_average(numbers):
logging.info("Calculating average for %d numbers", len(numbers))
total = 0
for i, num in enumerate(numbers):
total += num
logging.debug("Step %d: added %d, running total = %d", i, num, total)
if len(numbers) == 0:
logging.warning("Empty list — returning 0")
return 0
average = total / len(numbers)
logging.info("Average = %.2f", average)
return average
scores = [85, 92, 78, 95]
result = calculate_average(scores)
print("Result:", result)
Expected output (with DEBUG enabled):
2025-03-28 14:30:01 - root - INFO - Calculating average for 4 numbers
2025-03-28 14:30:01 - root - DEBUG - Step 0: added 85, running total = 85
2025-03-28 14:30:01 - root - DEBUG - Step 1: added 92, running total = 177
2025-03-28 14:30:01 - root - DEBUG - Step 2: added 78, running total = 255
2025-03-28 14:30:01 - root - DEBUG - Step 3: added 95, running total = 350
2025-03-28 14:30:01 - root - INFO - Average = 87.50
Result: 87.5
Key difference: the timestamp and level are automatically included. If you change level=logging.INFO, the DEBUG lines disappear without editing your code.
Step 3: Logging to a file
For long-running scripts or server applications, write logs to a file:
import logging
logging.basicConfig(
filename='app.log',
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
# Your application code
logging.info("Application started")
try:
x = 10 / 0
except ZeroDivisionError:
logging.error("Division by zero occurred", exc_info=True)
File output (app.log):
2025-03-28 14:35:10,123 - INFO - Application started
2025-03-28 14:35:10,124 - ERROR - Division by zero occurred
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero
Pro tip: The
exc_info=Trueparameter captures the full traceback. Without it, you only see the error message string.
Step 4: Adding context with loggers
Create named loggers for different modules — essential for larger projects:
import logging
# In database.py
logger = logging.getLogger(__name__)
def connect_to_db():
logger.info("Attempting database connection")
# ... connection logic ...
logger.debug("Connection parameters: host=localhost, port=5432")
# In payment.py
payment_logger = logging.getLogger(__name__)
def process_payment():
payment_logger.info("Processing payment request")
# ... payment logic ...
This way, when you see INFO:__main__ - something versus INFO:database - something, you immediately know which module generated the message.
Compare options / when to choose what
| Feature | print() |
logging |
|---|---|---|
| Setup time | Instant — just type print() |
Requires configuration |
| Output control | None — always prints to stdout | Multiple levels, output to file/console/network |
| Timestamp | Must be added manually with datetime |
Automatic via format string |
| Production safety | Often forgotten and left in code | Messages can be filtered by level |
| Performance impact | Minimal for small use | Slightly more overhead, but configurable |
| Tracebacks | Requires traceback module |
Built-in with exc_info=True |
| Thread safety | Not guaranteed | Thread-safe by default |
When to use print():
- Quick one-off debugging in scripts you'll delete.
- Interactive data exploration in Jupyter notebooks.
- Learning Python — no configuration required.
When to use logging:
- Any code that will run in production.
- Applications with multiple modules or long-running processes.
- When you need to preserve a permanent record of events.
- When different team members need different verbosity levels.
Troubleshooting & edge cases
Common mistakes
-
Calling
logging.info()but seeing no output. - Cause: The default logging level is WARNING. You needlogging.basicConfig(level=logging.DEBUG)or at leastlevel=logging.INFO. - Fix: Always explicitly set the level when configuring logging. -
Multiple
basicConfig()calls are ignored. - Cause:basicConfig()only works once — subsequent calls have no effect. - Fix: Configure logging once at the top of your main script, or uselogging.getLogger().setLevel(). -
Forgotten
print()statements in production code. - Cause: No easy way to suppress all prints. - Fix: Use a function likeDEBUG = Trueat the top, thenif DEBUG: print(...). Better yet, switch to logging. -
Logging to stdout when you expected a file. - Cause: No
filenameargument inbasicConfig(). - Fix:logging.basicConfig(filename='app.log', level=logging.INFO). -
Unformatted log messages look ugly: - Fix: Use format strings with placeholders:
logging.info("User %s logged in", username)instead of f-strings (f-strings evaluate immediately, format strings evaluate only if the level is active).
Edge cases
- Empty lists in loops: Your debug print might not execute at all — that's actually useful to detect zero-iteration bugs.
- Multithreaded code:
print()can interleave output from different threads.loggingis thread-safe by default. - Performance:
logging.debug()with expensive string formatting can slow your app. Use lazy formatting:logging.debug("Expensive: %s", expensive_function())— the function only runs if DEBUG is enabled.
What you learned & what's next
You now understand the two main debugging techniques in Python:
print()for quick, disposable debugging during development.loggingfor structured, configurable, production-grade diagnostics.
You can set different log levels (DEBUG, INFO, WARNING, ERROR, CRITICAL), control output destinations (console or file), and add timestamps and module context.
Next step: In the following lesson, you'll learn how to write unit tests — a complementary skill that prevents bugs before they happen, and automatically validates your fixes when they do.
Practice recap
Open a Python file you've written earlier in this track. Replace all print() debugging statements with appropriate logging calls (at least one of each level: DEBUG, INFO, WARNING). Configure logging to write both to a file and to the console. Run the script, then examine the log file to confirm all levels appear.
Common mistakes
- Calling
logging.info()without settingbasicConfig(level=logging.INFO)— default level is WARNING, so nothing appears. - Using f-strings in logging calls:
logging.debug(f"value = {x}")always evaluates the string, even if DEBUG is disabled. Use lazy formatting:logging.debug("value = %s", x). - Forgetting to remove debug
print()statements before deploying to production — they clutter output and can leak sensitive data. - Calling
logging.basicConfig()multiple times — only the first call has effect. Configure logging once at startup.
Variations
- Use
traceback.print_exc()to print exception tracebacks when you don't have logging set up. - Use the
-v(verbose) flag with Python's-m pdbfor interactive debugging sessions. - For web frameworks like Flask or Django, use their built-in logging configurations that integrate with the
loggingmodule.
Real-world use cases
- E-commerce checkout flow: log each step (cart validation, payment processing, order confirmation) to diagnose where purchases fail.
- API server debugging: log incoming requests with request ID and user agent to trace errors across services.
- Data pipeline monitoring: log row counts, processing times, and validation errors to detect data quality issues early.
Key takeaways
- Use
print()for one-off debugging in scripts you're actively developing. - Use
loggingfor any code that runs in production — it gives you level-based filtering, timestamps, and file output. - Configure logging once at application startup with
logging.basicConfig(), then use module-level loggers:logger = logging.getLogger(__name__). - Log levels (DEBUG, INFO, WARNING, ERROR, CRITICAL) let you control verbosity without editing code.
- Lazy formatting in logging calls (
%splaceholders) avoids performance cost when the log level is disabled.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.