Python logging module

Date Wed 03 June 2026 By Emmanuel Fleury Category programming Tags python / logging

Most Python beginners reach for print() to communicate with users, but this is widely considered bad practice, especially in larger projects. The recommended alternative is the logger design pattern, and Python ships a fully-featured logging module in its standard library to implement it.

Why use a logger?

Using print() forces you to repeat formatting decisions everywhere and makes them hard to change consistently. A logger centralises all of that. Here is what you gain:

  • Separation of concerns: Your modules simply call logger.info(); where that output goes and how it is formatted is decided exclusively in __main__.py.
  • Uniformity of logs: Structured log levels (DEBUG, INFO, WARNING, ERROR, CRITICAL) give you a consistent vocabulary, letting you reconstruct what happened in production without reproducing the issue locally.
  • Modularity: Each module gets its own named logger (logging.getLogger("myapp.my_module")), so you can silence or increase verbosity per module independently.
  • Open/Closed principle: Add new handlers or formatters without ever modifying existing modules.

In short: logging decouples what is reported from how it is reported, the same idea behind most good software architecture.

What is a logger?

The logging module combines two classic design patterns:

  • Singleton: logging.getLogger("myapp") always returns the same instance for a given name, no matter how many times or from where it is called. This is why you never need to pass the logger around.
  • Chain of Responsibility: Once the logger accepts a record, it passes it to each handler in turn. Each handler independently decides whether to process it based on its level filter.
          logging.getLogger("myapp")   <-- Singleton: always the same instance
                    │
                    v
  stdout <-- [stdout_handler]          <-- Chain of Responsibility: each handler
  stderr <-- [stderr_handler]              decides independently what to do
  file   <-- [file_handler]

And, if you have to handle a hierarchy of loggers, the propagation follows the same chain:

"myapp.my_module" --> "myapp" --> root logger
           \             |             |
            \------------+-------------+-----> [stdout_handler] ---> stdout
                                         \---> [stderr_handler] ---> stderr
                                          \--> [file_handler]   ---> file

The logging Python module

Designed by Vinay Sajip and part of the standard library since Python 2.3 (2003), the module provides:

  • Five standard log levels: DEBUG (10), INFO (20), WARNING (30), ERROR (40), CRITICAL (50).

    For reference, here are the six levels defined in the logging module:

    Level Value Meaning
    logging.NOTSET 0 Undefined (avoid using this)
    logging.DEBUG 10 Detailed diagnostic information
    logging.INFO 20 Normal operational messages
    logging.WARNING 30 Harmless but noteworthy situation
    logging.ERROR 40 A handled but harmful error
    logging.CRITICAL 50 An unexpected, harmful error (likely a bug)
  • Logger hierarchy: dot-separated names ("myapp.module") that propagate records up to parent loggers for extensibility.

  • Handlers: As stated in the previous section, you need to have several output handlers in your chain of responsibility, so you have access to several ones: StreamHandler, FileHandler, RotatingFileHandler, SysLogHandler, SMTPHandler, etc.
  • Formatters: customizable record formatting with %, {}, or $ style strings.
  • Filters: fine-grained control over which records a handler or logger processes (see in the boilerplate code).
  • Thread safety: handled internally via threading locks, no extra work required.

Boilerplate code

import argparse
import logging
import sys

# Custom VERBOSE level (between DEBUG=10 and INFO=20)
VERBOSE = 15
logging.addLevelName(VERBOSE, "VERBOSE")

def verbose(self, message, *args, **kwargs):
    if self.isEnabledFor(VERBOSE):
        # pylint: disable=protected-access
        self._log(VERBOSE, message, args, **kwargs)

logging.Logger.verbose = verbose


class CustomFormatter(logging.Formatter):
    """Changes format based on log level."""

    def __init__(self, fmt="%(levelno)s: %(msg)s") -> None:
        logging.Formatter.__init__(self, fmt)

    def format(self, record) -> str:
        if record.levelno == logging.DEBUG:
            fmt = "debug: %(msg)s"
        elif record.levelno in (VERBOSE, logging.INFO):
            fmt = "%(msg)s"
        elif record.levelno == logging.WARNING:
            fmt = "warning: %(msg)s"
        elif record.levelno == logging.ERROR:
            fmt = "error: %(msg)s"
        elif record.levelno == logging.CRITICAL:
            fmt = "critical: %(module)s: %(lineno)d: %(msg)s"
        else:
            fmt = self._fmt
        return logging.Formatter(fmt).format(record)


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Boilerplate example for logging usage."
    )
    parser.add_argument(
        "--verbose", "-v", action="store_true", help="Show verbose output"
    )
    args = parser.parse_args()

    logger = logging.getLogger("__logger__")
    logger.setLevel(VERBOSE if args.verbose else logging.INFO)

    fmt = CustomFormatter()

    # INFO/WARNING/VERBOSE/DEBUG --> stdout; ERROR/CRITICAL --> stderr
    stdout_handler = logging.StreamHandler(sys.stdout)
    stdout_handler.setFormatter(fmt)
    stdout_handler.addFilter(lambda record: record.levelno < logging.ERROR)

    stderr_handler = logging.StreamHandler(sys.stderr)
    stderr_handler.setFormatter(fmt)
    stderr_handler.addFilter(lambda record: record.levelno >= logging.ERROR)

    logger.addHandler(stdout_handler)
    logger.addHandler(stderr_handler)

    logger.info("Message from logger") # Message from logger
    logger.verbose("Verbose message")  # Verbose message (only with -v)
    logger.debug("Debug message")      # debug: Debug message
    logger.warning("Watch out")        # warning: Watch out
    logger.error("Something broke")    # error: Something broke
    logger.critical("Critical error")  # critical: <module>: <line>: Critical error

if __name__ == "__main__":
    main()

Custom formatter

The default formatter prefixes every message with its level name (e.g. INFO:), which looks noisy. CustomFormatter maps each level to a tailored format. Note that CRITICAL includes the module name and line number, the user is expected to report this information to the developer.

class CustomFormatter(logging.Formatter):
    """Changes format based on log level."""

    def __init__(self, fmt="%(levelno)s: %(msg)s") -> None:
        logging.Formatter.__init__(self, fmt)

    def format(self, record) -> str:
        if record.levelno == logging.DEBUG:
            fmt = "debug: %(msg)s"
        elif record.levelno in (VERBOSE, logging.INFO):
            fmt = "%(msg)s"
        elif record.levelno == logging.WARNING:
            fmt = "warning: %(msg)s"
        elif record.levelno == logging.ERROR:
            fmt = "error: %(msg)s"
        elif record.levelno == logging.CRITICAL:
            fmt = "critical: %(module)s: %(lineno)d: %(msg)s"
        else:
            fmt = self._fmt
        return logging.Formatter(fmt).format(record)

Then, we set this formatter on each handler like this:

fmt = CustomFormatter()
...
handler.setFormatter(fmt)

stdout/stderr split

Two handlers with lambda filters cleanly route informational messages to stdout and errors to stderr, which is the Unix convention.

    # INFO/WARNING/DEBUG → stdout; ERROR/CRITICAL → stderr
    stdout_handler = logging.StreamHandler(sys.stdout)
    stdout_handler.setFormatter(fmt)
    stdout_handler.addFilter(lambda record: record.levelno < logging.ERROR)

    stderr_handler = logging.StreamHandler(sys.stderr)
    stderr_handler.setFormatter(fmt)
    stderr_handler.addFilter(lambda record: record.levelno >= logging.ERROR)

    logger.addHandler(stdout_handler)
    logger.addHandler(stderr_handler)

Add a Custom VERBOSE level

Since the standard library has no VERBOSE level, we add one at value 15 (between DEBUG and INFO). This lets you hide extra detail by default (INFO threshold) and expose it on demand with -v.

# Custom VERBOSE level (between DEBUG=10 and INFO=20)
VERBOSE = 15
logging.addLevelName(VERBOSE, "VERBOSE")

def verbose(self, message, *args, **kwargs):
    if self.isEnabledFor(VERBOSE):
        # pylint: disable=protected-access
        self._log(VERBOSE, message, args, **kwargs)

logging.Logger.verbose = verbose

Using the logger from other modules

The named logger "__logger__" acts as a shared singleton. Any module can retrieve it with a single line, no setup duplication needed:

import logging

logger = logging.getLogger("__logger__")
logger.info("Message from another module")

Final words

This boilerplate covers most common needs and is easy to extend: add handlers, formatters, filters, or a full hierarchical logger structure as your project grows. The logging module can feel overwhelming at first, but understanding the two patterns underneath it, Singleton and Chain of Responsibility, makes everything click into place.

The boilerplate code also give you a solid starting point to build on more standard ground that the default configuration for UNIX command-line tools. So, feel free to copy-paste it and adapt it to your needs into your projects.

Happy logging!

References