Maintenance

Site is under maintenance — quizzes are still available.

Go to quizzes

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

Sponsored

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=True parameter 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

  1. Calling logging.info() but seeing no output. - Cause: The default logging level is WARNING. You need logging.basicConfig(level=logging.DEBUG) or at least level=logging.INFO. - Fix: Always explicitly set the level when configuring logging.

  2. 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 use logging.getLogger().setLevel().

  3. Forgotten print() statements in production code. - Cause: No easy way to suppress all prints. - Fix: Use a function like DEBUG = True at the top, then if DEBUG: print(...). Better yet, switch to logging.

  4. Logging to stdout when you expected a file. - Cause: No filename argument in basicConfig(). - Fix: logging.basicConfig(filename='app.log', level=logging.INFO).

  5. 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. logging is 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.
  • logging for 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 setting basicConfig(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

  1. Use traceback.print_exc() to print exception tracebacks when you don't have logging set up.
  2. Use the -v (verbose) flag with Python's -m pdb for interactive debugging sessions.
  3. For web frameworks like Flask or Django, use their built-in logging configurations that integrate with the logging module.

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 logging for 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 (%s placeholders) avoids performance cost when the log level is disabled.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.