Python Logging: Stop Using print() for Debugging
Every airplane has a flight recorder — a "black box" that silently records everything that happens during a flight. If something goes wrong, investigators can replay the recording to figure out exactly what happened and when.
Python's logging module is the black box for your programs. Instead of scattering print() statements everywhere and then deleting them later, you write log messages that can be turned on or off, filtered by importance, and formatted however you want.
In this tutorial, you'll learn why print() is a poor debugging tool, how log levels work, and how to set up logging that actually helps you find bugs.
Why Is print() Bad for Debugging?
We've all done it. Something isn't working, so you throw in a print() to see what's going on. Then another. And another. Pretty soon your output is a wall of text and you can't tell the important messages from the noise.
See how the debug messages get mixed in with the real output? You'd have to delete or comment out every print() before sharing this code. And if the bug comes back, you'd have to add them all again.
# Must manually add and remove
print('DEBUG: entering function')
print('DEBUG: x =', x)
# Must delete before production
# No way to filter by severity
# No timestamps# Turn on/off with one line
import logging
logging.debug('Entering function')
logging.debug('x = %s', x)
# Just change the log level
# Filter by severity
# Automatic timestampsWhat Are Log Levels and When Should You Use Each?
Not all messages are equally important. A message saying "connected to database" is nice to know. A message saying "database is on fire" is urgent. Log levels let you categorize messages by importance.
Python has five built-in log levels, from least to most severe:
When you set a log level, Python shows that level and everything above it. If you set the level to WARNING, you'll see WARNING, ERROR, and CRITICAL messages — but DEBUG and INFO are silenced.
How Do You Set Up Basic Logging?
The basicConfig() function is the quickest way to configure logging. You call it once at the top of your program. It sets the level, format, and where messages go.
The format string controls what each log line looks like. %(levelname)s inserts the level name, and %(message)s inserts your message. The force=True parameter ensures the config applies even if logging was already configured.
How Do You Format Log Messages?
A good log message answers three questions: when did it happen, how serious is it, and what happened. You control this with format codes.
Here are the most useful format codes:
%(asctime)s — Timestamp (when)%(levelname)s — Level name like DEBUG, INFO (how serious)%(message)s — Your message (what happened)%(name)s — Logger name%(funcName)s — Function name where the log was calledThe datefmt parameter controls how the timestamp looks. %H:%M:%S gives you hours:minutes:seconds. In production, you'd typically include the full date too.
How Do You Use Logging Inside Functions?
Logging really shines when you use it inside functions. It lets you trace how data flows through your program without cluttering the output that your users see.
Notice how the log messages tell the story of what happened. You can see exactly which path the code took and why. This is incredibly useful when debugging problems in larger programs.
Here's a more complete example showing how logging helps trace a multi-step process.
Practice Exercises
What will this code print? Remember, when the level is set to WARNING, only WARNING and above are shown.
import logging
logging.basicConfig(level=logging.WARNING, format='%(levelname)s: %(message)s', force=True)
logging.debug('step 1')
logging.info('step 2')
logging.warning('step 3')
logging.error('step 4')Write a function called divide(a, b) that divides two numbers and uses logging:
1. Set up basicConfig with level=logging.DEBUG and format='%(levelname)s: %(message)s' and force=True
2. At the start of the function, log a DEBUG message: 'Dividing %s by %s', a, b
3. If b is 0, log an ERROR message: 'Division by zero!' and return None
4. Otherwise, log an INFO message: 'Result: %s', result and return the result
Then call print(divide(10, 2)) and print(divide(5, 0)).
This code should show all five log messages, but only some appear. Find and fix the bug so that all five messages are displayed.
Expected output:
DEBUG: Starting
INFO: Processing
WARNING: Slow query
ERROR: Connection lost
CRITICAL: System downWrite a function check_login(username, password) with proper logging.
1. Configure logging: level=DEBUG, format='%(levelname)s: %(message)s', force=True
2. Log a DEBUG message: 'Login attempt for user: <username>'
3. If username is empty, log WARNING: 'Empty username provided' and return False
4. If password has fewer than 8 characters, log WARNING: 'Password too short for user: <username>' and return False
5. Otherwise log INFO: 'Login successful for user: <username>' and return True
Test with:
print(check_login('alice', 'securepass123'))
print(check_login('', 'pass'))
print(check_login('bob', 'short'))Refactor this function to replace all print() debug statements with proper logging calls. Keep the final print() for user output.
Original code:
def process_scores(scores):
print('DEBUG: Received scores:', scores)
if not scores:
print('ERROR: No scores provided')
return None
total = sum(scores)
print('DEBUG: Total =', total)
average = total / len(scores)
print('INFO: Average calculated:', average)
return average1. Set up logging with level=DEBUG, format='%(levelname)s: %(message)s', force=True
2. Replace print DEBUGs with logging.debug()
3. Replace print ERROR with logging.error()
4. Replace print INFO with logging.info()
5. Call print(process_scores([85, 92, 78, 90])) and print(process_scores([]))