print is for prototypes. logging is for code that lives. Logging gives you levels (DEBUG/INFO/WARNING/ERROR/CRITICAL), handlers (where to write), formatters (how it looks), and the ability to turn it on/off without touching the code. Once you've used it for a week, going back to print feels primitive.
The five levels
DEBUG — verbose tracing for development. INFO — events that mark normal operation. WARNING — something unusual but not failing. ERROR — a failure that didn't crash the program. CRITICAL — about to die. The level you set on a logger filters everything below it: setting INFO drops DEBUG.
logger.info(), not logging.info(), in libraries
The standard library pattern: at the top of each module, logger = logging.getLogger(__name__). Use logger.info(...), never logging.info(...). The named logger lets the application configure verbosity per-module — useful when you want noisy debug from one module and quiet from another. __name__ gives you the import path automatically.
basicConfig — for scripts
For a script that just needs logs to stderr or a file, logging.basicConfig(level=INFO, format='...') is the one-liner. For larger applications, configure handlers and formatters explicitly — that's the next code block.
Principle: Libraries call logger.info("..."). Applications configure logging once at the top — what level, what format, where to write. Don't call basicConfig from a library; let the application decide.
# my_library.py
import logging
logger = logging.getLogger(__name__) # named after the module
def do_work(item):
logger.debug("starting work on %r", item)
try:
result = item.upper()
except AttributeError:
logger.error("item %r is not a string", item)
raise
logger.info("processed %r", item)
return result
# In the application:
# import logging
# logging.basicConfig(level=logging.INFO)
# logging.getLogger('my_library').setLevel(logging.DEBUG) # noisy this module
Logging exceptions — exc_info and exception()·python
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def divide(a, b):
try:
return a / b
except Exception:
# logger.exception logs at ERROR level WITH a traceback — equivalent to
# logger.error("...", exc_info=True)
logger.exception("divide(%s, %s) failed", a, b)
return None
result = divide(10, 0)
# ERROR ... divide(10, 0) failed
# Traceback (most recent call last):
# ...
# ZeroDivisionError: division by zero
Format strings — lazy formatting·python
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
user = "alice"
count = 42
# DON'T — formats even at level DEBUG (wasted work if filtered out)
logger.debug(f"user {user} has count {count}")
# DO — % formatting, only formats if the level is enabled
logger.debug("user %s has count %d", user, count)
# Same idea for info/warning/error
logger.info("connected to %s on port %d", "localhost", 8000)
Multiple handlers — file + stderr·python
import logging
logger = logging.getLogger("my_app")
logger.setLevel(logging.DEBUG)
# Handler 1 — stderr at INFO
stream = logging.StreamHandler()
stream.setLevel(logging.INFO)
stream.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
logger.addHandler(stream)
# Handler 2 — file at DEBUG (everything)
file_h = logging.FileHandler("/tmp/app.log", mode="a")
file_h.setLevel(logging.DEBUG)
file_h.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
logger.addHandler(file_h)
logger.debug("detail — only in the file")
logger.info("important — in both stderr and file")
Build a small module quester.py that defines def play(level: str) which logs at all 5 levels. Use logging.getLogger(__name__). In a separate script, configure logging with basicConfig at INFO level, then explicitly set the quester logger's level to DEBUG so its DEBUG messages also appear. Confirm both observations: (a) other modules' DEBUG is silenced, (b) quester's DEBUG is shown.
Progress
Progress is local-only — sign in to sync across devices.