Logging Done Right: A Practical Deep Dive into Python's logging Module
print() is fine until it isn't. Here is how Python's logging module gives you levels, structure, and control that survive production.
Almost every Python program starts its debugging life the same way: a scattering of print() calls. It works right up until the moment it doesn't — when you need to silence the noise in production, route errors to a file while keeping info on the console, or attach a timestamp and a module name to every line. That is exactly the gap the standard library's logging module was built to fill, and learning it properly is one of the highest-leverage upgrades you can make to everyday Python code.
Why not just print?
A print() statement has exactly one destination and one volume setting: on. You cannot turn it down for a noisy module, you cannot promote it to an alert, and you cannot easily redirect it without editing code. Logging replaces that single dumb channel with a small pipeline you configure once and control from the outside. The same line of code can be invisible on your laptop, land in a rotating file on a server, and trigger a pager in a monitoring system — without you touching the call site.
The four moving parts
The module has a reputation for being confusing, but it really only has four concepts. A Logger is the object you call (logger.info(...)). A Handler decides where a record goes — the console, a file, a network socket. A Formatter decides what each line looks like. And Levels — DEBUG, INFO, WARNING, ERROR, CRITICAL — decide what gets through at all. Records flow from logger to handler, get filtered by level along the way, and are rendered by the formatter at the end.
import logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)-8s %(name)s: %(message)s"
))
logger.addHandler(handler)
logger.debug("Cache miss for key=%s", key)
logger.info("Request completed in %d ms", elapsed)
logger.warning("Retry %d of %d", attempt, max_attempts)
Use getLogger(__name__), always
Calling logging.getLogger(__name__) at the top of every module gives each file its own named logger that mirrors your package structure. That naming is what makes logging powerful later: you can raise the level for one chatty subpackage while leaving everything else at INFO, all from a single configuration block. Never log through the root logger directly in library code, and never call basicConfig() from inside a library — leave the final configuration decision to the application that imports you.
Let the formatter do the interpolation
Notice the logger.info("... %d ms", elapsed) style above. Passing the arguments separately rather than pre-formatting with an f-string means the string is only built if the record actually clears its level filter. For a DEBUG line in a hot loop that is disabled in production, that lazy evaluation is real saved work. It is one of the few places where the older %-style formatting genuinely earns its keep.
Toward structured logs
Once your logs leave your screen and start feeding a search system, plain sentences become a liability. Structured logging — emitting each event as key-value data, often JSON — lets you filter by user_id or request_id instead of grepping prose. You can get partway there with the extra={...} argument and a custom formatter, or reach for a library like structlog when you want it end to end. The mental shift is the important part: a log line is a data record, not a diary entry.
If you would like the same material walked through in German — with the same progression from print() to a properly configured, structured setup — our sister site MeineCodereise has an excellent companion piece: Logging in Python: Vom print() zum strukturierten Log. It is a good second pass on the concepts, and reading the same ideas explained twice in different words is a surprisingly effective way to make them stick.
A sensible default
For a small script, one call to logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") at startup gets you 90% of the benefit for one line of code. Reserve the full logger/handler/formatter assembly for when you actually need multiple destinations. Start simple, name your loggers, and you will never miss print() again.