2025, Oct 22 11:00
Fix Python logging propagation: keep ERROR out of INFO files and eliminate duplicate console lines
Learn why ERROR messages leak into INFO logs in Python logging dictConfig and how to fix duplicate console output: set propagate to a real boolean false.
Separating INFO and ERROR logs into different files sounds straightforward, yet a subtle configuration detail in Python logging can make ERROR entries show up in the INFO file as well. If you see duplicate console lines and your error messages are written into both app_error.log and app_info.log, the cause is almost certainly the same: propagation isn’t actually disabled.
Problem setup
Consider a logging configuration where you want INFO messages sent to app_info.log and ERROR messages sent to app_error.log. The configuration below illustrates that intent, but it still results in ERROR entries appearing in both files.
{
  "version": 1,
  "disable_existing_loggers": false,
  "formatters": {
    "fmt_line": {
      "format": "%(asctime)s - %(name)s -  %(levelname)s - %(message)s"
    }
  },
  "handlers": {
    "InfoSink": {
      "class": "logging.handlers.RotatingFileHandler",
      "filename": "app_info.log",
      "level": "INFO",
      "formatter": "fmt_line",
      "maxBytes": 10485760,
      "backupCount": 5,
      "encoding": "utf-8"
    },
    "ErrorSink": {
      "class": "logging.handlers.RotatingFileHandler",
      "filename": "app_error.log",
      "level": "ERROR",
      "formatter": "fmt_line",
      "maxBytes": 10485760,
      "backupCount": 5,
      "encoding": "utf-8"
    },
    "ConsoleSink": {
      "class": "logging.StreamHandler",
      "level": "INFO",
      "formatter": "fmt_line",
      "stream": "ext://sys.stdout"
    }
  },
  "loggers": {
    "svc_ERR": {
      "handlers": ["ConsoleSink", "ErrorSink"],
      "level": "ERROR",
      "propagate": "no"
    },
    "svc_INFO": {
      "handlers": ["ConsoleSink", "InfoSink"],
      "level": "INFO",
      "propagate": "no"
    }
  },
  "root": {
    "level": "DEBUG",
    "handlers": ["ConsoleSink", "InfoSink"]
  }
}
A minimal loader for this configuration might look like this:
import logging
import logging.config
import json
CFG_PATH = "log_config.json"
active_log = None
def start_logger(alias):
    global active_log
    with open(CFG_PATH, "r") as fh:
        cfg_obj = json.load(fh)
    if cfg_obj is None:
        raise ValueError("initialize log json file is not set")
    logging.config.dictConfig(cfg_obj)
    active_log = logging.getLogger(alias)
    return active_log
And usage:
>>> import logger_bootstrap  # your module containing start_logger
>>> lg = logger_bootstrap.start_logger("svc_ERR")
>>> lg.error("this is test2")
Despite targeting the error logger, the error message is written to app_error.log and to app_info.log. On top of that, you can see duplicate console lines when both the named logger and the root logger write to stdout.
Why it happens
Two mechanics are at play. First, a handler set to INFO will accept records with level INFO or higher, which includes ERROR. Second, whether a record “bubbles up” to ancestor loggers depends on the propagate attribute. The key is how that attribute is interpreted.
If this attribute evaluates to true, events logged to this logger will be passed to the handlers of higher level (ancestor) loggers, in addition to any handlers attached to this logger.
In the configuration above, propagate is defined as a non-empty string, for example "no". Any non-empty string evaluates to True. That means propagation isn’t disabled, and records from svc_ERR climb to the root logger, which has InfoSink attached. Because INFO means “INFO or higher,” the root writes the ERROR record into app_info.log as well.
Replacing the string with another non-empty string such as "false" does not change the outcome; it still evaluates to True. The attribute must be a boolean false, a 0, or an empty string to actually disable propagation.
The fix
Use a real boolean for propagate. Here is the corrected configuration; nothing else needs to change.
{
  "version": 1,
  "disable_existing_loggers": false,
  "formatters": {
    "fmt_line": {
      "format": "%(asctime)s - %(name)s -  %(levelname)s - %(message)s"
    }
  },
  "handlers": {
    "InfoSink": {
      "class": "logging.handlers.RotatingFileHandler",
      "filename": "app_info.log",
      "level": "INFO",
      "formatter": "fmt_line",
      "maxBytes": 10485760,
      "backupCount": 5,
      "encoding": "utf-8"
    },
    "ErrorSink": {
      "class": "logging.handlers.RotatingFileHandler",
      "filename": "app_error.log",
      "level": "ERROR",
      "formatter": "fmt_line",
      "maxBytes": 10485760,
      "backupCount": 5,
      "encoding": "utf-8"
    },
    "ConsoleSink": {
      "class": "logging.StreamHandler",
      "level": "INFO",
      "formatter": "fmt_line",
      "stream": "ext://sys.stdout"
    }
  },
  "loggers": {
    "svc_ERR": {
      "handlers": ["ConsoleSink", "ErrorSink"],
      "level": "ERROR",
      "propagate": false
    },
    "svc_INFO": {
      "handlers": ["ConsoleSink", "InfoSink"],
      "level": "INFO",
      "propagate": false
    }
  },
  "root": {
    "level": "DEBUG",
    "handlers": ["ConsoleSink", "InfoSink"]
  }
}
With propagation disabled using a real boolean, records handled by svc_ERR do not bubble up to the root, and thus no longer get written to app_info.log. Similarly, duplicate console lines disappear because the root no longer receives and re-emits the same record.
Why this matters
When logs propagate unintentionally, you lose the clean separation between INFO and ERROR files. That undermines the purpose of having distinct log targets, and it produces duplicate output where a single event is processed by multiple handlers. Ensuring propagate is truly disabled is the difference between precise routing and noisy, misleading log streams.
Takeaways
Define propagate as a boolean false rather than a string. Remember that level thresholds are inclusive, so a handler at INFO accepts ERROR records too. If you rely on the root logger, be aware that events will bubble up unless propagation is explicitly and correctly disabled. Get these details right, and you’ll have predictable, separate INFO and ERROR logs without duplication.