🐍 Python Course · Stage 3 · Lesson 38 of 89 · logging — Diagnostics
Course Overview →

🔵 Read Beginner and Intermediate tabs. The Expert tab has the internals — worth reading once you finish Stage 3.

← asyncio — Async I/O unittest — Testing →
thecodex.expert · The Codex Family of Knowledge
Python stdlib

Python logging module

Replace print() with a proper logging system — levels, file rotation, structured output, and zero-cost debug messages.

Python 3.13 Standard Library Last verified:
Canonical Definition

The Python logging module provides a flexible, hierarchical logging system — named loggers, five severity levels (DEBUG through CRITICAL), pluggable handlers (console, file, rotating), formatters, and centralised configuration via dictConfig.

🟩 Beginner

Replacing print() with proper logging

What you'll learn: The five log levels (DEBUG, INFO, WARNING, ERROR, CRITICAL), basicConfig for quick setup, and why logging is always better than print() for real applications.

How to read this tab: Replace all the print() calls in one of your scripts with logging calls. Add basicConfig at the top. Run it and observe the output format.

⏱ 25 min 📄 2 sections 🔶 Prerequisite: Modules & Packages
One sentence

logging is the right way to record what your program is doing — it beats print() because you can control how much detail you see, where messages go, and how they are formatted, all without changing the code that generates them.

The biggest logging mistake: using the root logger directly. logging.warning("something") writes to the root logger — and every library you import also uses the root logger. This means enabling DEBUG on the root logger floods you with messages from every library. Always use logging.getLogger(__name__) to get a named logger specific to your module.

Basic usage

Pythonlogging_basics.py
import logging

# Configure the root logger (do this once at program startup)
logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s — %(name)s — %(levelname)s — %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)

# Five severity levels (lowest to highest)
logging.debug("Detailed diagnostic info — usually too verbose for normal use")
logging.info("Normal operation confirmation: server started on port 8080")
logging.warning("Something unexpected but not an error: disk 80% full")
logging.error("Something failed: could not connect to database")
logging.critical("Severe failure: application cannot continue")

# Output:
# 2026-06-01 14:30:00 — root — DEBUG — Detailed diagnostic...
# 2026-06-01 14:30:00 — root — INFO — Normal operation...
💡

Logger names form a hierarchy. A logger named "myapp.database" is a child of "myapp", which is a child of the root logger. Log records propagate up the hierarchy unless you set propagate=False. This means you can set the level on "myapp" once and control all its children.

Named loggers

Pythonlogging_named.py
import logging

# Best practice: use __name__ as logger name
# This gives each module its own logger: "myapp.database", "myapp.server"
logger = logging.getLogger(__name__)

def connect_to_db(host, port):
    logger.info("Connecting to %s:%d", host, port)   # lazy string formatting
    try:
        # ... connection logic ...
        logger.debug("Connection established, pool size: %d", 10)
    except Exception as e:
        logger.error("Connection failed: %s", e, exc_info=True)  # include traceback
        raise

# In libraries: add NullHandler to avoid "No handler found" warning
# (let the application configure handlers)
logger.addHandler(logging.NullHandler())
💡

Multiple handlers = multiple outputs. Add a StreamHandler for console output and a FileHandler for file output — the same log messages go to both. The Formatter controls what each line looks like: %(asctime)s %(name)s %(levelname)s %(message)s is the standard production format.

Handlers and formatters

Pythonlogging_handlers.py
import logging
from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler

# Create logger
logger = logging.getLogger("myapp")
logger.setLevel(logging.DEBUG)

# Console handler — INFO and above
console = logging.StreamHandler()
console.setLevel(logging.INFO)
console.setFormatter(logging.Formatter("%(levelname)s — %(message)s"))

# File handler — DEBUG and above, rotates at 10MB, keeps 5 backups
file_handler = RotatingFileHandler(
    "app.log", maxBytes=10*1024*1024, backupCount=5
)
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(logging.Formatter(
    "%(asctime)s — %(name)s — %(levelname)s — %(funcName)s:%(lineno)d — %(message)s"
))

logger.addHandler(console)
logger.addHandler(file_handler)

logger.info("Server started")    # → console + file
logger.debug("Config loaded")    # → file only (below console level)

# TimedRotatingFileHandler — rotate daily at midnight
daily = TimedRotatingFileHandler("daily.log", when="midnight", backupCount=30)

✅ Beginner tab complete — check your understanding

  • I know the five log levels in order: DEBUG < INFO < WARNING < ERROR < CRITICAL
  • I can set up basic logging with logging.basicConfig(level=logging.DEBUG)
  • I use logging.debug() for development details, logging.info() for normal operations, logging.warning() for unexpected-but-recoverable situations, logging.error() for real errors
  • I know that the default level is WARNING — DEBUG and INFO messages are silenced unless you change it

Continue to pathlib →

🔵 Intermediate

Named loggers, handlers, and formatters

What you'll learn: Named loggers (one per module, using __name__). Handlers for directing log output to files, streams, or other destinations. Formatters for controlling the output format.

How to read this tab: Write a module that uses logging.getLogger(__name__) and observe how the logger name appears in the output. Then add a FileHandler to write logs to a file alongside the console output.

⏱ 30 min 📄 2 sections 🔶 Prerequisite: Beginner tab
📎

Propagation controls message flow. By default, a log record from "myapp.database" goes to the "myapp.database" logger, then propagates to "myapp", then to the root logger. If all three have handlers, the message appears three times. Set propagate=False on child loggers to prevent this.

Logging hierarchy and propagation

Loggers form a hierarchy based on their names — dot-separated like Python modules. "myapp.database" is a child of "myapp", which is a child of the root logger. When a logger decides a message should be emitted, it passes it to its handlers, then propagates it up to the parent logger (unless propagate = False). The root logger is the ancestor of all loggers. This means you can set up one handler on the root logger and all child loggers will use it — or configure specific loggers to handle their own output differently.

Pythonlogging_hierarchy.py
import logging

# Root logger — ancestor of all
root = logging.getLogger()

# Module-level loggers
db_logger = logging.getLogger("myapp.database")
api_logger = logging.getLogger("myapp.api")

# Only configure the root — children propagate up to it
logging.basicConfig(level=logging.WARNING)

# Override level for specific module
db_logger.setLevel(logging.DEBUG)   # database logs more verbosely

# Prevent propagation for a noisy library
noisy_lib = logging.getLogger("noisy_library")
noisy_lib.propagate = False        # stop messages going to root
noisy_lib.addHandler(logging.NullHandler())  # swallow all
📎

Structured logging is the modern approach. Instead of plain text like "User Priya logged in at 10:32", structured logging outputs JSON: {"event": "login", "user": "Priya", "timestamp": "..."}. Log aggregation systems can then query, filter, and alert on specific fields. Use python-json-logger (pip install python-json-logger) for this.

Structured logging and dictConfig

Pythonlogging_structured.py
import logging
import logging.config
import json

# Configure via dict (better than basicConfig for production)
LOGGING_CONFIG = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "standard": {"format": "%(asctime)s %(levelname)s %(name)s: %(message)s"},
        "json": {"()": "pythonjsonlogger.jsonlogger.JsonFormatter"},  # pip install
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "formatter": "standard",
            "level": "INFO",
        },
        "file": {
            "class": "logging.handlers.RotatingFileHandler",
            "filename": "app.log",
            "maxBytes": 10485760,
            "backupCount": 5,
            "formatter": "standard",
            "level": "DEBUG",
        },
    },
    "root": {"level": "DEBUG", "handlers": ["console", "file"]},
}
logging.config.dictConfig(LOGGING_CONFIG)

# Log with extra context
logger = logging.getLogger(__name__)
logger.info("User login", extra={"user_id": 42, "ip": "192.168.1.1"})
Commonly confused
Do not use print() for diagnostic output in production code. print() always goes to stdout, cannot be filtered by level, has no timestamps or module names, cannot be redirected to files or external systems, and must be removed or commented out to silence. logging.debug() costs nothing if the level is INFO or above — the string is never even formatted.
Use %s formatting in log calls, not f-strings. logger.debug("User: %s", user) is lazy — the string is only formatted if the message will actually be emitted. logger.debug(f"User: {user}") always formats the string, even if the DEBUG level is disabled. Use % formatting in logging calls.
The root logger and your application's logger are different. Calling logging.info() (no name) logs to the root logger. In a library, always create a named logger with logging.getLogger(__name__) — never configure the root logger, and always add a NullHandler so the library works without any logging configuration in the consuming application.
How this connects
Requires first

✅ Intermediate tab complete — check your understanding

  • I always use logging.getLogger(__name__) instead of the root logger
  • I can add a FileHandler to write logs to a file
  • I can set a Formatter to control the timestamp, level, and message format
  • I understand logger hierarchy: "myapp.module" is a child of "myapp"

Continue to pathlib →

🔴 Expert

dictConfig, structured logging, and the LogRecord pipeline

What you'll learn: logging.config.dictConfig() for production configuration. Structured (JSON) logging for log aggregation systems. The full LogRecord pipeline from emit() to handler.

How to read this tab: Read when setting up production logging for a real application — especially one that uses a log aggregation service like Datadog, Splunk, or ELK stack.

⏱ 25 min 📄 2 sections 🔶 Prerequisite: After Stage 3
📎

The full pipeline: 1) Logger.warning("msg") creates a LogRecord object. 2) Logger checks if the message should be logged (level check). 3) LogRecord is passed to each Handler. 4) Handler applies Filters (optional). 5) Handler passes to its Formatter, which produces the final string. 6) Handler emits the string (writes to file, sends to network, etc.).

Logging architecture: LogRecord, Filter, Handler, Formatter

When you call logger.info("msg"), the logging system creates a LogRecord object containing: name, levelno, pathname, lineno, msg, args, exc_info, func, and created (timestamp). The logger checks if the record's level meets its own effectiveLevel (walking up the hierarchy until a level is found). If it passes, each handler checks its own level filter, then applies its Filter chain, then its Formatter to produce the final string, then emits. The Formatter uses %-style formatting with LogRecord attributes as the dictionary. Custom Formatter subclasses can produce structured output (JSON, etc.).

logging.config and fileConfig vs dictConfig

The older logging.config.fileConfig() reads INI-format config files. The modern logging.config.dictConfig() (Python 3.2+) accepts a dictionary — easily sourced from JSON, YAML, or in-code dicts. The disable_existing_loggers key (default True in fileConfig, should usually be False in dictConfig) controls whether loggers created before dictConfig() is called are disabled — a common source of "my library's logger suddenly stopped working" bugs.

✅ Expert tab complete

  • I can configure logging with a dictConfig dictionary for clean, maintainable setup
  • I can output structured JSON logs for log aggregation systems
  • I understand the LogRecord pipeline: Logger → Handler → Formatter → output

Continue to pathlib →

Sources

1
Python Standard Library — logging. docs.python.org/3/library/logging.html.
2
Python logging HOWTO. docs.python.org/3/howto/logging.html.
3
Python logging Cookbook. docs.python.org/3/howto/logging-cookbook.html.
4
Python Standard Library — logging.handlers. docs.python.org/3/library/logging.handlers.html.
Source confidence: High Last verified: Primary source: docs.python.org/3/library/logging.html

Sources

1
Python Standard Library — logging. docs.python.org/3/library/logging.html.
2
Python logging HOWTO. docs.python.org/3/howto/logging.html.
3
Python logging Cookbook. docs.python.org/3/howto/logging-cookbook.html.