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.
logging.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
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.
# 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 0The 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.
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:
# passFields 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.
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