Python Logging: Setup, JSON Output, and Request IDs
Last verified on Nov 8, 2026.
Python's logging module is configured in one place at startup and used everywhere through logging.getLogger(__name__). The part people get wrong is the configuration: the default level hides INFO, the default format is plain text, and nothing carries a request ID. This guide builds a production setup with dictConfig, JSON output on stdout, a request ID on every line, and trace IDs from OpenTelemetry.
The Basics You Should Not Skip
The standard library defines five levels, from DEBUG (detail for diagnosing problems) through INFO (things working as expected), WARNING, and ERROR up to CRITICAL (the program may be unable to continue). The default level is WARNING, so logger.info(...) prints nothing until you configure otherwise. See log levels for guidance on which level to pick.
Create one logger per module:
import logging
logger = logging.getLogger(__name__)Child loggers propagate records to the handlers of their ancestors, so you configure handlers once on the root logger and never per module. The Python docs also say library code should add nothing but a NullHandler: handler choice belongs to the application.
Configure Everything With dictConfig
logging.config.dictConfig takes a dictionary and builds formatters, filters, handlers, and loggers from it. Call it once, as early as possible. Set disable_existing_loggers to False: the default is True, which silently disables any non-root logger created before the call, such as one in an imported module.
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"filters": {"request_id": {"()": RequestIdFilter}},
"formatters": {"json": {"()": JsonFormatter}},
"handlers": {
"stdout": {
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
"formatter": "json",
"filters": ["request_id"],
}
},
"root": {"level": "INFO", "handlers": ["stdout"]},
"loggers": {"urllib3": {"level": "WARNING"}},
}The "()" key tells dictConfig to call your own class instead of the built-in one, and ext://sys.stdout resolves to the real stream object. The urllib3 entry shows the usual trick for taming one noisy dependency without lowering the global level.
Emit JSON
The standard library has no JSON formatter, but writing one takes a dozen lines. This one reads every custom attribute passed through extra, so you do not have to list fields twice:
import json
import logging
from datetime import datetime, timezone
_STANDARD = set(logging.LogRecord("", 0, "", 0, "", (), None).__dict__) | {
"message", "asctime", "taskName",
}
class JsonFormatter(logging.Formatter):
def format(self, record):
payload = {
"timestamp": datetime.fromtimestamp(record.created, timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
for key, value in record.__dict__.items():
if key not in _STANDARD and value is not None:
payload[key] = value
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
return json.dumps(payload, default=str)Pass fields with extra, and use logger.exception inside an except block so the traceback is attached (the docs say to call it only from an exception handler):
logger.info("order placed", extra={"order_id": 4812, "amount_cents": 2599}){
"timestamp": "2026-11-08T06:44:50.900033+00:00",
"level": "INFO",
"logger": "checkout",
"message": "order placed",
"order_id": 4812,
"amount_cents": 2599,
"request_id": "req-8f3a"
}Keys in extra must not collide with LogRecord attributes such as name or message, or Python raises a KeyError. One line per event keeps the traceback inside a single JSON string, which is what makes structured logging parseable. For the format itself, see the JSON logging guide.
Add a Request ID to Every Line
A logging.Filter can attach context to each record. Pair it with contextvars so the value follows the request through threads and async tasks without being passed around:
import contextvars
import logging
request_id_var = contextvars.ContextVar("request_id", default=None)
class RequestIdFilter(logging.Filter):
def filter(self, record):
record.request_id = request_id_var.get()
return TrueIn your web framework's middleware, set the variable from the incoming X-Request-ID header or generate one, then reset it when the request ends. Every log line from that request now carries the same correlation ID. This is the pattern the Python logging cookbook describes for adding contextual information.
Add Trace and Span IDs
If you use OpenTelemetry, the opentelemetry-instrumentation-logging package injects otelTraceID, otelSpanID, otelServiceName, and otelTraceSampled into every record. Because the JSON formatter above emits unknown attributes, the IDs appear with no extra code:
from opentelemetry.instrumentation.logging import LoggingInstrumentor
LoggingInstrumentor().instrument(inject_trace_context=True){
"timestamp": "2026-11-08T06:45:19.497839+00:00",
"level": "INFO",
"logger": "checkout",
"message": "inside span",
"otelSpanID": "de989f1bd64f39a6",
"otelTraceID": "8e38ac254b906c018650dd5837c3c06a",
"otelTraceSampled": true,
"otelServiceName": "unknown_service:python"
}Outside a span the IDs are "0". A trace ID lets you jump from a slow request to every line it wrote. The OpenTelemetry Python guide covers the rest of the setup.
Common Mistakes
The Python docs note that argument formatting is deferred until it cannot be avoided, but computing the arguments is still your cost, which is why isEnabledFor exists.
Production Tips
- Write to stdout and let the platform collect it. Files add rotation work you do not need in containers.
- Keep the root level at
INFOand raise noisy dependencies toWARNINGby name. - Handlers that can block, such as network or SMTP handlers, belong behind a
QueueHandler, per the cookbook, so a slow endpoint does not stall request threads. - Never log secrets, tokens, or full request bodies. Redact in a filter if you must log objects.
- Settle on field names across services using the logging best practices guide.
Sending Python Logs to Sazabi
Sazabi, an AI-native observability and security platform, accepts OTLP over HTTP. Its OpenTelemetry source documents a logs-only Python setup: install opentelemetry-sdk and opentelemetry-exporter-otlp-proto-http, attach an OpenTelemetry LoggingHandler to the root logger, and set OTEL_EXPORTER_OTLP_ENDPOINT to your intake URL. Your JSON stdout handler keeps working alongside it. Read the OpenTelemetry source docs for the exact snippet and worker-fork notes, and see the OpenTelemetry integration and its traces page. For spotting log lines you have never seen before once they arrive, see novel events.
Frequently Asked Questions
What is the best way to configure Python logging?
Call logging.config.dictConfig once at startup with one handler on the root logger, then use logging.getLogger(__name__) in every module. Application code should not add handlers.
Does Python logging output JSON by default?
No. The standard library writes plain text. Subclass logging.Formatter to return json.dumps of the record fields, or use a library such as structlog.
Why are my INFO messages not showing?
The default level is WARNING. Set the root logger level to INFO in your configuration, and check that no library called disable_existing_loggers on a logger you rely on.
How do I add a request ID to every Python log line?
Store the ID in a contextvars.ContextVar when the request starts and attach it to each record with a logging.Filter. Every logger on that handler then includes it.
Sources
- Standard levels, a default level of WARNING, getLogger(__name__), child loggers propagating to ancestor handlers, NullHandler for libraries, and isEnabledFor for expensive arguments (accessed Nov 8, 2026)
- The extra argument populates the LogRecord, extra keys must not clash with LogRecord attributes, and exception() is for exception handlers (accessed Nov 8, 2026)
- dictConfig schema, disable_existing_loggers defaults to True, the special () key for user-defined objects, and ext://sys.stdout references (accessed Nov 8, 2026)
- Filters and contextvars add contextual information to records, and QueueHandler keeps blocking handlers off performance-critical threads (accessed Nov 8, 2026)
- The logging instrumentation injects otelTraceID, otelSpanID, otelServiceName, and otelTraceSampled into log records (accessed Nov 8, 2026)
- Sazabi accepts OTLP over HTTP; a LoggingHandler on the root logger exports Python logging records (accessed Nov 8, 2026)