How does Python's `logging` module work, and how should you configure it in an application?

7 minintermediateloggingtoolingproduction

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.db is a child of myapp.services, which is a child of myapp), 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 WARNING silently drops DEBUG/INFO calls 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.