Professional Logging
Replace print() with production-ready logging.
Introduction
While print() statements work for debugging during development, production applications need a more sophisticated approach. Python's logging module provides a powerful, flexible logging system that lets you record events, track issues, and monitor application behavior without cluttering your code with prints. You can control logging levels, format messages, write to files, rotate logs, and more, all with simple configuration. Mastering logging is essential for building maintainable, production-ready applications that you can troubleshoot and monitor effectively.
Why Use Logging Instead of print()?
The logging module offers significant advantages over print statements for real applications.
✗ Problems with print()
- Goes to stdout (can't control destination)
- No severity levels (all messages equal)
- Hard to disable in production
- No timestamps or context
- Difficult to filter or search
- Must manually remove before deployment
✓ Benefits of logging
- Control output destination (files, console, network)
- Five severity levels (DEBUG to CRITICAL)
- Easy to enable/disable per level
- Automatic timestamps and metadata
- Built-in formatting and filtering
- Leave in code, control via configuration
# ❌ Using print() - limited and messy
def process_data(data):
print(f"Processing {len(data)} items...")
for item in data:
print(f"Processing item: {item}")
# Process item
print("Done processing")
# Problems:
# - Can't turn off for production
# - No timestamps
# - No way to filter by importance
# - Goes directly to stdout
# ✅ Using logging - professional and flexible
import logging
def process_data(data):
logging.info("Processing %s items...", len(data))
for item in data:
logging.debug("Processing item: %s", item)
# Process item
logging.info("Done processing")
# Benefits:
# - Can set level to INFO in production (hides debug messages)
# - Automatic timestamps
# - Can write to files
# - Easy to configure
# Note the "%s" style above instead of an f-string. Logging only builds the
# final message if the level is actually enabled, so with level=INFO the
# debug() call costs nothing. An f-string is formatted eagerly, before
# logging is even called, so you pay for a message that gets thrown away:
# logging.debug(f"Processing item: {item}") # formatted even when hidden
# logging.debug("Processing item: %s", item) # formatted only if shownBasic Logging Usage
The logging module provides simple functions to get started immediately.
Quick Start with basicConfig()
import logging
# Configure logging (do this once at the start of your program)
logging.basicConfig(level=logging.DEBUG)
# Use logging functions
logging.debug("This is a debug message")
logging.info("This is an info message")
logging.warning("This is a warning message")
logging.error("This is an error message")
logging.critical("This is a critical message")Expected Output:
DEBUG:root:This is a debug message INFO:root:This is an info message WARNING:root:This is a warning message ERROR:root:This is an error message CRITICAL:root:This is a critical message
Basic Configuration Options
import logging
# Configure with format and date format
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logging.info("Application started")
logging.warning("This is a warning")
# IMPORTANT: basicConfig() only configures the root logger the FIRST time it
# runs. Once a handler exists it returns silently, so the call below has no
# effect at all: no file is created, no error is raised, and the two messages
# after it still go to the console in the format set above.
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
filename='app.log',
filemode='w' # 'w' = overwrite, 'a' = append
)
logging.info("This was meant for app.log")
logging.error("So was this")
# If you genuinely need to reconfigure inside one program, pass force=True
# (Python 3.8+), which closes and replaces the existing handlers:
logging.basicConfig(level=logging.DEBUG, filename='app.log', force=True)
logging.info("This one really is written to app.log")
print("--- app.log ---")
print(open('app.log').read(), end='')Expected Output:
2026-09-09 14:49:42 - INFO - Application started 2026-09-09 14:49:42 - WARNING - This is a warning 2026-09-09 14:49:42 - INFO - This was meant for app.log 2026-09-09 14:49:42 - ERROR - So was this --- app.log --- INFO:root:This one really is written to app.log
Important: Call basicConfig()only ONCE at the start of your program. Subsequent calls have no effect. For more complex setups, use loggers and handlers (covered later).
Understanding Log Levels
Python's logging has five standard levels. Each level has a numeric value, and messages are only shown if their level is greater than or equal to the configured level.
The Five Log Levels
| Level | Numeric Value | When to Use | Example |
|---|---|---|---|
DEBUG | 10 | Detailed information for diagnosing problems | Variable values, function entry/exit |
INFO | 20 | General informational messages | "Server started", "Processing 100 items" |
WARNING | 30 | Something unexpected but not an error | "Disk 90% full", "Deprecated API used" |
ERROR | 40 | Error that prevents a specific operation | "Failed to save file", "Database connection failed" |
CRITICAL | 50 | Serious error that may cause shutdown | "Out of memory", "Configuration file missing" |
How Log Levels Filter Messages
import logging
# Set level to WARNING - only WARNING, ERROR, CRITICAL show
logging.basicConfig(level=logging.WARNING)
logging.debug("Debug message") # ❌ Won't show (10 < 30)
logging.info("Info message") # ❌ Won't show (20 < 30)
logging.warning("Warning message") # ✓ Shows (30 >= 30)
logging.error("Error message") # ✓ Shows (40 >= 30)
logging.critical("Critical!") # ✓ Shows (50 >= 30)
# Common configurations:
# Development: level=logging.DEBUG (see everything)
# Production: level=logging.INFO or WARNING (hide debug details)
# Production (quiet): level=logging.ERROR (only errors)Expected Output:
WARNING:root:Warning message ERROR:root:Error message CRITICAL:root:Critical!
Practical Example: Using Levels Appropriately
import logging
logging.basicConfig(
level=logging.DEBUG,
format='%(levelname)s: %(message)s'
)
# Stand-ins for the real data layer
USERS = {123: {"name": "Alice", "email": None}}
def get_user(user_id):
return USERS.get(user_id)
def do_processing(user):
return {"name": user["name"].upper()}
def connect_to_database():
raise ConnectionError("could not reach db:5432")
def process_user_data(user_id):
"""Process user data with appropriate logging"""
# DEBUG: Detailed diagnostic information
logging.debug("Entering process_user_data(user_id=%s)", user_id)
# INFO: Confirmation that things are working
logging.info("Processing user %s", user_id)
user = get_user(user_id)
if user is None:
# ERROR: Failed to complete operation
logging.error("User %s not found", user_id)
return None
# WARNING: Something unexpected but recoverable
if user.get("email") is None:
logging.warning("User %s has no email address", user_id)
# DEBUG: Show intermediate values
logging.debug("User data: %s", user)
result = do_processing(user)
# INFO: Operation completed successfully
logging.info("Successfully processed user %s", user_id)
# DEBUG: Show return value
logging.debug("Returning: %s", result)
return result
def critical_operation():
"""Example of critical logging"""
try:
connect_to_database()
except Exception as e:
# CRITICAL: System cannot continue
logging.critical("Database connection failed: %s", e)
logging.critical("Application cannot continue without database")
raise
# Usage
process_user_data(123)
process_user_data(999)
try:
critical_operation()
except ConnectionError:
passExpected Output:
DEBUG: Entering process_user_data(user_id=123)
INFO: Processing user 123
WARNING: User 123 has no email address
DEBUG: User data: {'name': 'Alice', 'email': None}
INFO: Successfully processed user 123
DEBUG: Returning: {'name': 'ALICE'}
DEBUG: Entering process_user_data(user_id=999)
INFO: Processing user 999
ERROR: User 999 not found
CRITICAL: Database connection failed: could not reach db:5432
CRITICAL: Application cannot continue without databaseLoggers, Handlers, and Formatters
For more control, use the three main components: Loggers (create messages), Handlers (send messages to destinations), and Formatters (format message appearance).
Creating a Named Logger
Instead of the root logger, create named loggers for different modules.
import logging
# A logger level alone is not enough: with no handler anywhere in the chain
# Python falls back to logging.lastResort, which only emits WARNING and above
# and prints the bare message. basicConfig() installs a real handler.
logging.basicConfig(level=logging.DEBUG, format='%(levelname)s:%(name)s:%(message)s')
# Create a named logger
logger = logging.getLogger(__name__) # Use module name
logger.setLevel(logging.DEBUG)
# Use the logger
logger.debug("Debug message")
logger.info("Info message")
logger.warning("Warning message")
# Best practice: one logger per module, named after the module. Running this
# file directly makes __name__ "__main__"; imported as my_module it would be
# "my_module", and the name appears in every line that logger emits.
module_logger = logging.getLogger("my_module")
module_logger.info("Function called")
# Hierarchical loggers: a dot in the name creates a parent/child relationship
db_logger = logging.getLogger("myapp.database")
api_logger = logging.getLogger("myapp.api")
users_logger = logging.getLogger("myapp.api.users")
# Configuring the parent affects every child that has no level of its own
app_logger = logging.getLogger("myapp")
app_logger.setLevel(logging.INFO) # Applies to all myapp.* loggers
db_logger.debug("Connection pool details") # Dropped: DEBUG < INFO
api_logger.info("Request received")
users_logger.warning("Unknown field in payload")Expected Output:
DEBUG:__main__:Debug message INFO:__main__:Info message WARNING:__main__:Warning message INFO:my_module:Function called INFO:myapp.api:Request received WARNING:myapp.api.users:Unknown field in payload
Handlers: Directing Output
Handlers determine where log messages go. You can add multiple handlers to one logger.
import logging
# Create logger
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
# Create console handler (logs to terminal)
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO) # Only INFO and above to console
# Create file handler (logs to file)
file_handler = logging.FileHandler('app.log')
file_handler.setLevel(logging.DEBUG) # All levels to file
# Add handlers to logger
logger.addHandler(console_handler)
logger.addHandler(file_handler)
# Now messages go to both places. Neither handler has a formatter, so both
# print the bare message.
logger.debug("This goes only to file")
logger.info("This goes to both console and file")
logger.error("This goes to both console and file")
print("--- app.log ---")
print(open('app.log').read(), end='')
# Common handlers:
# StreamHandler() - Console output
# FileHandler(filename) - Write to file
# RotatingFileHandler() - Rotate files by size
# TimedRotatingFileHandler() - Rotate files by time
# SMTPHandler() - Email logs
# SysLogHandler() - System log
# HTTPHandler() - Send logs to HTTP serverExpected Output:
This goes to both console and file This goes to both console and file --- app.log --- This goes only to file This goes to both console and file This goes to both console and file
Formatters: Customizing Message Format
Formatters control how log messages appear.
import logging
# Create logger
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
# Create formatter
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
# Create handler and set formatter
handler = logging.StreamHandler()
handler.setFormatter(formatter)
logger.addHandler(handler)
logger.info("Application started")
# Common format specifiers:
# %(asctime)s - Human-readable time
# %(created)f - Time as float
# %(levelname)s - Log level (DEBUG, INFO, etc.)
# %(message)s - The log message
# %(name)s - Logger name
# %(filename)s - Source filename
# %(lineno)d - Line number
# %(funcName)s - Function name
# %(process)d - Process ID
# %(thread)d - Thread ID
# Detailed formatter for debugging: %(levelname)8s right-aligns in 8 columns
detailed_formatter = logging.Formatter(
'%(asctime)s [%(levelname)8s] %(name)s:%(lineno)d - %(message)s'
)
# Simple formatter for production
simple_formatter = logging.Formatter(
'%(levelname)s: %(message)s'
)
# Swap the formatter to see the difference on the same logger
handler.setFormatter(detailed_formatter)
logger.warning("Detailed format")
handler.setFormatter(simple_formatter)
logger.warning("Simple format")Expected Output:
2026-09-09 14:49:42 - __main__ - INFO - Application started 2026-09-09 14:49:42,623 [ WARNING] __main__:44 - Detailed format WARNING: Simple format
Complete Example: Logger with Multiple Handlers
import logging
def setup_logger(name):
"""Set up a logger with both console and file handlers"""
# Create logger
logger = logging.getLogger(name)
logger.setLevel(logging.DEBUG)
# Prevent duplicate handlers
if logger.handlers:
return logger
# Console handler with INFO level
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_format = logging.Formatter(
'%(levelname)s: %(message)s'
)
console_handler.setFormatter(console_format)
# File handler with DEBUG level
file_handler = logging.FileHandler('app.log')
file_handler.setLevel(logging.DEBUG)
file_format = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
file_handler.setFormatter(file_format)
# Add handlers to logger
logger.addHandler(console_handler)
logger.addHandler(file_handler)
return logger
# Use it
logger = setup_logger(__name__)
logger.debug("Detailed debug info") # Only in file
logger.info("User logged in") # Both console and file
logger.error("Failed to save data") # Both console and file
# %(asctime)s with no datefmt keeps the milliseconds after the comma
print("--- app.log ---")
print(open('app.log').read(), end='')Expected Output:
INFO: User logged in ERROR: Failed to save data --- app.log --- 2026-09-09 14:49:42,645 - __main__ - DEBUG - Detailed debug info 2026-09-09 14:49:42,645 - __main__ - INFO - User logged in 2026-09-09 14:49:42,645 - __main__ - ERROR - Failed to save data
Logging to Files
File logging is essential for production applications. Learn to manage log files effectively.
Basic File Logging
import logging
# Simple file logging
logging.basicConfig(
filename='app.log',
filemode='a', # 'a' = append, 'w' = overwrite
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logging.info("Application started")
logging.warning("Low memory warning")
logging.error("Failed to connect to API")
# Using FileHandler for more control
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
# Create file handler
file_handler = logging.FileHandler(
'application.log',
mode='a',
encoding='utf-8'
)
file_handler.setLevel(logging.DEBUG)
# Add formatter
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
# This record reaches application.log through the handler above, and ALSO
# app.log, because records propagate up to the root logger that basicConfig
# configured. Set logger.propagate = False to keep it out of the root's file.
logger.info("Logging to file with custom handler")
for name in ('app.log', 'application.log'):
print(f"--- {name} ---")
print(open(name).read(), end='')Expected Output:
--- app.log --- 2026-09-09 14:49:42,664 - INFO - Application started 2026-09-09 14:49:42,664 - WARNING - Low memory warning 2026-09-09 14:49:42,664 - ERROR - Failed to connect to API 2026-09-09 14:49:42,664 - INFO - Logging to file with custom handler --- application.log --- 2026-09-09 14:49:42,664 - __main__ - INFO - Logging to file with custom handler
Rotating Log Files
Prevent log files from growing indefinitely by using rotation.
import logging
import os
from logging.handlers import RotatingFileHandler
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
# In production this would be maxBytes=1024 * 1024 (1 MB). It is 1 KB here so
# the demo below actually rolls over instead of writing one small file.
handler = RotatingFileHandler(
'app.log',
maxBytes=1024, # 1 KB
backupCount=5 # keep app.log.1 through app.log.5
)
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
handler.setFormatter(formatter)
logger.addHandler(handler)
for i in range(1, 101):
logger.info("Log record number %s", i)
print("Files on disk:", sorted(f for f in os.listdir('.') if f.startswith('app.log')))
# When app.log reaches maxBytes:
# 1. app.log.5 is deleted
# 2. app.log.4 -> app.log.5
# 3. app.log.3 -> app.log.4
# 4. app.log.2 -> app.log.3
# 5. app.log.1 -> app.log.2
# 6. app.log -> app.log.1
# 7. New app.log is createdExpected Output:
Files on disk: ['app.log', 'app.log.1', 'app.log.2', 'app.log.3', 'app.log.4', 'app.log.5']
Time-Based Rotation
import logging
import os
import time
from logging.handlers import TimedRotatingFileHandler
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
# Production setting: when='midnight', interval=1, backupCount=7 keeps a week
# of daily files. This demo rolls every second so it finishes in a few seconds.
handler = TimedRotatingFileHandler(
'app.log',
when='S',
interval=1,
backupCount=3
)
# Options for 'when':
# 'S' - Seconds
# 'M' - Minutes
# 'H' - Hours
# 'D' - Days
# 'midnight' - Roll over at midnight
# 'W0'-'W6' - Roll over on specific weekday (0=Monday)
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
handler.setFormatter(formatter)
logger.addHandler(handler)
for i in range(1, 4):
logger.info("Record written in interval %s", i)
time.sleep(1.1)
# The rolled files carry the rollover time as a suffix
for name in sorted(f for f in os.listdir('.') if f.startswith('app.log')):
print(name)Expected Output:
app.log app.log.2026-09-09_14-49-42 app.log.2026-09-09_14-49-43
Production-Ready File Logging Setup
# Save as myapp.py, then run: python myapp.py
import logging
import os
from logging.handlers import RotatingFileHandler
def setup_production_logging(app_name):
"""Configure production-ready logging"""
# Create logs directory if it doesn't exist
log_dir = 'logs'
if not os.path.exists(log_dir):
os.makedirs(log_dir)
# Create logger
logger = logging.getLogger(app_name)
logger.setLevel(logging.DEBUG)
# Prevent duplicate handlers
if logger.handlers:
return logger
# Console handler - INFO and above
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_format = logging.Formatter(
'%(asctime)s [%(levelname)s] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
console_handler.setFormatter(console_format)
# File handler - all levels, rotating
file_handler = RotatingFileHandler(
os.path.join(log_dir, f'{app_name}.log'),
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=5
)
file_handler.setLevel(logging.DEBUG)
file_format = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - '
'%(filename)s:%(lineno)d - %(message)s'
)
file_handler.setFormatter(file_format)
# Error file handler - ERROR and above only
error_handler = RotatingFileHandler(
os.path.join(log_dir, f'{app_name}_errors.log'),
maxBytes=5 * 1024 * 1024, # 5 MB
backupCount=3
)
error_handler.setLevel(logging.ERROR)
error_handler.setFormatter(file_format)
# Add all handlers
logger.addHandler(console_handler)
logger.addHandler(file_handler)
logger.addHandler(error_handler)
return logger
# Use it
logger = setup_production_logging('myapp')
logger.debug("Debug information") # Only in myapp.log
logger.info("Application started") # Console + myapp.log
logger.error("Database error") # Console + both log files
# Backups (myapp.log.1 and so on) only appear once a file passes maxBytes
for name in sorted(os.listdir('logs')):
print(f"--- logs/{name} ---")
print(open(os.path.join('logs', name)).read(), end='')Expected Output:
2026-09-09 14:49:46 [INFO] Application started 2026-09-09 14:49:46 [ERROR] Database error --- logs/myapp.log --- 2026-09-09 14:49:46,041 - myapp - DEBUG - myapp.py:63 - Debug information 2026-09-09 14:49:46,041 - myapp - INFO - myapp.py:64 - Application started 2026-09-09 14:49:46,042 - myapp - ERROR - myapp.py:65 - Database error --- logs/myapp_errors.log --- 2026-09-09 14:49:46,042 - myapp - ERROR - myapp.py:65 - Database error
Best practice: Always log to files in production. Use separate files for different log levels (info vs errors) to make troubleshooting faster. Implement log rotation to prevent disk space issues.
Practical Logging Patterns
Real-world logging patterns for common scenarios.
Logging Exceptions
# Save as divide_demo.py, then run: python divide_demo.py
import logging
logging.basicConfig(level=logging.DEBUG, format='%(levelname)s:%(name)s:%(message)s')
logger = logging.getLogger(__name__)
def divide(a, b):
try:
result = a / b
logger.info("Division successful: %s / %s = %s", a, b, result)
return result
except ZeroDivisionError:
# Log exception with traceback
logger.exception("Division by zero error")
# OR use exc_info=True
# logger.error("Division by zero error", exc_info=True)
return None
except Exception:
# Log unexpected exceptions
logger.exception("Unexpected error during division")
raise
# Test it
divide(10, 2)
divide(10, 0)
# Best practice: Use logger.exception() in except blocks. It logs at ERROR
# level and attaches the active traceback automatically.Expected Output:
INFO:__main__:Division successful: 10 / 2 = 5.0
ERROR:__main__:Division by zero error
Traceback (most recent call last):
File "/tmp/tmpkzhahv0k/divide_demo.py", line 9, in divide
result = a / b
~~^~~
ZeroDivisionError: division by zeroFunction Execution Logging
import functools
import logging
import time
logging.basicConfig(level=logging.DEBUG, format='%(levelname)s:%(name)s:%(message)s')
logger = logging.getLogger(__name__)
def process_item(item):
return item * 2
def process_data(data):
"""Process data with logging"""
# Log function entry
logger.info("Starting process_data with %s items", len(data))
start_time = time.time()
try:
# Log progress
for i, item in enumerate(data):
logger.debug("Processing item %s/%s: %s", i + 1, len(data), item)
process_item(item)
# Log milestones
if (i + 1) % 100 == 0:
logger.info("Processed %s items so far...", i + 1)
# Log success
elapsed = time.time() - start_time
logger.info(
"Successfully processed %s items in %.2f seconds", len(data), elapsed
)
except Exception:
logger.exception("Failed to process data")
raise
finally:
logger.debug("Exiting process_data")
process_data([10, 20, 30])
# Create a decorator for automatic logging
def log_execution(logger):
"""Decorator to log function execution"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
logger.debug("Calling %s()", func.__name__)
try:
result = func(*args, **kwargs)
logger.debug("%s() completed successfully", func.__name__)
return result
except Exception:
logger.exception("%s() raised exception", func.__name__)
raise
return wrapper
return decorator
# Use the decorator
@log_execution(logger)
def calculate_total(items):
return sum(items)
calculate_total([1, 2, 3, 4, 5])Expected Output:
INFO:__main__:Starting process_data with 3 items DEBUG:__main__:Processing item 1/3: 10 DEBUG:__main__:Processing item 2/3: 20 DEBUG:__main__:Processing item 3/3: 30 INFO:__main__:Successfully processed 3 items in 0.00 seconds DEBUG:__main__:Exiting process_data DEBUG:__main__:Calling calculate_total() DEBUG:__main__:calculate_total() completed successfully
Structured Logging with Extra Data
import logging
from uuid import uuid4
logger = logging.getLogger(__name__)
def generate_request_id():
return str(uuid4())
# Add extra context to log messages
def process_user_request(user_id, action):
# Pass extra data with the 'extra' parameter
logger.info(
"Processing request",
extra={
'user_id': user_id,
'action': action,
'request_id': generate_request_id()
}
)
# Create custom formatter to include extra fields
class StructuredFormatter(logging.Formatter):
def format(self, record):
# Add custom fields if they exist
if hasattr(record, 'user_id'):
record.msg = f"[User: {record.user_id}] {record.msg}"
if hasattr(record, 'request_id'):
record.msg = f"[Request: {record.request_id}] {record.msg}"
return super().format(record)
# Use it
handler = logging.StreamHandler()
formatter = StructuredFormatter(
'%(asctime)s - %(levelname)s - %(message)s'
)
handler.setFormatter(formatter)
logger.addHandler(handler)
# Without this the logger inherits the root level (WARNING) and the INFO
# calls below are silently dropped, no matter what the handler allows.
logger.setLevel(logging.INFO)
# Example: User context throughout request
class RequestContext:
def __init__(self, user_id, request_id):
self.user_id = user_id
self.request_id = request_id
def log(self, level, message):
logger.log(
level,
message,
extra={
'user_id': self.user_id,
'request_id': self.request_id
}
)
context = RequestContext(user_id=123, request_id='abc-456')
context.log(logging.INFO, "Processing payment")
context.log(logging.ERROR, "Payment failed")
# The request id here is a fresh uuid4, so it differs on every run
process_user_request(user_id=456, action='checkout')Expected Output:
2026-09-09 14:49:46,100 - INFO - [Request: abc-456] [User: 123] Processing payment 2026-09-09 14:49:46,100 - ERROR - [Request: abc-456] [User: 123] Payment failed 2026-09-09 14:49:46,100 - INFO - [Request: 5b95655d-5bbb-4401-a6fd-2b69acc1b625] [User: 456] Processing request
Configuration from File
Keeping the configuration outside the code lets you change levels and destinations without touching the program. The ini format is read by logging.config.fileConfig():
# logging_config.ini
[loggers]
keys=root,myapp
[handlers]
keys=consoleHandler,fileHandler
[formatters]
keys=simpleFormatter,detailedFormatter
[logger_root]
level=INFO
handlers=consoleHandler
[logger_myapp]
level=DEBUG
handlers=consoleHandler,fileHandler
qualname=myapp
propagate=0
[handler_consoleHandler]
class=StreamHandler
level=INFO
formatter=simpleFormatter
args=(sys.stdout,)
[handler_fileHandler]
class=FileHandler
level=DEBUG
formatter=detailedFormatter
args=('app.log', 'a')
[formatter_simpleFormatter]
format=%(levelname)s: %(message)s
[formatter_detailedFormatter]
format=%(asctime)s - %(name)s - %(levelname)s - %(message)s
datefmt=%Y-%m-%d %H:%M:%SLoading it takes two lines, after which every module that calls getLogger('myapp') picks up the console and file handlers defined above:
import logging.config
# Reads the [loggers] / [handlers] / [formatters] sections shown above
logging.config.fileConfig('logging_config.ini')
logger = logging.getLogger('myapp')
logger.info("Logging configured from file")
logger.debug("Only the file handler is at DEBUG, so this line is file-only")
print("--- app.log ---")
print(open('app.log').read(), end='')Expected Output:
INFO: Logging configured from file --- app.log --- 2026-09-09 14:49:46 - myapp - INFO - Logging configured from file 2026-09-09 14:49:46 - myapp - DEBUG - Only the file handler is at DEBUG, so this line is file-only
dictConfig() expresses the same structure as a dictionary, which you can build at runtime, load from JSON or YAML, and change per environment. It is the form most projects settle on:
import logging.config
# dictConfig is the more flexible option: the same structure as the ini file,
# expressed as a dict you can build at runtime
config = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'simple': {
'format': '%(levelname)s: %(message)s'
},
'detailed': {
'format': '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
}
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'level': 'INFO',
'formatter': 'simple',
'stream': 'ext://sys.stdout'
},
'file': {
'class': 'logging.handlers.RotatingFileHandler',
'level': 'DEBUG',
'formatter': 'detailed',
'filename': 'app.log',
'maxBytes': 10485760, # 10MB
'backupCount': 5
}
},
'loggers': {
'myapp': {
'level': 'DEBUG',
'handlers': ['console', 'file'],
'propagate': False
}
}
}
logging.config.dictConfig(config)
logger = logging.getLogger('myapp')
logger.info("Configured from a dict")
logger.debug("File handler only")
print("--- app.log ---")
print(open('app.log').read(), end='')Expected Output:
INFO: Configured from a dict --- app.log --- 2026-09-09 14:49:46,148 - myapp - INFO - Configured from a dict 2026-09-09 14:49:46,148 - myapp - DEBUG - File handler only
Logging Best Practices
✓ Do This
- Use named loggers (
__name__) - Choose appropriate log levels
- Log exceptions with
.exception() - Use structured logging (extra context)
- Configure logging once at startup
- Rotate log files in production
- Include timestamps and levels
- Log function entry/exit for complex flows
✗ Avoid This
- Don't use print() in production code
- Don't log sensitive data (passwords, tokens)
- Don't log in tight loops without throttling
- Don't use DEBUG level in production
- Don't forget to rotate log files
- Don't log entire large objects
- Don't call basicConfig() multiple times
- Don't mix logging and print statements
Key Takeaways
Logging Basics
- Use
loggingmodule, not print() - Five levels: DEBUG, INFO, WARNING, ERROR, CRITICAL
- Configure once with
basicConfig() - Messages filter by configured level
- Use
logger.exception()for errors
Components
- Loggers: Create log messages
- Handlers: Send to destinations
- Formatters: Format appearance
- Use named loggers:
getLogger(__name__) - Multiple handlers for multiple outputs
File Logging
- Use FileHandler for basic file logging
- RotatingFileHandler rotates by size
- TimedRotatingFileHandler rotates by time
- Keep separate error logs
- Always rotate in production
Production Tips
- Set level to INFO or WARNING
- Log to files, not just console
- Include context (user IDs, request IDs)
- Never log sensitive data
- Monitor log files for errors
What's Next?
You've mastered professional logging! Now let's learn how to create custom exceptions and build robust error handling systems for production applications.
- Custom Exceptions - Create meaningful exception classes for your application
- Exception Hierarchies - Build organized exception structures for better error handling
- Context Managers - Master the with statement and exception handling patterns