Logging Instead of print

print has no level, no source and no off switch; a module-level logger configured once at the entry point has all three.

Syntaxlogging.getLogger(__name__).error(msg, *args, exc_info=False, stack_info=False, stacklevel=1, extra=None)

print writes a string to stdout. It carries no level, no logger name, no timestamp, and the only way to turn it off is to edit the line. logging separates the decision to record something from the decision to show it: your module emits records, and the application decides which ones reach a stream, a file or a collector.

Levels

DEBUG (10) is for tracing while you work; INFO (20) for the events a healthy run produces, such as a job starting and finishing; WARNING (30) for something recoverable that someone should look at; ERROR (40) for work that did not get done; CRITICAL (50) for the process giving up. The root logger defaults to WARNING, which is why an unconfigured logger.info(...) appears to do nothing.

One logger per module, one configuration per program

Write logger = logging.getLogger(__name__) at module level. Because loggers form a dotted hierarchy, the name is what lets someone raise billing.gateway to DEBUG while the rest of the system stays at INFO. Configure in exactly one place — the entry point — with logging.basicConfig(level=..., format=..., stream=...) or logging.config.dictConfig(...). A library that configures logging overrides its users, so libraries only emit.

Pass arguments, do not format

logger.debug("charging %s to %s", cents, card) merges the arguments only if the record is actually emitted; an f-string is built every time, whatever the level. When you swallow an exception, record the traceback: logger.exception(msg) is shorthand for logger.error(msg, exc_info=True), and without one of them the stack is simply gone.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Turn on debug output for one module in production without restarting at DEBUG everywhere
  • Keep the traceback for an exception your code deliberately recovers from
  • Silence a chatty third-party library without touching its code
  • Ship structured fields - request id, user id, job name - that your log search can filter on

More examples

Configure at the entry point, emit everywhere else

The level comes from the environment, so raising verbosity in production is a restart rather than a deploy. Naming the logger per module is what makes logging.getLogger("myapp.jobs").setLevel(logging.DEBUG) possible later, without turning on every library's debug output at the same time.

Example · python
# myapp/__main__.py - the only file that configures logging
import logging
import os
import sys

from myapp.jobs import run_nightly


def main() -> int:
    logging.basicConfig(
        level=os.environ.get("LOG_LEVEL", "INFO").upper(),
        format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
        stream=sys.stderr,
        force=True,        # 3.8+: replace handlers a dependency installed
    )
    logging.getLogger("httpx").setLevel(logging.WARNING)   # quieten one library
    return run_nightly()


if __name__ == "__main__":
    raise SystemExit(main())


# myapp/jobs.py - a module never calls basicConfig
import logging

logger = logging.getLogger(__name__)    # "myapp.jobs"


def run_nightly() -> int:
    logger.info("nightly job starting")
    ...
    return 0

The traceback you would otherwise lose

Any except block that does not re-raise should log with exc_info, or the only evidence of the failure is its absence. Call logger.exception only from inside an except block - elsewhere there is no current exception and the record says NoneType: None.

Example · python
def cached_rate(symbol):
    try:
        return _read_cache(symbol)
    except (OSError, ValueError):
        # The fallback is deliberate, but the cause must still be recorded:
        # exception() logs at ERROR with the traceback attached.
        logger.exception("cache unreadable for %s, falling back to the API", symbol)
        return _fetch_rate(symbol)


# Equivalent, spelled out:
#   logger.error("cache unreadable for %s, ...", symbol, exc_info=True)
#
# And the version that produces an unsolvable support ticket:
#   except Exception:
#       pass

Fields a log aggregator can filter on

Put the identifiers in the record rather than inside the message text, so a search can group every line from one request. A format string that names an attribute no record carries raises a formatting error at emit time, so set a default with a Filter or a LoggerAdapter when only some call sites supply it.

Example · python
FORMAT = "%(asctime)s %(levelname)s %(name)s req=%(request_id)s %(message)s"
logging.basicConfig(format=FORMAT, level=logging.INFO)

logger = logging.getLogger("billing.api")

# extra= adds attributes to the LogRecord, which the format string can name.
logger.info(
    "charge accepted amount=%s currency=%s",
    1250,
    "EUR",
    extra={"request_id": "req_01HX"},
)

Discussion

  • Be the first to comment on this lesson.