Python logging module
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
loggingmodule:Level Value Meaning logging.NOTSET0 Undefined (avoid using this) logging.DEBUG10 Detailed diagnostic information logging.INFO20 Normal operational messages logging.WARNING30 Harmless but noteworthy situation logging.ERROR40 A handled but harmful error logging.CRITICAL50 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
- Python logging documentation.
- Logging HOWTO by Vinay Sajip (original author of this module).
- Logging in Python by Philipp Acsany, Oct 29, 2025.