Fix: Python logging Not Showing Output
Part of: Python Errors
Quick Answer
How to fix Python logging not displaying messages — log level misconfiguration, missing handlers, root logger vs named loggers, basicConfig not working, and logging in libraries vs applications.
The Error
You call logging.info() or logger.debug() but nothing appears in the output:
import logging
logging.info("Starting application") # Nothing printed
logging.debug("Debug message") # Nothing printedOr a named logger produces no output:
import logging
logger = logging.getLogger(__name__)
logger.info("This should appear") # SilenceOr only some log levels appear:
logging.basicConfig()
logging.debug("debug") # Nothing
logging.info("info") # Nothing
logging.warning("warn") # WARNING:root:warn ← Only warnings and aboveOr the configuration you set is being ignored:
logging.basicConfig(level=logging.DEBUG) # Configured DEBUG level
logging.debug("debug") # Still nothing — basicConfig ignored?Why This Happens
Python’s logging system has multiple layers — loggers, handlers, and formatters — each with their own level settings. Misconfiguration at any layer causes messages to disappear. The architecture was designed for flexibility (a single application can route different log messages to different destinations at different severity levels), but that flexibility comes at the cost of silent failures when any layer is misconfigured.
The most common trap is the default log level. When you call logging.getLogger(), the root logger starts at WARNING. This means DEBUG and INFO messages are filtered out before they ever reach a handler. Calling logging.debug("test") does nothing unless you explicitly lower the level first. This default exists because library authors are expected to log at DEBUG/INFO without flooding the user’s console, but it surprises every application developer who expects logging.info() to produce output immediately.
The second trap is basicConfig(). This function is designed to be called exactly once, early in program startup. If any handler is already attached to the root logger (because a library imported before your code triggered logging), basicConfig() silently does nothing. The force=True parameter (added in Python 3.8) exists specifically to work around this, but many tutorials omit it.
Specific causes:
- Default level is WARNING — the root logger’s default level is
WARNING.DEBUGandINFOmessages are filtered out unless you explicitly lower the level. basicConfig()called after handlers are attached —basicConfig()is a no-op if the root logger already has handlers. Calling it after any logging output (even from a library) means your configuration is silently ignored.- No handler attached — a logger with no handler (and no propagation to a logger with a handler) drops all messages silently.
- Handler level vs logger level — both the logger AND the handler have levels. A message must pass both filters. Setting
logger.setLevel(DEBUG)but leaving the handler atWARNINGstill suppresses debug messages. propagate=Falseon a named logger without its own handler — if you setpropagate=Falseon a logger but don’t attach a handler, the logger has nowhere to send messages.- Library calling
logging.basicConfig()— some libraries callbasicConfig()themselves, consuming the root logger’s handler slot before your code runs.
How Other Tools Handle This
Python’s logging stdlib module is powerful but verbose. Several alternative libraries take different approaches to the same problem, and understanding them clarifies what makes stdlib logging confusing and when an alternative is worth adopting.
loguru replaces the entire logging architecture with a single object. You from loguru import logger and call logger.info("message"). There is no basicConfig(), no handler/formatter separation, no level hierarchy to debug. Output works immediately with sensible defaults (colored output, ISO timestamp, module name). Adding a file sink is one line: logger.add("app.log", rotation="10 MB"). loguru intercepts stdlib logging via InterceptHandler, so third-party libraries that use logging.getLogger() still route through loguru. The trade-off is that loguru is a third-party dependency and its single-logger model does not support the fine-grained per-module configuration that stdlib logging provides. For small-to-medium applications, loguru eliminates the most common footguns.
structlog solves a different problem: structured (key-value) logging. Instead of logger.info("User logged in, user_id=42"), you write logger.info("user_logged_in", user_id=42). The output is a JSON object or a key-value string, depending on the configured renderer. structlog can wrap stdlib logging (using it as the output layer) or bypass it entirely. The key insight is that structlog separates the “what to log” API from the “how to format and deliver” pipeline. Processors (functions in a chain) transform each log event before output, enabling context injection (request ID, user ID), filtering, and sampling without modifying application code. structlog integrates with stdlib logging, loguru, or its own PrintLogger.
Eliot takes a causal-tracing approach. Each log message is part of an “action tree” that tracks parent-child relationships between operations. with start_action(action_type="handle_request") as action: creates a context, and all messages logged inside that context are linked to it. This produces logs that read like a distributed trace without a separate tracing system. Eliot is more complex to set up than stdlib logging but eliminates the “which request caused this log line?” problem that plagues multi-threaded servers.
Django logging uses stdlib logging under the hood but configures it via a LOGGING dictionary in settings.py. This is essentially dictConfig with Django-specific defaults. Django pre-configures loggers for django.request, django.server, django.db.backends, and django.security. The common mistake in Django is adding logging.basicConfig() in a view or model file — it does nothing because Django has already configured the root logger during startup. All logging configuration in Django belongs in settings.LOGGING. To see SQL queries, set django.db.backends to DEBUG in that dictionary.
Flask logging is simpler. Flask creates a logger accessible as app.logger, which is a stdlib logging.Logger named after the application. In development mode (FLASK_DEBUG=1), Flask attaches a StreamHandler at DEBUG level. In production mode, Flask does not attach any handler, which means app.logger.info() produces no output unless you configure a handler yourself. The fix is the same as for any stdlib logger: attach a handler and set the level. Flask does not use dictConfig by default, but you can apply one manually.
12-factor logging is the operational philosophy that simplifies all of the above. The twelve-factor app methodology says: log to stdout, nothing else. No file handlers, no rotation, no syslog. The process manager (systemd, Docker, Kubernetes) captures stdout and routes it to the aggregation system (CloudWatch, Datadog, ELK). This means your Python application only needs a StreamHandler on sys.stdout with an appropriate formatter (plain text for development, JSON for production). All the complexity of RotatingFileHandler, SysLogHandler, and SMTPHandler is pushed to infrastructure.
Fix 1: Set the Log Level Explicitly
The most common fix — set the log level on both the root logger and the handler:
import logging
# Configure root logger
logging.basicConfig(
level=logging.DEBUG, # Show DEBUG and above
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
)
logging.debug("debug message") # DEBUG:root:debug message
logging.info("info message") # INFO:root:info message
logging.warning("warning") # WARNING:root:warningFor named loggers:
import logging
# Configure root logger first
logging.basicConfig(level=logging.DEBUG)
# Get a named logger — inherits root's handler and level
logger = logging.getLogger(__name__)
logger.debug("this now works") # Shows if basicConfig was called firstThe level hierarchy: A message must pass the logger’s level check AND the handler’s level check to appear in output. If either is set too high, the message is silently dropped.
Fix 2: Call basicConfig Before Any Logging Occurs
basicConfig() is a no-op if the root logger already has handlers. Import order matters:
import logging
# WRONG — some libraries log during import, adding handlers before basicConfig
import requests # requests may trigger logging setup
import boto3 # boto3 adds its own handlers
logging.basicConfig(level=logging.DEBUG) # Ignored — handlers already exist!
# CORRECT — configure logging FIRST, before importing third-party libraries
import logging
logging.basicConfig(level=logging.DEBUG) # Runs before any handlers are added
import requests
import boto3Or force reconfiguration with force=True (Python 3.8+):
import logging
logging.basicConfig(
level=logging.DEBUG,
format='%(levelname)s:%(name)s:%(message)s',
force=True, # Removes existing handlers and reconfigures
)force=True is the nuclear option — it clears all existing handlers and reapplies the configuration. Use it when you can’t control import order.
Fix 3: Add a Handler Manually
When basicConfig() isn’t appropriate (library code, complex apps), configure handlers explicitly:
import logging
import sys
logger = logging.getLogger('myapp')
logger.setLevel(logging.DEBUG) # Logger level
# Create a console handler
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(logging.DEBUG) # Handler level — must also be DEBUG
# Create a formatter
formatter = logging.Formatter(
'%(asctime)s [%(levelname)s] %(name)s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
handler.setFormatter(formatter)
# Attach handler to logger
logger.addHandler(handler)
# Now it works
logger.debug("debug")
logger.info("info")
logger.error("error")Add a file handler:
file_handler = logging.FileHandler('app.log')
file_handler.setLevel(logging.WARNING) # Only warnings and above to file
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
# Sends to stdout AND file (with different levels)
logger.warning("This goes to both stdout and app.log")
logger.debug("This only goes to stdout")Common Mistake: Calling
logger.addHandler(handler)in a module that gets imported multiple times (e.g., inside a function or at module level in a library) adds duplicate handlers. Each import appends another handler, and messages appear 2x, 3x, 4x. Guard against this by checkingif not logger.handlers:before adding, or usedictConfigwhich replaces handlers cleanly.
Fix 4: Fix the Logger Hierarchy and Propagation
Named loggers form a hierarchy based on their names. logging.getLogger('myapp.db') is a child of logging.getLogger('myapp'), which is a child of the root logger:
import logging
# Root logger — parent of all loggers
root = logging.getLogger()
root.setLevel(logging.WARNING) # Root filters to WARNING and above
# Named logger
logger = logging.getLogger('myapp')
logger.setLevel(logging.DEBUG) # Logger allows DEBUG
# Handler on root — this is the only handler
logging.basicConfig(level=logging.WARNING)
logger.debug("debug") # Passes logger level (DEBUG) but FAILS root handler level (WARNING)
logger.warning("warn") # Passes both → appearsFix: set the handler level to match what you want to see:
import logging
logging.basicConfig(level=logging.DEBUG) # Handler level = DEBUG
logger = logging.getLogger('myapp')
logger.setLevel(logging.DEBUG) # Logger level = DEBUG
logger.debug("now visible") # Passes both levelsOr add a handler directly to the named logger:
logger = logging.getLogger('myapp')
logger.setLevel(logging.DEBUG)
handler = logging.StreamHandler()
handler.setLevel(logging.DEBUG)
logger.addHandler(handler)
logger.propagate = False # Don't also send to root logger (prevents duplicate output)
logger.debug("works")Fix 5: Fix Duplicate Log Messages
If messages appear twice in the output, a handler is attached to both a logger and its parent:
import logging
logger = logging.getLogger('myapp')
logger.setLevel(logging.DEBUG)
# Handler added to 'myapp' logger
handler = logging.StreamHandler()
logger.addHandler(handler)
# basicConfig also adds a handler to the root logger
logging.basicConfig()
logger.info("test")
# Output:
# test ← from 'myapp' handler
# INFO:myapp:test ← propagated to root handler (with default format)Fix — disable propagation to prevent double logging:
logger = logging.getLogger('myapp')
logger.addHandler(handler)
logger.propagate = False # Don't send to root loggerFix 6: Configure Logging with dictConfig
For production applications, use dictConfig for structured, easy-to-read configuration:
import logging
import logging.config
LOGGING_CONFIG = {
'version': 1,
'disable_existing_loggers': False, # Don't silence library loggers
'formatters': {
'standard': {
'format': '%(asctime)s [%(levelname)s] %(name)s: %(message)s',
},
'json': {
'()': 'pythonjsonlogger.jsonlogger.JsonFormatter', # pip install python-json-logger
'format': '%(asctime)s %(levelname)s %(name)s %(message)s',
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'level': 'DEBUG',
'formatter': 'standard',
'stream': 'ext://sys.stdout',
},
'file': {
'class': 'logging.handlers.RotatingFileHandler',
'level': 'WARNING',
'formatter': 'standard',
'filename': 'app.log',
'maxBytes': 10_485_760, # 10 MB
'backupCount': 5,
},
},
'loggers': {
'': { # Root logger
'handlers': ['console'],
'level': 'WARNING',
},
'myapp': { # Application logger
'handlers': ['console', 'file'],
'level': 'DEBUG',
'propagate': False, # Don't also log to root
},
'myapp.db': { # Database logger — less verbose
'level': 'INFO',
'propagate': True, # Uses myapp's handlers
},
},
}
logging.config.dictConfig(LOGGING_CONFIG)
logger = logging.getLogger('myapp')
logger.debug("Application starting") # visibleFix 7: Silence Noisy Library Loggers
Third-party libraries (requests, boto3, urllib3, sqlalchemy) often log at DEBUG level, flooding your output when you enable DEBUG for your app:
import logging
# Enable DEBUG for your code only
logging.basicConfig(level=logging.WARNING) # Root stays at WARNING
# Enable DEBUG for your specific module
logging.getLogger('myapp').setLevel(logging.DEBUG)
# Silence specific noisy libraries
logging.getLogger('urllib3').setLevel(logging.WARNING)
logging.getLogger('boto3').setLevel(logging.WARNING)
logging.getLogger('botocore').setLevel(logging.WARNING)
logging.getLogger('sqlalchemy.engine').setLevel(logging.WARNING)
logging.getLogger('requests').setLevel(logging.WARNING)In dictConfig, control third-party loggers:
'loggers': {
'urllib3': {'level': 'WARNING'},
'boto3': {'level': 'WARNING'},
'sqlalchemy.engine': {'level': 'INFO'}, # Show SQL queries but not debug
}Still Not Working?
Inspect what handlers are attached:
import logging
# Check root logger
root = logging.getLogger()
print("Root level:", root.level, logging.getLevelName(root.level))
print("Root handlers:", root.handlers)
# Check named logger
logger = logging.getLogger('myapp')
print("Logger level:", logger.level, logging.getLevelName(logger.level))
print("Logger handlers:", logger.handlers)
print("Propagate:", logger.propagate)
# Walk the effective hierarchy
log = logger
while log:
print(f"Logger '{log.name}': level={logging.getLevelName(log.level)}, handlers={log.handlers}")
if not log.parent:
break
log = log.parentUse logging.getLogger().manager.loggerDict to see all loggers that have been created:
print(logging.getLogger().manager.loggerDict.keys())Check if a library is using NullHandler — libraries should call logging.getLogger(__name__).addHandler(logging.NullHandler()) and let the application configure logging. If a library adds a real handler, it may conflict with your setup.
Check if your logging is swallowed by a framework’s test runner:
# pytest captures logging by default — use the --log-cli-level flag to see logs during tests
# pytest --log-cli-level=DEBUG test_myapp.py
# Or configure in pytest.ini / pyproject.toml
# [tool.pytest.ini_options]
# log_cli = true
# log_cli_level = "DEBUG"Verify the effective level of a logger when inheritance is involved:
logger = logging.getLogger('myapp.submodule')
print(logger.getEffectiveLevel())
# This walks up the hierarchy and returns the first non-NOTSET level
# If you see 30 (WARNING), a parent logger is setting the effective level higher than you expectFor related Python issues, see Fix: Python asyncio Runtime Error, Fix: Python ImportError Circular Import, Fix: Loguru Not Working, and Fix: structlog Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Kafka Consumer Not Receiving Messages, Connection Refused, and Rebalancing Errors
How to fix Apache Kafka issues — consumer not receiving messages, auto.offset.reset, Docker advertised.listeners, max.poll.interval.ms rebalancing, MessageSizeTooLargeException, and KafkaJS errors.
Fix: Python Packaging Not Working — Build Fails, Package Not Found After Install, or PyPI Upload Errors
How to fix Python packaging issues — pyproject.toml setup, build backends (setuptools/hatchling/flit), wheel vs sdist, editable installs, package discovery, and twine upload to PyPI.
Fix: Celery Beat Not Working — Scheduled Tasks Not Running or Beat Not Starting
How to fix Celery Beat issues — beat scheduler not starting, tasks not executing on schedule, timezone configuration, database scheduler, and running beat with workers.
Fix: OpenTelemetry Not Working — Traces Not Appearing, Spans Missing, or Exporter Connection Refused
How to fix OpenTelemetry issues — SDK initialization order, auto-instrumentation setup, OTLP exporter configuration, context propagation, and missing spans in Node.js, Python, and Java.