The Story That Explains Why Logging Exists
When something goes wrong at 30,000 feet, Pilot A has nothing. Pilot B replays the last 60 seconds and knows exactly which valve failed and when. That black box is identical in role to Python's logging module.
print() is the Post-It note. logging is the black box.
Python's logging module is the standard, thread-safe, level-aware,
configurable, production-ready way to record what your program is doing. It ships
with the standard library — no pip install required — and every serious
Python codebase uses it.
Logging is not "printing with extra steps." It's a separate output channel that carries a severity level, timestamp, source location, and context — and can be routed to files, the console, syslog, a network socket, or a JSON aggregator without changing a single line of your business code.
Why print() Fails in Production
Every Python developer starts with print(). Every senior Python developer
eventually rips them all out. Here's why.
| Problem | Consequence |
|---|---|
| No severity level | Debug noise mixes with real errors |
| Always goes to stdout | Cannot redirect selectively |
| No timestamp | Cannot correlate events |
| No file/line info | Grep the codebase to find source |
| Cannot silence | Must delete/comment before ship |
| Not thread-safe | Interleaved garbage output |
| Feature | Benefit |
|---|---|
| 5 severity levels | Filter noise in one config line |
| Multiple handlers | Console + file + email at once |
| Auto timestamps | Precise event ordering |
| Module/line auto-captured | Zero-cost source tracking |
| Toggle by env variable | DEBUG in dev, WARNING in prod |
| Thread & process safe | Clean output under load |
A codebase with 500 print() calls cannot be "turned down" for production.
You either see everything (unreadable) or delete them all (lose observability).
With logging, you change ONE line and the entire application quiets down.
The Five Log Levels
Every log message carries a severity. Python defines five standard levels — each with a numeric value. When the logger's level is set, anything below that threshold is silently dropped.
Quick Demo of All Five Levels
import logging
# The simplest possible setup
logging.basicConfig(level=logging.DEBUG)
logging.debug("Loop iteration i=5, value=42")
logging.info("Server started on port 8000")
logging.warning("Disk usage at 82%")
logging.error("Failed to send email to user@example.com")
logging.critical("Database connection pool exhausted!")
Development → DEBUG. Staging → INFO. Production → WARNING (or INFO if disk is cheap). Never ship with DEBUG on — you'll drown in noise and pay for disk.
The Anatomy of a Logging System
Python's logging module is built from four cooperating parts. Once you see the wiring, everything else becomes obvious.
log.info(...) creates a LogRecord and passes it to attached handlers. Loggers form a tree by dotted name.
Visual Flow — From log.info() to Disk
Getting a Real Logger — Never Use the Root
Beginners call logging.info(...) directly, which uses the root logger.
Professionals never do this. Instead, every module creates its own named logger with
logging.getLogger(__name__). This gives you free hierarchy, free filtering,
and free source identification.
# myapp/db.py
import logging
log = logging.getLogger(__name__) # -> 'myapp.db'
def query(sql):
log.debug("Executing SQL: %s", sql)
# ... run query ...
log.info("Query complete in %d ms", 42)
__name__ resolves to the module's dotted path — e.g. myapp.db.
This creates a hierarchical logger tree. You can then set the level of
myapp.db to WARNING to silence chatty DB
logs while leaving myapp.api at DEBUG. Zero code changes.
The Logger Hierarchy
| Logger Name | Parent | Effective Level (if not set) |
|---|---|---|
myapp | root | inherits from root |
myapp.db | myapp | inherits from myapp |
myapp.db.pool | myapp.db | walks up until a level is set |
myapp.api | myapp | can be set independently |
Formatters — Making Logs Readable
A raw INFO:root:User logged in line is not enough for production. You want
timestamp, level, source, thread, and message. Formatters use placeholders from the
LogRecord attributes.
| Placeholder | Meaning | Example |
|---|---|---|
%(asctime)s | Human-readable timestamp | 2026-07-12 14:32:07,213 |
%(name)s | Logger name | myapp.db |
%(levelname)s | Level as text | WARNING |
%(levelno)s | Level as number | 30 |
%(module)s | Python module name | db |
%(funcName)s | Function that called log | run_query |
%(filename)s | Source file | db.py |
%(lineno)d | Line number | 147 |
%(thread)d | Thread ID | 139823..1 |
%(threadName)s | Thread name | MainThread |
%(process)d | Process ID | 19832 |
%(message)s | The log text you wrote | User 42 logged in |
import logging
FMT = "%(asctime)s | %(levelname)-8s | %(name)s:%(lineno)d | %(message)s"
DATE = "%Y-%m-%d %H:%M:%S"
logging.basicConfig(level=logging.INFO, format=FMT, datefmt=DATE)
log = logging.getLogger("shop.checkout")
log.info("Order %s placed for $%.2f", "ORD-8821", 129.95)
log.warning("Coupon SUMMER25 expired but was accepted")
Using %(levelname)-8s left-pads the level to 8 characters. This makes
every log line align in columns so your eye can scan severity down the left edge
instantly. Small trick, massive readability win.
Handlers — Sending Logs Everywhere
A handler is a destination. The most common ones ship built-in with Python.
Full Setup — Console + Rotating File
import logging
from logging.handlers import RotatingFileHandler
# 1. Get a named logger
log = logging.getLogger("myapp")
log.setLevel(logging.DEBUG) # capture everything at the logger
# 2. Build a formatter
fmt = logging.Formatter(
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
# 3. Console handler -> only WARNING and up on screen
console = logging.StreamHandler()
console.setLevel(logging.WARNING)
console.setFormatter(fmt)
# 4. File handler -> everything DEBUG+ to disk, rotate at 5MB, keep 5 backups
file_h = RotatingFileHandler(
"myapp.log", maxBytes=5_000_000, backupCount=5
)
file_h.setLevel(logging.DEBUG)
file_h.setFormatter(fmt)
# 5. Attach both handlers
log.addHandler(console)
log.addHandler(file_h)
# Demo
log.debug("This goes to FILE only (console is WARNING+)")
log.info("This goes to FILE only")
log.warning("This goes to BOTH file and console")
log.error("This goes to BOTH file and console")
A record must pass two level checks: (1) the logger's level, then (2) the handler's level. Set the logger permissive (DEBUG) and let each handler restrict what it shows. This is the standard pattern.
Logging Exceptions — Get the Full Traceback
A bare log.error("Something failed") loses the exception. Use
log.exception() or exc_info=True to capture the full traceback.
import logging
logging.basicConfig(level=logging.INFO)
log = logging.getLogger(__name__)
def divide(a, b):
try:
return a / b
except ZeroDivisionError:
# Automatically appends the traceback
log.exception("Failed to divide %s by %s", a, b)
return None
divide(10, 0)
1. log.exception("msg") — implies ERROR level + traceback (use inside except:).
2. log.error("msg", exc_info=True) — same effect, any level.
3. log.error("msg", exc_info=e) — attach a specific exception object.
dictConfig — The Production Pattern
For real apps you don't wire handlers with Python code — you describe the entire
logging setup as a dictionary and pass it to logging.config.dictConfig().
This lives in one file and can be loaded from YAML/JSON at startup.
import logging.config
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"standard": {
"format": "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
"detailed": {
"format": "%(asctime)s | %(levelname)-8s | %(name)s | %(filename)s:%(lineno)d | %(message)s",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"level": "INFO",
"formatter": "standard",
},
"file": {
"class": "logging.handlers.RotatingFileHandler",
"level": "DEBUG",
"formatter": "detailed",
"filename": "app.log",
"maxBytes": 5_000_000,
"backupCount": 5,
},
"error_file": {
"class": "logging.handlers.RotatingFileHandler",
"level": "ERROR",
"formatter": "detailed",
"filename": "errors.log",
"maxBytes": 5_000_000,
"backupCount": 10,
},
},
"loggers": {
"myapp": {
"handlers": ["console", "file", "error_file"],
"level": "DEBUG",
"propagate": False,
},
"urllib3": { # silence chatty 3rd-party libs
"level": "WARNING",
},
},
"root": {
"handlers": ["console"],
"level": "WARNING",
},
}
logging.config.dictConfig(LOGGING)
log = logging.getLogger("myapp.orders")
log.info("Order #100 processed")
log.error("Payment gateway timeout for order #101")
One dictionary describes formatters, handlers, and loggers together. It can be loaded from a YAML file, differ per environment, and reviewed in code review as a single unit. Frameworks like Django and Flask both use this pattern natively.
Real-World Example — Flask Web App Logging
import logging
import logging.config
import time
from flask import Flask, request, g
# --- Logging setup ---
logging.config.dictConfig({
"version": 1,
"formatters": {"default": {
"format": "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
}},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "default",
},
"file": {
"class": "logging.handlers.TimedRotatingFileHandler",
"filename": "access.log",
"when": "midnight",
"backupCount": 14,
"formatter": "default",
},
},
"root": {"handlers": ["console", "file"], "level": "INFO"},
})
app = Flask(__name__)
log = logging.getLogger("shop.api")
@app.before_request
def _start_timer():
g.start = time.perf_counter()
@app.after_request
def _log_request(response):
elapsed_ms = (time.perf_counter() - g.start) * 1000
log.info(
"%s %s -> %d in %.1fms (ip=%s)",
request.method, request.path, response.status_code,
elapsed_ms, request.remote_addr,
)
return response
@app.route("/order/<int:order_id>")
def get_order(order_id):
log.debug("Fetching order %s from DB", order_id)
if order_id < 0:
log.warning("Rejecting negative order id: %s", order_id)
return {"error": "bad id"}, 400
return {"id": order_id, "total": 42.00}
@app.errorhandler(Exception)
def _unhandled(err):
log.exception("Unhandled error on %s", request.path)
return {"error": "internal"}, 500
if __name__ == "__main__":
app.run(port=8000)
Structured (JSON) Logging
Modern log aggregators (Datadog, Splunk, ELK, Loki) prefer JSON — one object per
line, keys as searchable fields. Ship it with the python-json-logger package.
# pip install python-json-logger
import logging
from pythonjsonlogger import jsonlogger
log = logging.getLogger("shop")
log.setLevel(logging.INFO)
handler = logging.StreamHandler()
handler.setFormatter(jsonlogger.JsonFormatter(
"%(asctime)s %(levelname)s %(name)s %(message)s"
))
log.addHandler(handler)
# Attach extra structured fields via `extra=`
log.info("Order placed", extra={
"order_id": "ORD-8821",
"user_id": 42,
"amount": 129.95,
"currency": "USD",
})
Text logs force regex parsing. JSON logs are native fields — you can query
user_id:42 or amount:>100 in your log tool instantly.
Kubernetes, Docker, and Lambda all emit stdout to log aggregators automatically,
so shipping JSON to stdout is the modern default.
Performance — The Two Golden Rules
| Bad Pattern |
|---|
log.debug("User " + str(user) + " age=" + str(age)) |
log.debug(f"Query result: {expensive_call()}") |
| String is built even when DEBUG is disabled. |
| Good Pattern |
|---|
log.debug("User %s age=%d", user, age) |
if log.isEnabledFor(logging.DEBUG): log.debug("Query: %s", expensive_call()) |
| Formatting happens only if the record will be emitted. |
f-strings are lovely everywhere except in logging calls. They force evaluation immediately, even when the log level would drop the record. Use %-style placeholders — the logger only interpolates if it will actually emit. This can matter enormously for hot loops with disabled DEBUG.
Common Pitfalls & How to Avoid Them
| Pitfall | Symptom | Fix |
|---|---|---|
Calling logging.info(...) directly |
Uses root logger; hard to filter per module | log = logging.getLogger(__name__) |
| Duplicate log lines | Every message printed twice or more | Handlers added multiple times, or propagate=True feeds parents. Add handlers once; set propagate=False. |
| Silent logs | Nothing appears anywhere | No handler on any ancestor. Root has none by default until you call basicConfig() or dictConfig(). |
basicConfig after another log call |
Config is silently ignored | basicConfig only works if no handlers exist. Pass force=True in Python 3.8+ to override. |
| Third-party library floods logs | urllib3, botocore, sqlalchemy noise | logging.getLogger("urllib3").setLevel(logging.WARNING) |
Logging inside __del__ or shutdown |
Errors about closed file handles | Avoid logging during interpreter shutdown; use atexit if needed. |
| Missing traceback | ERROR line but no stack | Use log.exception(...) inside except:. |
logging vs print vs loguru — Quick Comparison
| Feature | print() | logging (stdlib) | loguru (3rd party) |
|---|---|---|---|
| Severity levels | No | 5 built-in | Fully custom |
| Timestamps | Manual | Automatic | Automatic |
| Multiple destinations | No | Yes (handlers) | Yes (sinks) |
| Configuration | N/A | Verbose (dictConfig) | One-liner |
| Thread/process safe | No | Yes | Yes |
| Ships with Python | Yes | Yes | pip install |
| Framework support | None | Universal | Growing |
| Best when… | REPL play | Any real project | Small scripts + rapid dev |
For any library, framework, or team project — use the standard logging module. It's universally understood, integrates with every framework, and needs no extra dependency. Save loguru for personal scripts and CLI tools where you want beauty over convention.
Golden Rules
log = logging.getLogger(__name__)
at the top. Never call logging.info at module scope. This gives you
free hierarchy and per-module filtering forever.
dictConfig. Libraries must never call basicConfig —
that's the application's job.
log.debug("x=%s", x) defers formatting until the record is actually emitted.
f-strings evaluate eagerly and waste CPU on disabled DEBUG.
except: block, always call log.exception(...)
— never log.error(str(e)). Only exception() attaches the
full traceback.
logging.getLogger("urllib3").setLevel(logging.WARNING).
One line saves you from megabytes of noise per hour.