Structured Logging in Python

A Practical Guide to the logging Module

Photo by Markus Spiske on Unsplash

print() is every developer’s first debugging tool. It’s fast, obvious, and gets the job done when you’re writing a quick script. But as your Python applications grow whether you’re building background workers, APIs, or data pipelines you quickly hit its limits. print() has no severity levels, no timestamps, no way to route messages to different destinations, and no way to silence it without changing your code.

Python’s built-in logging module solves all of these problems. It gives you a fully featured, production-ready logging system without installing anything. Yet many developers either reach for third-party libraries immediately or, worse, wire it up incorrectly and wonder why their log output behaves unpredictably.

This guide walks you through the logging module from the ground up covering loggers, handlers, formatters, and log levels so you can instrument your Python code the right way.

Understanding the Five Log Levels

Before writing a single line of logging code, you need to understand the severity hierarchy that logging uses to filter messages. From lowest to highest severity: DEBUG (detailed diagnostic information, useful during development), INFO (confirmation that things are working as expected), WARNING (something unexpected happened, but the program is still running), ERROR (a serious problem; the program could not perform a specific function), and CRITICAL (a severe error; the program itself may be unable to continue).

Each level maps to an integer value (DEBUG=10, INFO=20, WARNING=30, ERROR=40, CRITICAL=50). When you set a logger’s level, it silently discards any message below that threshold. This is the mechanism that lets you write verbose debug logging in development and suppress it entirely in production without removing a single log call.

Here is a minimal example showing all five levels:

import logging

logging.basicConfig(level=logging.DEBUG)

logging.debug("Starting data load")
logging.info("Loaded 1,024 records")
logging.warning("Record count lower than expected")
logging.error("Failed to connect to remote endpoint")
logging.critical("Disk quota exceeded — aborting")

Expected output:

DEBUG:root:Starting data load
INFO:root:Loaded 1,024 records
WARNING:root:Record count lower than expected
ERROR:root:Failed to connect to remote endpoint
CRITICAL:root:Disk quota exceeded — aborting

The “root” in each line is the name of the default logger. You will replace this with a named logger in the next section.

Loggers, Handlers, and Formatters

The logging module is built around three core components that work together. Loggers are the entry point you call them in your code to emit log records. Each logger has a name forming a dot-separated hierarchy (e.g., myapp, myapp.db). Handlers determine where records go: the console, a file, a network socket, etc. A single logger can have multiple handlers. Formatters control what each log line looks like, letting you include timestamps, levels, logger names, line numbers, and more.

Here is a practical setup that writes DEBUG and above to the console, and WARNING and above to a file:

import logging

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

console_handler = logging.StreamHandler()
console_handler.setLevel(logging.DEBUG)

file_handler = logging.FileHandler("app.log")
file_handler.setLevel(logging.WARNING)

formatter = logging.Formatter(
fmt="%(asctime)s [%(levelname)-8s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
console_handler.setFormatter(formatter)
file_handler.setFormatter(formatter)

logger.addHandler(console_handler)
logger.addHandler(file_handler)

logger.debug("Cache miss — fetching from database")
logger.info("Query executed in 42ms")
logger.warning("Response time exceeded threshold")
logger.error("Null value in required field 'user_id'")

Expected output (console):

2026-06-01 10:00:00 [DEBUG   ] myapp: Cache miss — fetching from database
2026-06-01 10:00:00 [INFO ] msyapp: Query executed in 42ms
2026-06-01 10:00:00 [WARNING ] myapp: Response time exceeded threshold
2026-06-01 10:00:00 [ERROR ] myapp: Null value in required field 'user_id'

The file app.log would only contain the WARNING and ERROR lines. Notice the %-8s format specifier it left-aligns the level text and pads it to 8 characters, keeping all log lines neatly aligned.

Using Logger Hierarchies in Real Applications

When you call logging.getLogger(“myapp.db”), you get a child logger of myapp. If you have not configured myapp.db explicitly, it inherits the level and handlers of its parent. This means you can give every module its own named logger, configure them all from a single place, and selectively raise or lower verbosity for specific subsystems without touching your application logic.

Here is an example with three subsystem loggers, where only the database logger runs at DEBUG while the others default to INFO:

import logging

logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%H:%M:%S"
)

db_logger = logging.getLogger("myapp.db")
auth_logger = logging.getLogger("myapp.auth")
api_logger = logging.getLogger("myapp.api")

db_logger.setLevel(logging.DEBUG)

db_logger.debug("Opening connection pool")
db_logger.info("Connection pool ready (size=10)")
auth_logger.info("User 'alice' authenticated successfully")
api_logger.warning("Rate limit approaching for client 192.168.1.5")
auth_logger.error("Invalid token presented by client 10.0.0.2")

Expected output:

10:00:00 [DEBUG] myapp.db: Opening connection pool
10:00:00 [INFO] myapp.db: Connection pool ready (size=10)
10:00:00 [INFO] myapp.auth: User 'alice' authenticated successfully
10:00:00 [WARNING] myapp.api: Rate limit approaching for client 192.168.1.5
10:00:00 [ERROR] myapp.auth: Invalid token presented by client 10.0.0.2

The myapp.db logger emits its DEBUG message even though the root logger is set to INFO. The child logger’s explicit level takes precedence, while myapp.auth and myapp.api inherit INFO from the parent.

Logging Exceptions with Full Tracebacks

When something goes wrong, you usually want more than just an error message you want the full stack trace. The logging module makes this easy with the exc_info parameter, or more conveniently, by calling logger.exception() from inside an except block. This method always logs at ERROR level and automatically appends the current exception’s traceback.

Here is an example where a missing configuration key is caught and logged with its full traceback:

import logging

logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%H:%M:%S"
)

logger = logging.getLogger("myapp.parser")

def parse_config(data: dict) -> float:
return float(data["timeout"])

config = {"host": "localhost"}

try:
timeout = parse_config(config)
except KeyError:
logger.exception("Failed to parse configuration — required key missing")

Expected output:

10:00:00 [ERROR] myapp.parser: Failed to parse configuration — required key missing
Traceback (most recent call last):
File "example.py", line 16, in <module>
timeout = parse_config(config)
File "example.py", line 11, in parse_config
return float(data["timeout"])
KeyError: 'timeout'

The logging module is one of the most mature and feature-complete parts of the Python standard library, yet many developers either skip it or misconfigure it and end up fighting their own log output. Once you understand the three-part architecture loggers route messages, handlers deliver them, formatters shape them everything else falls into place naturally.

For intermediate developers, the most important habits to build are these: always use named loggers (getLogger(__name__)) instead of the root logger directly; set levels on handlers as well as loggers for fine-grained control; and use logger.exception() inside except blocks so tracebacks are never silently swallowed.

There is more to explore rotating file handlers (logging.handlers.RotatingFileHandler), JSON-formatted log output for log aggregation systems, and logging.config.dictConfig for declarative configuration but the patterns covered here will serve you well in the vast majority of real-world Python applications.


Structured Logging in Python was originally published in ScriptSerpent on Medium, where people are continuing the conversation by highlighting and responding to this story.

Scroll to Top