Python — Log Messages Appear Multiple Times
Fix duplicated Python log lines by finding repeated addHandler calls and propagating records between loggers, then configure handlers once at the root.
Symptoms
- Each log line appears twice, three times, or more with identical timestamps and content
- The multiplication gets worse the longer the process runs or the more modules import the logging setup
- Duplicate lines carry the same logger name but the count grows with re-imports or re-initialization
Possible causes
- A module calls logging.basicConfig or logger.addHandler at import time, so each import adds another handler
- A re-initialization path (hot reload, reconnect, test fixture) adds handlers without clearing existing ones
- Child loggers propagate records to ancestor loggers whose handlers also emit, printing once per attached handler
- Both a library default handler and the application handler are active on the same logger
Diagnosis steps
Count handlers per logger
python -c "import logging; print(logging.getLogger('app').handlers); print(logging.getLogger().handlers)"Interpretation
More than one handler on the emitting logger or its ancestors is the mechanism; the list length predicts the duplicate count.
Find registration at import time
grep -Rn "addHandler\|basicConfig" src/ | head -20Interpretation
Any module-level call runs on every import of that module, and test collection imports it repeatedly.
Confirm propagation shape
python -c "import logging; l=logging.getLogger('a.b'); print(l.parent.name, l.propagate)"Interpretation
A child logger with propagate=True and a parent with handlers emits once per handler up the chain.
Root cause
A log record is emitted once per handler attached to the emitting logger and, with propagation enabled, once per handler on every ancestor. Repeated addHandler calls — typically at import time or in re-initialization paths — stack multiple handlers, so one record prints once per stacked handler.
Fix
Configure handlers once at the program entry point on the root logger, guard library setup against re-entry, clear existing handlers before reconfiguring, and set propagate=False only for deliberate isolation such as access logs.
Risk note
Blindly setting propagate=False on every logger hides records from centralized handlers. Use it for intentional streams, not as a deduplication fix.
Verify the fix
Emit one record after a fresh start and after a simulated re-import; assert exactly one line in captured output each time.
Symptom walkthrough
Identical timestamps and content on consecutive lines rule out duplicate emission and point at duplicate delivery:
2026-10-22 09:14:03 INFO app.jobs job finished
2026-10-22 09:14:03 INFO app.jobs job finished
Interrogate the logger to see the multiplication directly:
import logging
print(logging.getLogger("app.jobs").handlers) # [..StreamHandler]
print(logging.getLogger("app").handlers) # [..StreamHandler, ..FileHandler]
The total handler count across the logger and its ancestors equals the number of printed copies. The count growing with process lifetime or test collection identifies import-time registration as the source.
Fix in practice
Centralize configuration at the entry point, and make reconfiguration idempotent by clearing first:
import logging
def setup_logging():
root = logging.getLogger()
root.setLevel(logging.INFO)
for h in list(root.handlers):
root.removeHandler(h) # idempotent re-entry
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
root.addHandler(handler)
In libraries, never attach handlers — emit records and let the application decide delivery. When a module must configure logging for standalone use, guard it:
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
Use propagate=False only for deliberately isolated streams (access logs to their own file), and document the choice.