At 8:05 the till prints six lines and nobody knows which one mattered. print says something happened; logging says when, where, and how bad. What does a level actually filter?
print is fine for a quick check, but logging adds severity, destinations, and a consistent format. A logger sends records to handlers, and each handler can format or filter them. Small, explicit configurations are easier to test than global logging setup. You will also add request context, filter sensitive records, and capture tracebacks safely.
Send a record to a handler
A logger emits records at or above its level. A handler decides where they go. Here StringIO captures the output so the example stays self-contained and writes to stdout only when we print it.
import logging
from io import StringIO
stream = StringIO()
handler = logging.StreamHandler(stream)
logger = logging.getLogger("demo")
logger.handlers[:] = [handler]
logger.setLevel(logging.INFO)
logger.propagate = False
logger.info("started")
print(stream.getvalue().strip())A logger with a destination
Filter with levels
The level is a threshold. At WARNING, info records are ignored while warning and error records pass. This lets you keep diagnostic detail in development and show only important messages in production.
import logging
from io import StringIO
stream = StringIO()
handler = logging.StreamHandler(stream)
logger = logging.getLogger("demo")
logger.handlers[:] = [handler]
logger.setLevel(logging.WARNING)
logger.propagate = False
logger.info("hidden")
logger.warning("shown")
print(stream.getvalue().strip())Only warnings and above
Format the message
A formatter turns a record into text. Fields such as levelname, name, and message are available in the format string. Attach the formatter to the handler that owns the destination.
import logging
from io import StringIO
stream = StringIO()
handler = logging.StreamHandler(stream)
handler.setFormatter(logging.Formatter("%(levelname)s:%(message)s"))
logger = logging.getLogger("demo")
logger.handlers[:] = [handler]
logger.setLevel(logging.INFO)
logger.propagate = False
logger.warning("check")
print(stream.getvalue().strip())A predictable line format
Attach request context with LoggerAdapter
A LoggerAdapter adds the same context to every record, such as a request ID. Include that field in the formatter so the value is actually visible. The adapter is a small wrapper; it does not create a second logger hierarchy.
import io
import logging
stream = io.StringIO()
logger = logging.getLogger("gift.request")
logger.handlers[:] = [logging.StreamHandler(stream)]
handler = logger.handlers[0]
handler.setFormatter(logging.Formatter("%(request_id)s %(message)s"))
logger.setLevel(logging.INFO)
logger.propagate = False
adapter = logging.LoggerAdapter(logger, {"request_id": "r-17"})
adapter.info("loaded %s", "menu")
print(stream.getvalue().strip())One record, one request ID
Filter records before they leave
A Filter can reject a record before any handler formats it. Use this for policy checks, not as a substitute for never putting secrets into a log call. Here the filter drops messages that mention a password; the safe record still reaches the handler.
import io
import logging
class NoSecrets(logging.Filter):
def filter(self, record):
return "password" not in record.getMessage().lower()
stream = io.StringIO()
logger = logging.getLogger("gift.safe")
logger.handlers[:] = [logging.StreamHandler(stream)]
logger.handlers[0].addFilter(NoSecrets())
logger.setLevel(logging.INFO)
logger.propagate = False
logger.info("user %s", "maya")
logger.info("password=%s", "hunter2")
print(repr(stream.getvalue()))Drop the sensitive record early
Capture an exception without stderr
logger.exception() records the message and the current traceback. Point the handler at a StringIO while testing so the traceback does not leak to stderr. In production, keep tracebacks out of user-facing responses and scrub sensitive values before logging.
import io
import logging
stream = io.StringIO()
logger = logging.getLogger("gift.checkout")
logger.handlers[:] = [logging.StreamHandler(stream)]
logger.setLevel(logging.INFO)
logger.propagate = False
try:
1 / 0
except ZeroDivisionError:
logger.exception("checkout failed")
text = stream.getvalue()
print(text.splitlines()[0])
print("ZeroDivisionError" in text)A traceback in a controlled stream
Key points
- A logger emits records; handlers decide where they go.
- The logger level is a threshold for records.
- Handlers can have their own levels and formatters.
- propagate=False keeps a demo logger from also using the root logger.
- StringIO makes logging examples deterministic and easy to test.
- Adapters add context; filters enforce policy; exception() captures a traceback.
Common mistakes
- Calling logging.info before configuring any handler and wondering where it went.
- Configuring the root logger in a library module.
- Forgetting that a lower-level handler can still filter a record after the logger passes it.
- Assuming logging is silent by default: handlers and propagation decide where records go.
Create a logger at INFO, attach a StringIO handler, log a message, and print the captured text.
Try it yourself
Change the values, predict the output, then run the code. The Python runtime is downloaded the first time you press Run, so the first run can take a few seconds.
Practice this lesson
12 questions with immediate explanations.