Python's logging module is older than most developers using it, but the 2026 stack is genuinely modern. The standard library still ships the same four components it had in 2003 (Logger, Handler, Formatter, Filter), but the canonical way to wire them together is dictConfig, the canonical output format is JSON (so log aggregators like Datadog, Loki, and Elasticsearch can index your logs), and async microservices increasingly reach for structlog because its processor pipeline and contextvars integration handle request-scoped context cleanly. This entry walks through the architecture (the 4 components, the hierarchy, the propagation rule), the canonical dictConfig setup, when to layer in JSON formatting, when structlog earns the dependency, and the production patterns (lazy formatting, level checks, library NullHandler) that distinguish maintenance-grade logging from print statements. Part of our Stdlib Essentials guide.
Configure with dictConfig, not basicConfig, for production applications.
JSON output for log aggregation; use python-json-logger with stdlib or structlog for richer context.
Libraries: attach only a NullHandler, never configure logging directly.
Use logger.exception() inside except blocks to attach the traceback automatically.
One log call, two destinations: the LogRecord propagates up the logger hierarchy and fans out to all attached handlers, each transforming the record through its formatter.
The Four Components
Every logging configuration is made of the same four parts:
Logger: receives the log call (logger.info(...)), checks the level threshold, decides whether to pass the record on.
Handler: takes accepted records and routes them somewhere (a file, stderr, syslog, a network socket).
Formatter: shapes the record into output bytes (plain text, JSON, custom format).
Filter: optional finer-grained acceptance check (run on either logger or handler).
The flow: logger.info() creates a LogRecord. The Logger checks its level. If accepted, the record propagates up the hierarchy. At each level, attached handlers receive the record. Each handler runs the record through its formatter and writes the result.
The Hierarchy and Propagation
Loggers are named in dotted form (myapp.auth.login) and arranged as a tree by name. Each logger has an implicit parent (one dot level up); the root has no parent.
If accepted, handlers attached to myapp.auth.login receive it.
If propagate=True (default), the record goes to myapp.auth's handlers.
Then to myapp's handlers.
Then to the root logger's handlers.
This is why attaching handlers ONLY to the root is the standard pattern: every logger in your application contributes through propagation, no per-logger handler attachment needed.
dictConfig: The Canonical Configuration
For applications, use logging.config.dictConfig with a dict that describes the entire setup. This is declarative, testable, and supports custom formatters and handlers.
Call dictConfig once at application startup (in main or your settings module). The dict can be loaded from JSON, YAML, or TOML if you prefer separating config from code.
JSON Output for Aggregators
Log aggregators (Datadog, Loki, Elasticsearch, Splunk) work best with structured JSON because parsing free-form text is fragile. The two paths to JSON:
Path 1: python-json-logger with stdlib
The lightest dependency. Install and use the JsonFormatter in any handler:
Path 2: structlog (recommended for async services)
structlog is the modern alternative for async microservices. Every log call is structured by design, the processor pipeline is composable and testable, and contextvars integration makes request-scoped context seamless.
The contextvars integration shines in FastAPI and similar async frameworks: bind a request ID at the start of a request, and every subsequent log call inside that request includes it automatically.
Libraries: NullHandler Only
If you're writing a library that other projects will install, do NOT call basicConfig or dictConfig. The application embedding your library is responsible for configuring logging.
NullHandler silences the "No handlers could be found" warning that Python emits when a logger produces records but no handlers are configured. The application's configured handlers still receive the records via propagation. This is documented in the official logging cookbook as the canonical library pattern.
Log Levels in Production
Standard levels, in order: DEBUG < INFO < WARNING < ERROR < CRITICAL.
Production guidance:
Root logger: WARNING or INFO depending on volume tolerance.
Your application loggers: INFO for normal operation; bump to DEBUG in development.
Noisy third-party libraries (sqlalchemy.engine, boto3, urllib3): force WARNING or ERROR to drown out their info-level chatter.
Never log secrets at any level. Filter them out at the formatter level if needed.
The dictConfig example above shows the pattern: myapp at DEBUG (your code), root at WARNING (everything else), sqlalchemy.engine at WARNING (drown out the SQL trace).
Exception Logging
logger.exception(msg) is the canonical pattern inside an except block. It logs at ERROR level and attaches the full traceback automatically.
try:
do_thing()
except SomeError:
logger.exception("do_thing failed")
raise # let the caller decide (covered in the raise guide)
The traceback ends up in the record's exc_info field and is rendered by the formatter (multi-line string for plain formatters, structured field for JSON formatters). Covered alongside the raise patterns in the exception handling guide.
Performance: Lazy Formatting and Level Checks
Two patterns prevent logging from becoming a bottleneck.
Pass arguments to the logger, not a pre-formatted string
# Bad: formats every time, even if log level filters the message out
logger.debug(f"user {user_id} did {action} on {resource}")
# Good: formatting deferred; happens only if DEBUG is enabled
logger.debug("user %s did %s on %s", user_id, action, resource)
The lazy form lets the logger decide whether to format. If DEBUG is filtered out, the format string is never evaluated.
Check level before expensive computation
if logger.isEnabledFor(logging.DEBUG):
logger.debug("complex state: %s", expensive_serialization(state))
For log calls whose arguments require significant computation, gate them behind isEnabledFor to skip the work entirely when the level filters the message out.
Common Mistakes
1. Using basicConfig in libraries or applications
basicConfig is a one-call shortcut for scripts and tutorials. Production code uses dictConfig; libraries use NullHandler. Mixing the two pollutes the application's logging setup.
2. Pre-formatting log messages with f-strings
logger.debug(f"...") formats the string even when DEBUG is filtered out. Pass arguments separately and let the logger lazy-format.
3. Print statements left in production
print bypasses the logging configuration entirely; the output isn't filtered by level, isn't structured for aggregators, and isn't testable. Always go through logger.
4. Adding handlers in multiple places
Calling addHandler in module imports duplicates handlers and produces duplicated log lines. Configure handlers once in dictConfig; never call addHandler at module scope in application code.
5. Logging secrets or PII
Logs go to aggregators, files, and sometimes third parties. Filter sensitive fields (passwords, tokens, full credit card numbers, raw PII) before they reach the formatter. structlog processors are a good place for this; with stdlib, use a custom Formatter or Filter.
Frequently Asked Questions
What is the modern way to configure Python logging in 2026?
Use logging.config.dictConfig with a dict (often loaded from JSON or YAML) that describes loggers, handlers, formatters, and filters. dictConfig is declarative, testable, and supports custom JSON formatters out of the box. logging.basicConfig is fine for quick scripts but doesn't scale to applications with multiple handlers and structured output. The canonical 2026 pattern: a single config dict in your settings module, dictConfig called once at startup, all loggers obtained via logging.getLogger(__name__) throughout the codebase. Libraries should never call basicConfig or dictConfig themselves; they attach only a NullHandler and let applications configure logging.
Should I use structlog or the standard library logging in Python?
Use structlog when you need composable processors, first-class structured fields, and request-scoped context (especially in async microservices). Its processor pipeline is testable, and contextvars integration makes request-scoped logging seamless in FastAPI and similar async frameworks. Use the standard library logging when stdlib-only is preferred, when the codebase is small, or when you only need basic structured output (which python-json-logger can handle on top of stdlib). For large async services, structlog is the 2026 industry favorite; for libraries and simple scripts, stdlib with a JSON formatter is enough.
What is the difference between logger.info and logger.exception in Python?
logger.info logs a message at INFO level with no exception context. logger.exception logs at ERROR level AND attaches the current exception's traceback to the log record. Use logger.exception inside an except block when you want the full traceback in your logs. Pattern: try: do_thing() except SomeError: logger.exception('do_thing failed'); raise. The "exception" method is shorthand for logger.error('msg', exc_info=True). For other levels, pass exc_info=True explicitly. The traceback ends up in the log record's exc_info field and is rendered by the formatter (as a multi-line string for plain formatters, or as a structured field for JSON formatters).
Why should libraries use NullHandler in Python?
Libraries should never configure logging directly because the application embedding the library is responsible for that. If a library calls basicConfig or attaches handlers, it pollutes the application's logging setup and surprises users. The standard pattern is: import logging; logger = logging.getLogger(__name__); logger.addHandler(logging.NullHandler()). NullHandler silences the warning that Python emits when logs are produced but no handlers exist; the application's configured handlers still receive the records via propagation. This is documented in PEP 282 and the official logging cookbook as the canonical library pattern.
How do I get JSON logs in Python?
Two options. With stdlib only, install the python-json-logger package and use its JsonFormatter in your handler config. Example dictConfig formatter entry: 'json': {'()': 'pythonjsonlogger.json.JsonFormatter', 'format': '%(asctime)s %(name)s %(levelname)s %(message)s'}. With structlog, configure a JSON renderer at the end of the processor chain: structlog.configure(processors=[..., structlog.processors.JSONRenderer()]). structlog produces cleaner JSON because every log call is structured by design; stdlib + JsonFormatter retrofits stdlib's positional formatting into JSON, which works but is less elegant for complex records. For 2026 production services emitting to log aggregators, both produce valid JSON; pick the one that matches your stack.
The Bottom Line: dictConfig + JSON in production, NullHandler in libraries, structlog for async services
Python logging in 2026 is a 4-component pipeline (Logger, Handler, Formatter, Filter) wired up via dictConfig for applications and ignored in libraries (which attach only a NullHandler). JSON output via python-json-logger or structlog feeds log aggregators properly. logger.exception attaches tracebacks inside except blocks. Lazy formatting with positional arguments keeps performance acceptable even at DEBUG level. Reach for structlog when async context flow matters; stick with stdlib when the application is simpler. This entry closes the Stdlib Essentials series; browse the full Stdlib Essentials guide for the rest.
Build Logging Reflexes Through Practice
CodeGym's Python track turns logging patterns into reflex across 62 levels: dictConfig setup, JSON formatters, structlog with FastAPI, NullHandler in libraries, exception logging. AI validators catch f-string log messages and missing NullHandler; AI mentors explain why a particular log record went where it did. First level free; full plan on the pricing page.
GO TO FULL VERSION