Stop Using print() for Debugging: A Practical Deep Dive into Python's logging Module
Learn how Python's logging module really works — loggers, handlers, formatters, and levels — plus dictConfig, rotating files, structured JSON logs, and the pitfalls that trip up almost everyone.
Reach for print() often enough and you eventually hit its limits. You cannot easily switch it off in production, it does not tell you where a message came from or how urgent it is, and it writes to one place only. Python's logging module solves all of this — but it has a reputation for being confusing, mostly because people copy a basicConfig snippet without understanding the machinery underneath. This guide takes you through that machinery from the ground up so you can configure logging deliberately instead of by trial and error.
Why logging beats print
Logging gives you three things print() never will. First, levels: every message carries a severity, and you can raise or lower the threshold globally without touching your code. Second, routing: the same message can go to the console, a file, and a network service at once. Third, context: timestamps, module names, line numbers, and thread IDs are added for free. The cost is a five-minute investment in understanding four objects: loggers, handlers, formatters, and levels.
The quickest possible start
For scripts, basicConfig wires up a sensible default in one call. Call it once, early, before you emit any log records.
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
log = logging.getLogger(__name__)
log.debug("You won't see this — DEBUG is below the INFO threshold")
log.info("Server started on port %s", 8000)
log.warning("Disk usage at 85%%")
Two details matter here. Notice the message uses %s placeholders with the arguments passed separately rather than an f-string. That is not an accident — we will come back to why it matters. And notice getLogger(__name__): naming your logger after the module is the single most useful convention in the whole system.
The five levels
Logging defines five standard severities, each with a numeric value: DEBUG (10), INFO (20), WARNING (30), ERROR (40), and CRITICAL (50). A logger processes a record only if its level is at or above the configured threshold. The default threshold on the root logger is WARNING, which is exactly why a bare logging.info("hi") in a fresh interpreter appears to do nothing — the message is real, it is simply below the bar.
import logging
log = logging.getLogger("billing")
log.setLevel(logging.DEBUG)
log.debug("Fetched %d invoices", 42) # 10
log.info("Charge succeeded") # 20
log.warning("Retrying payment") # 30
log.error("Gateway timeout") # 40
log.critical("Ledger corrupted") # 50
Choose levels by audience, not by how dramatic the event feels. DEBUG is for developers diagnosing a problem, INFO records normal milestones an operator cares about, WARNING flags something recoverable but noteworthy, ERROR means an operation failed, and CRITICAL means the program may not be able to continue.
The mental model: loggers, handlers, formatters
This is the part worth slowing down for. A logger is what you call methods on. When you emit a record, the logger checks its level and, if the record passes, hands it to every attached handler. Each handler decides where the record goes (console, file, socket) and can apply its own level filter. Before writing, the handler asks its formatter to turn the record into a string. So the flow is: logger → handler(s) → formatter → destination.
Here is the whole pipeline assembled by hand, which is what basicConfig does for you behind the scenes:
import logging
import sys
logger = logging.getLogger("app")
logger.setLevel(logging.DEBUG)
# A handler decides the destination and can have its own level.
console = logging.StreamHandler(sys.stdout)
console.setLevel(logging.WARNING)
# A formatter decides the layout.
console.setFormatter(logging.Formatter("%(levelname)s | %(message)s"))
logger.addHandler(console)
logger.debug("Passes the logger, blocked by the handler's WARNING level")
logger.error("Shown: ERROR | ...")
Records must clear two gates — the logger's level and then each handler's level — which is what lets you, say, send everything to a file but only warnings and above to the console.
The logger hierarchy and propagation
Logger names are dotted paths that form a tree. getLogger("app.db.pool") is a child of app.db, which is a child of app, which is a child of the invisible root logger. By default a record travels up the tree: it is handled by the logger that created it and then propagated to each ancestor's handlers. This is why configuring the root logger once is often all you need — every module logger flows into it.
import logging
logging.basicConfig(level=logging.INFO, format="%(name)s: %(message)s")
logging.getLogger("app").info("top level")
logging.getLogger("app.db").info("database layer")
logging.getLogger("app.db.pool").info("connection pool")
All three lines appear because each record propagates up to the root handler installed by basicConfig. If you attach a dedicated handler to a specific logger and want to stop records from also hitting the root handlers, set logger.propagate = False — otherwise you will see every message printed twice.
Logging exceptions the right way
Inside an except block, use logger.exception(). It logs at ERROR level and automatically attaches the full traceback, which is exactly the information you need to debug a failure after the fact.
import logging
log = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
def divide(a, b):
try:
return a / b
except ZeroDivisionError:
log.exception("Failed to divide %s by %s", a, b)
return None
divide(10, 0)
The output includes the message followed by the complete stack trace. Outside an exception handler, you can get the same effect on any call with log.error("...", exc_info=True).
Configuring with dictConfig
Once you outgrow basicConfig, the cleanest way to set everything up is logging.config.dictConfig. It takes a single dictionary describing formatters, handlers, and loggers, which you can store in a config file and load at startup. This keeps configuration out of your business logic entirely.
import logging.config
CONFIG = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"detailed": {
"format": "%(asctime)s %(name)s %(levelname)s %(message)s",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "detailed",
"level": "INFO",
},
},
"root": {
"handlers": ["console"],
"level": "INFO",
},
}
logging.config.dictConfig(CONFIG)
logging.getLogger("service").info("Configured entirely from a dict")
Always set "disable_existing_loggers": False unless you have a specific reason not to. The default of True silently switches off any logger created before dictConfig ran — a classic source of "my library stopped logging" bug reports.
Writing to files with rotation
Logging to a file that grows without bound will eventually fill a disk. RotatingFileHandler caps the size and keeps a fixed number of backups; TimedRotatingFileHandler rolls over on a schedule instead.
import logging
from logging.handlers import RotatingFileHandler
handler = RotatingFileHandler(
"app.log",
maxBytes=1_000_000, # roll over at ~1 MB
backupCount=5, # keep app.log.1 ... app.log.5
)
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
logger = logging.getLogger("worker")
logger.setLevel(logging.INFO)
logger.addHandler(handler)
for i in range(10_000):
logger.info("Processing job %d", i)
When app.log reaches the size limit it is renamed to app.log.1, the previous .1 becomes .2, and so on, with the oldest backup discarded. You get bounded disk usage with zero external tooling.
Structured logging as JSON
Log aggregators like Elasticsearch, Loki, or CloudWatch are far happier parsing JSON than free-form text. You do not need a third-party library for a basic version — a custom formatter is enough.
import json
import logging
class JsonFormatter(logging.Formatter):
def format(self, record):
payload = {
"time": self.formatTime(record),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
return json.dumps(payload)
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
log = logging.getLogger("api")
log.setLevel(logging.INFO)
log.addHandler(handler)
log.propagate = False
log.info("request completed")
Each line is now a self-contained JSON object your platform can index and query. For production use, the popular python-json-logger package generalizes this pattern, but the mechanism is exactly what you see above.
Adding context with extra and LoggerAdapter
Often you want to attach fields like a request ID or user ID to every message in a code path. Pass an extra dict for a one-off, or wrap the logger in a LoggerAdapter to inject the same fields automatically.
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s [req=%(request_id)s] %(message)s",
)
base = logging.getLogger("http")
# Inject request_id into every call through the adapter.
log = logging.LoggerAdapter(base, {"request_id": "a1b2c3"})
log.info("handling GET /users")
log.warning("slow query detected")
Because the format string references %(request_id)s, every record needs that field — the adapter guarantees it is always present so the formatter never raises a KeyError.
Common pitfalls
Using f-strings in log calls. Write log.info("user %s logged in", name), not log.info(f"user {name} logged in"). With the lazy form, the string is only interpolated if the record actually gets emitted, so a filtered-out DEBUG line costs almost nothing. It also lets aggregators group messages by their template.
Calling basicConfig more than once. After the first call it becomes a no-op (unless you pass force=True), so a second call with a different format silently does nothing. Configure logging in exactly one place.
Duplicated messages. If a line prints twice, you almost certainly added a handler to a named logger without setting propagate = False, so both your handler and the root's handler fire.
Logging inside libraries. If you publish a library, do not call basicConfig or add handlers — that is the application's job. Instead, attach a logging.NullHandler() to your top-level logger so your library stays silent until the user opts in.
import logging
# In a library's __init__.py
logging.getLogger("mylib").addHandler(logging.NullHandler())
Wrap-up and next steps
The whole module clicks into place once you internalize the pipeline: a logger you name after your module checks a level, hands passing records to handlers that route them to destinations, and each handler uses a formatter to render the text — with records propagating up the dotted-name tree to the root. Start with basicConfig for scripts, graduate to dictConfig for real applications, add rotation for files, and switch to a JSON formatter when you ship to a log platform.
From here, explore logging.handlers for SMTPHandler and SocketHandler, look at QueueHandler and QueueListener for non-blocking logging in high-throughput or async code, and read the official Logging Cookbook in the Python docs for battle-tested recipes. Replace one print() with a properly named logger today and you will not want to go back.