How does Python's `logging` module work, and how should you configure it in an application?
Quick Answer
`logging` organizes output through **loggers** (named, hierarchical, e.g. `myapp.db`), **handlers** (where log records go — console, file, network), **formatters** (how a record is rendered as text), and **levels** (DEBUG/INFO/WARNING/ERROR/CRITICAL, filtering what actually gets emitted). Best practice: use `logging.getLogger(__name__)` per module (never the root logger directly, never `print()`), and configure handlers/levels **once**, centrally, at the application's entry point.
Detailed Answer
The core building blocks
import logging
logger = logging.getLogger(__name__) # named per-module, e.g. "myapp.services.db"
logger.debug("detailed diagnostic info")
logger.info("normal operational event")
logger.warning("something unexpected, but handled")
logger.error("a real failure")
logger.critical("the application may be unable to continue")
- Logger: the named entry point application code calls — hierarchical
by dotted name (
myapp.services.dbis a child ofmyapp.services, which is a child ofmyapp), so configuration can be applied at any level and inherited by everything below it. - Level: filters what actually gets processed — a logger/handler set
to
WARNINGsilently dropsDEBUG/INFOcalls entirely (cheaply, with minimal overhead, since the level check happens before the message is even formatted). - Handler: decides where a passed-level record goes (console, rotating file, syslog, a remote log aggregator) — one logger can feed multiple handlers simultaneously.
- Formatter: decides how a record renders as text (timestamp, level, message, module name).
Why getLogger(__name__) per module, not the root logger
# app/db.py
import logging
logger = logging.getLogger(__name__) # 'app.db'
# app/api.py
import logging
logger = logging.getLogger(__name__) # 'app.api'
Using __name__ automatically names each logger after its module,
giving free hierarchical structure — you can later configure app.db to
log at DEBUG while everything else stays at INFO, without touching
any application code, just the central configuration.
Centralized configuration at the entry point
import logging
def configure_logging():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
handlers=[logging.StreamHandler(), logging.FileHandler("app.log")],
)
# main.py
configure_logging() # called ONCE, at startup
Configuration (levels, handlers, formatters) should happen exactly once,
centrally, typically in the application's entry point — individual
modules should only ever call getLogger(__name__) and log messages,
never configure handlers themselves (which would risk duplicate handlers
or conflicting configuration if the module is imported multiple times or
from different contexts).
Why lazy %-style formatting matters here specifically
logger.debug("processing item %s with data %s", item_id, huge_data_structure)
# NOT: logger.debug(f"processing item {item_id} with data {huge_data_structure}")
Passing %s-style placeholders and arguments separately means the string
is only actually formatted if the log record passes the level filter and
reaches a handler — an f-string, by contrast, is always fully evaluated
immediately, even if the log call is filtered out entirely (e.g., a
DEBUG call in a production system configured for INFO), wasting the
cost of formatting (potentially an expensive huge_data_structure repr)
every single time regardless of whether it's ever emitted.
Never use print() for anything beyond a quick throwaway script
print() has no levels, no filtering, no structured output, no easy way
to redirect/rotate/aggregate, and can't be selectively silenced per
module — logging (or a structured logging library like structlog for
more advanced needs) is the correct default for anything beyond a
one-off script.
Interview-ready summary: logging structures output through named,
hierarchical loggers, levels that filter cheaply, handlers that route
output, and formatters that render it — configure handlers/levels once
centrally at startup, call getLogger(__name__) per module everywhere
else, and prefer lazy %-style formatting over f-strings in log calls so
filtered-out messages cost nothing to (not) format.