Logging Configuration and dictConfig in Python

Hand-wiring loggers, handlers, and formatters with imperative calls works for a script but collapses into unmaintainable setup code once a service has multiple sinks, per-module levels, and environment-specific routing. logging.config.dictConfig replaces that boilerplate with a single declarative dictionary that describes the entire logging tree, applied once at startup. This guide is part of the Python Logging Fundamentals and Structured Data guide and builds directly on the routing concepts in handler architecture, the serialization rules in formatter configuration, and the severity model in log levels and severity mapping.

dictConfig schema to live logging tree A configuration dictionary on the left lists formatters, filters, and handlers. Arrows show handlers referencing formatters and filters, then loggers and root referencing handlers, producing the live logging tree on the right. config dict formatters filters handlers loggers root dictConfig live logging tree root logger app.api app.db console + file handlers with formatters attached
How the dictConfig schema keys map onto the live logger, handler, and formatter objects at startup.

Key principles this guide enforces:

  • Configure logging exactly once, as early as possible, before any module logs its first record.
  • Reference formatters, filters, and handlers by name so each is defined in one place.
  • Set propagate and disable_existing_loggers explicitly rather than relying on defaults that surprise you.
  • Keep secrets and environment-specific values out of static config files by resolving them in code before the call.

Prerequisites

dictConfig ships with the Python standard library, so the core feature needs nothing beyond CPython 3.8 or newer. Loading configuration from a YAML file is the only piece that requires a third-party dependency.

# YAML loading is optional; JSON uses the stdlib json module.
pip install "pyyaml>=6.0,<7.0"

No environment variables are required, though the production example below reads LOG_LEVEL and LOG_FORMAT to demonstrate env-driven configuration.

Concept and architecture

logging.config.dictConfig accepts a dictionary that conforms to a versioned schema. The dictionary is processed top to bottom, with later sections referencing names declared earlier. The schema has a small set of recognized top-level keys, and every other key is rejected with a ValueError.

The version key is mandatory and must equal the integer 1. It exists so future schema revisions can change behavior without breaking old configs. The formatters, filters, and handlers sections each map a name to a definition object. Handlers reference formatters and filters by those names, which is what lets you define a JSON formatter once and attach it to three handlers.

The loggers section maps logger names (the dotted hierarchy you pass to getLogger) to a configuration with level, handlers, and propagate. The special root key configures the root logger and accepts the same fields except a name. Because records propagate up the hierarchy to the root unless stopped, the interaction between a named logger's handlers and the root's handlers is the single most common source of duplicate output, covered in the mistakes section.

Two flags govern lifecycle. disable_existing_loggers (default True) disables every logger that already exists and is not named in the new config; setting it to False is almost always correct for real applications, because import-time loggers in libraries are created before your config runs. incremental (default False) tells dictConfig to only adjust levels and propagation on already-configured loggers and handlers instead of rebuilding the tree, which is how you safely tweak verbosity at runtime.

Step-by-step implementation

Step 1 — Anchor the schema. Start with the version key. This is the minimal valid config and resets the root logger to a StreamHandler at WARNING if you stop here.

import logging.config

config = {
    "version": 1,                       # required, must be the integer 1
    "disable_existing_loggers": False,  # keep library loggers alive
}
logging.config.dictConfig(config)

Step 2 — Declare formatters. A formatter definition supports format, datefmt, the style placeholder syntax (%, {, or $), and validate. Define a human-readable console formatter and a JSON-shaped one.

config["formatters"] = {
    "console": {
        "format": "%(asctime)s %(levelname)-8s %(name)s | %(message)s",
        "datefmt": "%Y-%m-%dT%H:%M:%S",
    },
    "json": {
        # A compact JSON line; see formatter-configuration for a real encoder.
        "format": '{"ts":"%(asctime)s","level":"%(levelname)s",'
                  '"logger":"%(name)s","msg":"%(message)s"}',
    },
}

Step 3 — Add filters. Filters are referenced by handlers and loggers. The () key marks a custom callable to instantiate; any other keys are passed as keyword arguments to that callable.

config["filters"] = {
    "below_error": {
        "()": "myapp.logging_filters.MaxLevelFilter",  # custom class path
        "max_level": "WARNING",                        # kwarg to __init__
    },
}

Step 4 — Define handlers. Each handler names its class, an optional level, the formatter to use, a filters list, and any class-specific arguments such as stream or filename. Route INFO-and-below to stdout and ERROR-and-above to a file.

config["handlers"] = {
    "stdout": {
        "class": "logging.StreamHandler",
        "level": "DEBUG",
        "formatter": "console",
        "filters": ["below_error"],   # keep errors off stdout
        "stream": "ext://sys.stdout",
    },
    "error_file": {
        "class": "logging.handlers.RotatingFileHandler",
        "level": "ERROR",
        "formatter": "json",
        "filename": "errors.log",
        "maxBytes": 10_485_760,       # 10 MiB before rollover
        "backupCount": 5,
    },
}

Step 5 — Attach loggers and the root. Map your application's package logger to both handlers and set propagate: False so records do not also hit the root. Configure the root as the catch-all for everything else.

config["loggers"] = {
    "myapp": {
        "level": "INFO",
        "handlers": ["stdout", "error_file"],
        "propagate": False,           # prevents duplicate emission via root
    },
    "sqlalchemy.engine": {
        "level": "WARNING",           # quiet a noisy dependency
        "handlers": ["stdout"],
        "propagate": False,
    },
}
config["root"] = {
    "level": "WARNING",
    "handlers": ["stdout"],
}

Step 6 — Apply once at startup. Call dictConfig before any logging happens, ideally in your entry point or app factory.

logging.config.dictConfig(config)
logging.getLogger("myapp").info("logging configured")

The ext:// prefix is a dictConfig convenience that resolves a dotted name against importable objects, so ext://sys.stdout becomes the actual stream object. The cfg:// prefix can reference other parts of the same config dictionary for advanced reuse.

Loading configuration from YAML and JSON

A static file keeps configuration editable by operators without code changes. YAML and JSON both deserialize to the same dictionary that dictConfig consumes. For the full end-to-end version of this pattern, see configuring logging with dictConfig.

import json
import logging.config
from pathlib import Path

import yaml  # from pyyaml>=6.0,<7.0


def load_logging(path: str) -> None:
    text = Path(path).read_text(encoding="utf-8")
    if path.endswith((".yaml", ".yml")):
        config = yaml.safe_load(text)   # never use yaml.load on untrusted input
    else:
        config = json.loads(text)
    logging.config.dictConfig(config)

For environment-driven configuration, resolve values in code after parsing so secrets and per-stage overrides never live in the file:

import os

config = yaml.safe_load(Path("logging.yaml").read_text())
config["root"]["level"] = os.environ.get("LOG_LEVEL", "INFO")
chosen = "json" if os.environ.get("LOG_FORMAT") == "json" else "console"
config["handlers"]["stdout"]["formatter"] = chosen
logging.config.dictConfig(config)

Configuration reference

Key Scope Required Default Purpose
version top level yes none Schema version; must be the integer 1.
formatters top level no {} Named formatter definitions (format, datefmt, style, validate).
filters top level no {} Named filter definitions; () marks a custom callable.
handlers top level no {} Named handlers with class, level, formatter, filters, class args.
loggers top level no {} Per-name level, handlers, filters, propagate.
root top level no none Root logger config; same fields as a logger minus the name.
incremental top level no False Only adjust levels/propagation on existing objects.
disable_existing_loggers top level no True Disable loggers not named in this config.
propagate per logger no True Whether records pass up to ancestor handlers.
() filter/handler/formatter no none Dotted path to a custom callable to instantiate.

Async and concurrency considerations

dictConfig itself is a one-shot setup call, not a hot path, but the handlers it builds run on whatever thread or task emits the record. The default StreamHandler and FileHandler perform synchronous I/O, so under asyncio they block the event loop for the duration of the write. The standard remedy is to declare a QueueHandler as the only attached handler and let a background listener drain to the real sinks, the pattern detailed in non-blocking logging with QueueHandler.

Note that the QueueListener is not constructed by the dictConfig schema in older Python versions, so its background thread is typically started in code after the dictConfig call returns. When using incremental: True to bump levels at runtime, the change is applied in-process and is safe to call from a signal handler, but it cannot swap handler objects, so a queue-based handler stays in place across reloads.

Production code example

This example assembles a complete, runnable configuration that adapts to an environment variable and emits both a console line and a JSON error record.

import logging.config
import os
import sys


def build_config() -> dict:
    level = os.environ.get("LOG_LEVEL", "INFO").upper()
    return {
        "version": 1,
        "disable_existing_loggers": False,
        "formatters": {
            "console": {
                "format": "%(asctime)s %(levelname)-8s %(name)s | %(message)s",
                "datefmt": "%Y-%m-%dT%H:%M:%S",
            },
            "json": {
                "format": '{"ts":"%(asctime)s","level":"%(levelname)s",'
                          '"logger":"%(name)s","msg":"%(message)s"}',
                "datefmt": "%Y-%m-%dT%H:%M:%S",
            },
        },
        "handlers": {
            "stdout": {
                "class": "logging.StreamHandler",
                "level": level,
                "formatter": "console",
                "stream": "ext://sys.stdout",
            },
            "error_file": {
                "class": "logging.handlers.RotatingFileHandler",
                "level": "ERROR",
                "formatter": "json",
                "filename": "errors.log",
                "maxBytes": 10_485_760,
                "backupCount": 5,
            },
        },
        "loggers": {
            "payment": {
                "level": level,
                "handlers": ["stdout", "error_file"],
                "propagate": False,   # no duplicate via root
            },
        },
        "root": {"level": "WARNING", "handlers": ["stdout"]},
    }


if __name__ == "__main__":
    logging.config.dictConfig(build_config())
    log = logging.getLogger("payment")
    log.info("charge accepted", extra={})
    log.error("gateway timeout after 3 retries")

Expected Output (console, with LOG_LEVEL=INFO):

2026-06-19T12:04:51 INFO     payment | charge accepted
2026-06-19T12:04:51 ERROR    payment | gateway timeout after 3 retries

The error_file handler simultaneously writes only the ERROR record as a JSON line to errors.log:

{"ts":"2026-06-19T12:04:51","level":"ERROR","logger":"payment","msg":"gateway timeout after 3 retries"}

Common mistakes

Silent third-party loggers after configuration Error signature: a dependency that logged normally before your setup goes completely quiet. Root cause: disable_existing_loggers defaults to True, disabling every logger created during import before your dictConfig call ran. Remediation: set disable_existing_loggers: False, or name the libraries explicitly in the loggers section so they are reconfigured rather than disabled.

Duplicate log lines from propagation Error signature: every message appears twice in stdout. Root cause: a named logger and the root both have handlers, and the named logger propagates records upward by default. Remediation: set propagate: False on the named logger, or attach handlers only to the root. Aligning levels per log levels and severity mapping does not fix duplication; only propagation control does.

Treating incremental config as a full rebuild Error signature: a new handler or formatter declared in an incremental: True config never takes effect. Root cause: incremental mode only mutates level and propagate on existing objects; it ignores new handler, formatter, and filter definitions. Remediation: use incremental mode purely for live verbosity changes, and run a full (non-incremental) dictConfig when handler topology must change.

Calling dictConfig after the first log record Error signature: early startup messages use the wrong format or go to the wrong sink. Root cause: modules imported before the config call already grabbed loggers and emitted records under the default configuration. Remediation: call dictConfig at the very top of your entry point, before importing modules that log at import time.

Frequently Asked Questions

Why are my third-party library logs silent after calling dictConfig?

By default dictConfig sets disable_existing_loggers to true, which mutes every logger created before the call. Set it to false to keep library loggers alive, or list them explicitly in the loggers section.

Can I update logging config at runtime without rebuilding everything?

Yes. Set incremental to true and dictConfig will only adjust levels and propagation on existing loggers and handlers rather than recreating them. Handler and formatter objects cannot be changed incrementally.

Should I load logging config from YAML or define it in Python?

Use a Python dict for values that depend on environment variables or runtime conditions, and YAML or JSON when operators need to edit configuration without touching code. Both feed the same dictConfig call.

Why do I get duplicate log lines after configuring logging?

Records propagate from child loggers to the root by default. If both a named logger and the root have handlers attached, each record is emitted twice. Set propagate to false on the named logger or attach handlers in only one place.