Python logging Module: A Practical Guide for Real Applications
print() statements are fine for a quick debug session, but they don’t survive production. You can’t filter them by severity, redirect them to a file, rotate them when they get large, or ship them to a log aggregator. Python’s built-in logging module handles all of this, and once you understand its architecture, configuring it for any scenario takes about ten lines.
The Basics: Five Log Levels
import logging
logging.debug("Detailed diagnostic info")
logging.info("Normal application events")
logging.warning("Something unexpected, but recoverable")
logging.error("A function failed")
logging.critical("Application cannot continue")
Levels in order of severity: DEBUG < INFO < WARNING < ERROR < CRITICAL. The root logger defaults to WARNING, so debug and info are silenced unless you configure it otherwise.
Getting a Named Logger
Don’t use the root logger in library or application code. Create a named logger per module:
import logging
logger = logging.getLogger(__name__)
def process(data):
logger.info("Processing %d records", len(data))
for record in data:
logger.debug("Record: %s", record)
__name__ evaluates to the module’s dotted path (e.g., myapp.services.user). This lets you control log verbosity per module in configuration.
Basic Configuration
For scripts and small applications, basicConfig sets up a handler in one call:
import logging
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
logger.info("Application starting")
logger.debug("Debug mode active")
2024-03-15 10:42:01 INFO __main__: Application starting
2024-03-15 10:42:01 DEBUG __main__: Debug mode active
Call basicConfig once, at the entry point of your application. Calling it in library code is an anti-pattern.
Handlers: Where Logs Go
A logger can have multiple handlers — each sends log records to a different destination:
import logging
from logging.handlers import RotatingFileHandler
logger = logging.getLogger("myapp")
logger.setLevel(logging.DEBUG)
# Console handler — only warnings and above
console = logging.StreamHandler()
console.setLevel(logging.WARNING)
console.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
# File handler — everything, rotating at 5MB
file_handler = RotatingFileHandler(
"app.log", maxBytes=5 * 1024 * 1024, backupCount=3
)
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(
logging.Formatter("%(asctime)s %(levelname)-8s %(name)s: %(message)s")
)
logger.addHandler(console)
logger.addHandler(file_handler)
Common built-in handlers:
| Handler | Use |
|---|---|
StreamHandler |
stdout / stderr |
FileHandler |
plain file |
RotatingFileHandler |
rotates by size |
TimedRotatingFileHandler |
rotates by time (daily, weekly) |
SysLogHandler |
system syslog |
SMTPHandler |
email alerts for critical errors |
QueueHandler |
async logging (non-blocking) |
Formatters
A Formatter controls what each log line looks like. Key format fields:
%(asctime)s 2024-03-15 10:42:01,123
%(levelname)s DEBUG / INFO / WARNING / ERROR / CRITICAL
%(name)s logger name (module path)
%(message)s the log message
%(filename)s source file
%(lineno)d line number
%(funcName)s function name
%(process)d PID
%(thread)d thread ID
A format string that includes location info — useful during development:
fmt = "%(asctime)s %(levelname)-8s [%(name)s:%(lineno)d] %(message)s"
Structured Logging with extra and JSON
For production systems feeding into Elasticsearch, Datadog, or similar, JSON logs are easier to parse than text. Use the extra parameter to attach fields:
import logging
import json
class JsonFormatter(logging.Formatter):
def format(self, record):
log = {
"time": self.formatTime(record),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
if hasattr(record, "user_id"):
log["user_id"] = record.user_id
if record.exc_info:
log["exception"] = self.formatException(record.exc_info)
return json.dumps(log)
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
logging.getLogger().addHandler(handler)
logger = logging.getLogger("myapp")
logger.info("User logged in", extra={"user_id": 42})
{"time": "2024-03-15 10:42:01,123", "level": "INFO", "logger": "myapp", "message": "User logged in", "user_id": 42}
For production use, the python-json-logger package is a robust alternative to rolling your own.
Logging Exceptions
Pass exc_info=True or use logger.exception() (which implies ERROR level and exc_info=True):
try:
result = risky_operation()
except ValueError as e:
logger.exception("Operation failed for input %s", input_data)
# Logs the message + full traceback
2024-03-15 10:42:01 ERROR myapp: Operation failed for input bad_data
Traceback (most recent call last):
File "app.py", line 12, in main
result = risky_operation()
ValueError: invalid literal
Configuring via logging.config
For larger applications, keep logging configuration out of code using a dict-config:
import logging.config
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"standard": {
"format": "%(asctime)s %(levelname)-8s %(name)s: %(message)s"
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "standard",
"level": "INFO",
},
"file": {
"class": "logging.handlers.RotatingFileHandler",
"filename": "app.log",
"maxBytes": 10_000_000,
"backupCount": 5,
"formatter": "standard",
"level": "DEBUG",
},
},
"root": {
"handlers": ["console", "file"],
"level": "DEBUG",
},
"loggers": {
"httpx": {"level": "WARNING"}, # silence verbose third-party loggers
"sqlalchemy.engine": {"level": "WARNING"},
},
}
logging.config.dictConfig(LOGGING)
Performance: Lazy Formatting
Logging uses %-style formatting deliberately — the message string is only formatted if the log level is active. Don’t use f-strings in log calls:
# Bad — string is always formatted, even if DEBUG is silenced
logger.debug(f"Processing {len(data)} records with config {config}")
# Good — formatting only happens if DEBUG is active
logger.debug("Processing %d records with config %s", len(data), config)
Propagation and the Logger Hierarchy
Loggers form a tree rooted at the root logger. By default, a logger propagates records up to its parent. This means configuring the root logger is usually enough — child loggers (like myapp.db) inherit handlers automatically.
Set propagate = False to stop a logger from passing records up — useful when you want a specific logger to write to its own file without also appearing in the root handler.
db_logger = logging.getLogger("myapp.db")
db_logger.propagate = False
db_logger.addHandler(db_file_handler)
Conclusion
The logging module’s architecture — loggers, handlers, formatters — is more powerful than it looks from the outside. The key habits: use getLogger(__name__) in every module, call basicConfig or dictConfig once at startup, always use %s lazy formatting rather than f-strings, and prefer JSON output if your logs will be machine-parsed. These four changes alone take you from print debugging to production-ready observability.