Configuring Python Logging with dictConfig

This walkthrough builds one complete, copy-ready logging setup: a single YAML file with a JSON formatter, a console handler and a rotating file handler, per-module loggers, and a filter, all applied with one logging.config.dictConfig call at startup. It is the concrete companion to the logging configuration and dictConfig overview and part of the Python Logging Fundamentals and Structured Data guide.

Startup load of a dictConfig YAML file A logging.yaml file is read once at startup and passed to dictConfig. Two module loggers send records through handlers: one to the console as text, one to a rotating file as JSON. logging.yaml read once dictConfig at startup console readable text app.log rotating JSON
One YAML file drives both a readable console handler and a rotating JSON file handler through a single dictConfig call.

logging.config.dictConfig consumes a plain dictionary that describes the entire logging tree at once: formatters, filters, handlers, and loggers, plus a small set of top-level switches. Building that dictionary in YAML rather than in Python keeps operations knobs (levels, file paths, rotation sizes) out of code and lets you ship different files per environment. The schema is versioned by the mandatory version: 1 key; that anchor is what tells dictConfig to use the dictionary schema rather than the older fileConfig INI format.

Prerequisites

# YAML parsing only; the json and logging modules are stdlib.
pip install "pyyaml>=6.0,<7.0"

Optional environment override used below:

export LOG_LEVEL=DEBUG   # falls back to INFO when unset

Implementation

The dictionary schema has five sections you will fill in: formatters, filters, handlers, loggers, and the root logger, sitting under three top-level switches (version, disable_existing_loggers, and optionally incremental). dictConfig resolves them in dependency order — formatters and filters first, then handlers that reference them by name, then loggers that attach handlers — so a typo in a name surfaces as a build-time error rather than a silent miswire.

Step 1 — Write a custom JSON formatter via the () factory. The stdlib Formatter interpolates a format string but does not emit valid JSON when messages contain quotes. A tiny json.dumps-based formatter is safer and needs no third-party package. The () key tells dictConfig to treat the value as a factory: it imports the dotted path and calls it, passing any sibling keys as keyword arguments. That means your formatter can accept configuration straight from YAML. Place it in an importable module, here app/log_json.py.

# app/log_json.py
import json
import logging


class JsonFormatter(logging.Formatter):
    """Serialize each record as a single JSON object."""

    def __init__(self, fields: list[str] | None = None) -> None:
        # fields arrives from the YAML sibling keys via the () factory
        super().__init__()
        self.fields = fields or ["ts", "level", "logger", "msg"]

    def format(self, record: logging.LogRecord) -> str:
        base = {
            "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
            "level": record.levelname,
            "logger": record.name,
            "msg": record.getMessage(),
        }
        payload = {k: base[k] for k in self.fields if k in base}
        if record.exc_info:                       # attach traceback if present
            payload["exc"] = self.formatException(record.exc_info)
        return json.dumps(payload, ensure_ascii=False)

Step 2 — Write a filter to drop noisy records. A filter is any callable or object with a filter(record) method; returning falsy discards the record before it reaches the handler. Filters are also configured through the () factory, and a handler attaches them by name. Here a filter suppresses health-check log lines that would otherwise flood the file.

# app/log_filters.py
import logging


class DropHealthChecks(logging.Filter):
    """Discard records whose message mentions the health endpoint."""

    def filter(self, record: logging.LogRecord) -> bool:
        # returning False drops the record; True lets it through
        return "/healthz" not in record.getMessage()

Step 3 — Declare the configuration in YAML. The file names two formatters, one filter, two handlers, and two module loggers. The custom formatter and filter are wired with the () key, which dictConfig instantiates by importing the dotted path. propagate: false on each named logger prevents records from also reaching the root.

# logging.yaml
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:
    "()": app.log_json.JsonFormatter      # custom class, instantiated by dictConfig
    fields: [ts, level, logger, msg]      # passed as a kwarg to __init__

filters:
  no_health:
    "()": app.log_filters.DropHealthChecks

handlers:
  stdout:
    class: logging.StreamHandler
    level: DEBUG
    formatter: console
    stream: ext://sys.stdout
  file:
    class: logging.handlers.RotatingFileHandler
    level: INFO
    formatter: json
    filters: [no_health]                   # attach the filter by name
    filename: app.log
    maxBytes: 10485760                     # 10 MiB per file
    backupCount: 5                         # keep five rolled files

loggers:
  app.api:
    level: DEBUG
    handlers: [stdout, file]
    propagate: false
  app.db:
    level: WARNING                         # quieter than the rest of the app
    handlers: [stdout, file]
    propagate: false

root:
  level: WARNING
  handlers: [stdout]

Step 4 — Load and apply it once at startup. Read the file, deserialize with yaml.safe_load, optionally override the level from the environment, then call dictConfig before importing any module that logs. Reading the YAML in code (rather than via the LOGGING setting some frameworks expose) keeps the load path explicit and testable.

# app/bootstrap.py
import logging.config
import os
from pathlib import Path

import yaml  # pyyaml>=6.0,<7.0


def configure_logging(path: str = "logging.yaml") -> None:
    config = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
    # Optional env-driven override applied before the config is built.
    level = os.environ.get("LOG_LEVEL")
    if level:
        config["loggers"]["app.api"]["level"] = level.upper()
    logging.config.dictConfig(config)

Step 5 — Emit from two modules. Each module fetches its own named logger. Because the names match the YAML keys, app.api runs at DEBUG and app.db is held at WARNING. Per-module loggers are the reason dotted names matter: a logger named app.db.pool with no explicit entry inherits the app.db level through the hierarchy, so you configure a whole subtree with one key.

# app/main.py
from app.bootstrap import configure_logging

configure_logging()                  # MUST run before the loggers are used

import logging

api_log = logging.getLogger("app.api")
db_log = logging.getLogger("app.db")

api_log.debug("request received: GET /orders/42")
api_log.info("order 42 served in 12ms")
api_log.info("GET /healthz ok")                    # dropped by the file filter
db_log.debug("SELECT * FROM orders WHERE id=42")   # suppressed at WARNING
db_log.warning("connection pool at 90% capacity")

incremental vs disable_existing_loggers

Two top-level switches change how dictConfig treats loggers that already exist when it runs.

disable_existing_loggers (default true) disables every logger created before the call that is not explicitly named in the new config. Libraries imported earlier grab their loggers at import time, so the default silently mutes them. Setting it to false leaves those loggers enabled and is almost always what you want in an application.

incremental (default false) changes the whole semantics: when true, dictConfig ignores formatters, filters, and handler/logger construction, and only adjusts the level and propagate of objects that already exist. It is meant for tuning a live configuration — for example raising a single logger to DEBUG at runtime — without tearing down and rebuilding handlers (which would close open file descriptors). You cannot add a new handler or formatter incrementally; attempting to do so is simply skipped. Most startups use a full, non-incremental config and reserve incremental updates for an admin endpoint that bumps levels.

Configuration options

YAML key Where Effect
version: 1 top level Required schema anchor.
disable_existing_loggers: false top level Keeps import-time and library loggers active.
incremental: true top level Only updates levels/propagate of existing objects.
"()" formatter/filter/handler Dotted path to a factory dictConfig calls with sibling keys.
filters: [name] handler/logger Attaches a configured filter by name.
stream: ext://sys.stdout handler Resolves the named stream object via the ext:// prefix.
maxBytes / backupCount file handler Rotation threshold and number of retained files.
level logger/handler Lowest severity passed; per-module on each logger entry.
propagate: false logger Stops records from also reaching the root handlers.

Verification

Run python -m app.main with the default level. The console (via the console formatter) shows readable lines:

Expected Output (stdout):

2026-06-19T12:11:03 DEBUG    app.api | request received: GET /orders/42
2026-06-19T12:11:03 INFO     app.api | order 42 served in 12ms
2026-06-19T12:11:03 INFO     app.api | GET /healthz ok
2026-06-19T12:11:03 WARNING  app.db | connection pool at 90% capacity

The app.db DEBUG line is absent because that logger is pinned to WARNING. The rotating app.log simultaneously receives JSON-formatted records. The file handler is at INFO, so the DEBUG line from app.api is excluded there, and the no_health filter strips the /healthz line that did reach the console:

Expected Output (app.log):

{"ts": "2026-06-19T12:11:03", "level": "INFO", "logger": "app.api", "msg": "order 42 served in 12ms"}
{"ts": "2026-06-19T12:11:03", "level": "WARNING", "logger": "app.db", "msg": "connection pool at 90% capacity"}

Each record appears once per destination, confirming propagate: false prevents the root logger from re-emitting it, and the health-check line is present on the console but absent from the file, confirming the filter is attached to the file handler only.

Common mistakes

Configuring after the loggers are already in use Importing modules that log at import time before calling configure_logging means those early records use the default root handler and the wrong format. Call the bootstrap function as the first statement in your entry point.

Pointing () at a class that cannot be imported A ValueError during dictConfig usually means the dotted path under () is wrong or the module is not on sys.path. Verify the path resolves with a quick python -c "import app.log_json" before wiring it into the YAML.

Forgetting disable_existing_loggers: false Leaving it at the default disables every logger created before the call, silencing dependencies. Keep it false unless you have a deliberate reason to mute pre-existing loggers, as detailed in the logging configuration and dictConfig overview.

Expecting incremental: true to add handlers An incremental config silently ignores any new formatter, filter, or handler and only touches the levels of objects that already exist. If a fresh handler never appears, you left incremental on from an earlier runtime tweak; use a full config to construct objects and reserve incremental updates for level changes.

Frequently Asked Questions

Where should I call dictConfig in a real application?

Call it once in your entry point or application factory, before importing or running any module that emits log records. Configuring after the first record means early logs use the default setup.

Do I need python-json-logger to emit JSON with dictConfig?

No. You can declare a custom formatter class under the formatters section using the parentheses key, including a small one you write yourself. A third-party library is only a convenience, not a requirement.

How do I give each module its own log level?

Add an entry per dotted logger name under the loggers section, each with its own level and propagate set to false, so a noisy module can run at WARNING while your code runs at DEBUG.

What does incremental mode do in dictConfig?

With incremental set to true, dictConfig only adjusts the levels of existing handlers and loggers and ignores formatters, filters, and handler classes. It exists to tweak a running configuration without rebuilding it, but it cannot add new objects.