X-Ray Vision: Logging in Python

Most Python developers first notice the need for a robust logging system when simple print() statements become cluttered and fail to provide a coherent view of application behavior. In larger systems, especially in DevOps environments, the stakes are even higher. You need clarity about how the code is running, so you can diagnose bugs, monitor performance, and confirm that everything is working as intended. Python’s built-in logging module is an excellent way to achieve that clarity, but it does require some setup and understanding.

This post explores the essentials of Python logging, including:

  • Root loggers and why you might not want to stick with them

  • Flexible logging formats

  • How to set the appropriate log levels, plus creating custom levels

  • Examples for audiences familiar with Python and looking to improve observability skills

When you implement logging in a systematic way, it’s like having X-ray vision into your application. You’ll see exactly what’s going on and gain the power to spot issues before they become showstoppers. Let’s dive in.

Good logging gives you x-ray vision into your Python code

Good logging gives you x-ray vision into your Python code.

Where It All Starts: The Python Logging Mental Model

The logging module revolves around a few key concepts:

  • Logger: The entry point for emitting log messages. You create or retrieve one with a call to logging.getLogger(name).

  • Handlers: Determine where log messages go, such as to a console, a file, or a remote logging server.

  • Formatters: Define how log records look, including timestamps and metadata.

  • Levels: Indicate the importance or severity of each log message (DEBUG, INFO, WARNING, ERROR, CRITICAL).

When you emit a log message, the logger checks if that message meets or exceeds the logger’s threshold level, then passes it to one or more handlers, which format and deliver the message to the desired output. Because you can configure all these pieces independently, you can have multiple loggers for different parts of your application, multiple handlers that write to different places, and custom formats for each output.

From a DevOps standpoint, this is ideal. A microservice can write INFO or DEBUG logs to a file for deep analysis but send ERROR logs to a pager or monitoring system. Since logs are a cornerstone of observability (along with metrics and traces), having flexible configuration is crucial for diagnosing issues in real-world systems.

Spotting the Root Logger

When you import Python’s logging module and call something like logging.debug("Hello from root logger"), you’re using the root logger. By default, the root logger is set to WARNING. That’s why a quick test with logging.debug("You won’t see me") produces no output. If you call:

import logging

logging.debug("This debug message won’t appear by default")
logging.info("Nor will this info message")
logging.warning("This is a warning message")

You’ll only see:

WARNING:root:This is a warning message

on your console. The WARNING threshold filters out DEBUG and INFO.

Configuring the Root Logger

For small scripts, you can configure the root logger with logging.basicConfig(). For example:

import logging

logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s [%(levelname)s] %(message)s"
)

logging.debug("Now you'll see me")
logging.info("And this info message too")

Here, level=logging.DEBUG means all messages at DEBUG level or above will appear. The format string includes a timestamp (asctime), the log level (levelname), and your actual message (message).

Why Named Loggers Are Better

Although the root logger can suffice for smaller projects, most larger applications or multi-module codebases benefit from named loggers. These let you label logs according to their source module or functionality:

logger = logging.getLogger("my_module")
logger.setLevel(logging.INFO)

Any log you emit from logger is tagged with "my_module". In a DevOps environment with multiple modules and microservices, it’s far easier to debug if each log entry includes the component or service name. If the project grows and you decide to redirect logs from a specific module to a particular handler, it’s straightforward with named loggers.

Building Your Own Logging Format

The format of your logs is more than an aesthetic choice. The way you structure log lines can make them easier to filter, search, and parse. Python allows you to include placeholders that map to different attributes of the log record, such as:

  • %(asctime)s: Timestamp

  • %(name)s: Logger name

  • %(levelname)s: Log level (DEBUG, INFO, etc.)

  • %(message)s: The actual message text

  • %(pathname)s: The file path of the source code

  • %(lineno)d: The line number in the source code

Here’s an example:

import logging

logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s [%(levelname)s] %(name)s:%(lineno)d - %(message)s"
)

logging.debug("Initializing the app")

A log line might look like:

2025-04-04 12:15:32,045 [DEBUG] root:5 - Initializing the app

Notice the timestamp, level, logger name, line number, and message. In a production environment, having timestamps and contextual data is invaluable for diagnosing issues.

Setting the Appropriate Log Levels

Python has five built-in levels to indicate severity or importance:

  1. DEBUG (10): Detailed information for diagnosing problems.

  2. INFO (20): Confirmation that things are working as expected.

  3. WARNING (30): An indication that something unexpected happened, but the program can continue.

  4. ERROR (40): A more serious issue that prevented some part of the program from running.

  5. CRITICAL (50): A severe error that may stop the application entirely.

If you set a logger’s level to WARNING, for instance, then any logs at INFO or DEBUG will be ignored. This is essential in production systems because you generally want fewer logs, focusing on warnings and errors, while in development you want more verbosity.

Example: A Simple Logger with a Level

import logging

logger = logging.getLogger("my_app")
logger.setLevel(logging.INFO)

logger.debug("You won't see this")
logger.info("This is an info message")
logger.warning("This is a warning message")

You’ll see only:

This is an info message
This is a warning message

The debug message is hidden because the level is set to INFO.

Creating and Using Custom Levels

The built-in five levels work well for many cases, but sometimes you need a specialized event. Python allows you to add custom levels using integer values. Behind the scenes, levels map to numeric codes. For example, DEBUG is 10 and INFO is 20. You can pick another number and define a custom level:

import logging

SECURITY_AUDIT = 25
logging.addLevelName(SECURITY_AUDIT, "SECURITY_AUDIT")

def security_audit_log(self, message, *args, **kwargs):
    if self.isEnabledFor(SECURITY_AUDIT):
        self._log(SECURITY_AUDIT, message, args, **kwargs)

logging.Logger.security_audit = security_audit_log

logger = logging.getLogger("security_logger")
logger.setLevel(logging.INFO)

logger.security_audit("User updated their password")

Here, SECURITY_AUDIT sits between INFO (20) and WARNING (30). If the logger’s threshold is INFO, you’ll see SECURITY_AUDIT messages. This can be incredibly useful if, for example, you want a separate handler for all audit-related events.

Wrangling Handlers for Multiple Outputs

A big advantage of Python logging is the ability to attach multiple handlers to a single logger. Suppose you want to:

  • Write all logs (DEBUG or higher) to a file.

  • Send warning and error logs to your console for immediate visibility.

You could do:

import logging

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

# File handler for all messages
file_handler = logging.FileHandler("app.log")
file_handler.setLevel(logging.DEBUG)
file_format = logging.Formatter(
    "%(asctime)s [%(levelname)s] %(name)s:%(lineno)d - %(message)s"
)
file_handler.setFormatter(file_format)

# Console handler for warnings and above
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.WARNING)
console_format = logging.Formatter("%(levelname)s - %(message)s")
console_handler.setFormatter(console_format)

# Add handlers
logger.addHandler(file_handler)
logger.addHandler(console_handler)

logger.debug("Debug info, will appear in file only")
logger.warning("Warning, will appear in file and console")
logger.error("Error, will appear in both too")

When you run this:

  • The debug message goes to app.log but not to the console.

  • The warning and error messages appear in both places.

In DevOps scenarios, you can forward logs to specialized aggregators (like Fluentd or Logstash), or you can have a handler that sends only errors and critical messages to PagerDuty or Slack.

Practical Logging Patterns in DevOps

Distributed Logging

When your application is spread across multiple servers or containers, collecting logs in a single place is essential. You can configure Python handlers to send logs via HTTP to a central server, or rely on Docker or Kubernetes logging drivers that forward logs automatically. Tools like Elastic Stack and Splunk help you store, visualize, and search logs from multiple services.

Log Rotation and Retention

Log files can grow huge over time. Python includes rotating handlers, such as RotatingFileHandler or TimedRotatingFileHandler, to automatically rotate logs. You can limit them by size (e.g., 5 MB) or time (e.g., once per day), keeping only a specified number of backups.

JSON or Other Structured Formats

Instead of raw text, you can output logs as JSON. This makes them easier to parse in search-based logging tools. By storing logs in a structured format, you can query them by specific fields (like log level, timestamp ranges, user ID, and so on).

Sensitive Data Redaction

Logs might contain credentials or personal data. In a DevOps context, it is critical to sanitize or redact logs as necessary. You can build filters into your logging configuration that remove or hash sensitive strings before they’re written to disk or sent over the network.

Example: Rotating File Handler

from logging.handlers import RotatingFileHandler
import logging

logger = logging.getLogger("rotating_example")
logger.setLevel(logging.INFO)

handler = RotatingFileHandler(
    "myapp.log",
    maxBytes=1024 * 1024 * 5,  # 5 MB
    backupCount=5
)
formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
handler.setFormatter(formatter)

logger.addHandler(handler)

for i in range(100000):
    logger.info(f"Log line {i}")

Once myapp.log hits 5 MB, it’s rotated out and renamed, then a new file is started. Old logs are retained up to the specified backup count.

Tuning Your Observability

Logging is just one piece of observability, which also includes metrics and tracing. Here are a few best practices:

  • Correlate logs with request IDs or user IDs: If you add an identifier to each log line, you can track a single request or user journey through multiple services.

  • Use environment-specific log levels: In development, you might set DEBUG to see everything. In production, you might set WARNING or ERROR to reduce noise.

  • Integrate with monitoring tools: Route logs to services like Datadog or Elasticsearch and pair them with dashboards that show real-time charts. This is crucial for understanding what’s happening across your ecosystem.

For advanced configuration options, check out the official Python Logging HOWTO. You’ll see more details on capturing warnings, configuring logger hierarchies, and controlling logs using config files.

Bringing Observability to AI-Generated Code

Many teams have started using AI-driven tools to help generate Python code. These tools can save time by sketching out functions and modules quickly, but the trade-off is that you might not fully understand every decision made by the AI. It can be tricky to figure out which lines are absolutely correct or where hidden bugs might lurk. That’s where Python logging comes in:

1. Verifying AI-Generated Logic

Once you copy an AI-suggested block of code into your application, add DEBUG or INFO logs around key functions to confirm the flow is doing what you (and the AI) expect. For instance, if the AI script is handling file uploads, log the file path, the bytes processed, or the time taken. This layer of “living documentation” gives you deeper visibility into code you didn’t write yourself.

2. Identifying Hidden Assumptions

AI-generated code might rely on assumptions you didn’t explicitly state. A log message can give you a heads-up when the code hits an unexpected data type or condition. For example, if the AI tool wrote code for a dictionary lookup, you can log what the dictionary keys are before and after modification. If the logs reveal strange keys or missing data, you know the AI-based logic needs revision.

3. Using Custom Log Levels for AI Blocks

Consider defining a custom level (perhaps AI_CHECK = 23) to highlight logs that specifically verify AI-generated segments. You can route these logs to a separate file, making it simple to isolate AI-related messages. Over time, you can dial this logging up or down, depending on how reliable you find the AI output.

4. Keep It Flexible

AI-generated code tends to evolve as you refine your prompts or training data. Because Python logging is so configurable, you can easily add new handlers or update existing ones to track fresh AI-related behaviors. Whether you need more detail or less, it’s just a matter of adjusting the level and format of your logs.

By inserting log statements in places where you rely heavily on AI-generated functionality, you’ll see exactly how that code responds under real usage. Examples include data transformations, API calls, and complex conditionals. You can combine this with the broader best practices already discussed in this post (handlers, formatters, rotation policies, etc.) to get a complete picture of your application.

Trying It Yourself: A Comprehensive Example

Below is a condensed script showing various features: multiple handlers, a custom level, and rotating logs for errors.

import logging
from logging.handlers import RotatingFileHandler

# Define a custom level
DATA_EVENT = 25
logging.addLevelName(DATA_EVENT, "DATA_EVENT")

def data_event_log(self, message, *args, **kwargs):
    if self.isEnabledFor(DATA_EVENT):
        self._log(DATA_EVENT, message, args, **kwargs)

logging.Logger.data_event = data_event_log

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

# File handler for debug logs
debug_file_handler = logging.FileHandler("data_debug.log")
debug_file_handler.setLevel(logging.DEBUG)
debug_formatter = logging.Formatter(
    "%(asctime)s [%(levelname)s] %(name)s:%(lineno)d - %(message)s"
)
debug_file_handler.setFormatter(debug_formatter)
logger.addHandler(debug_file_handler)

# Console handler for errors only
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.ERROR)
console_formatter = logging.Formatter("%(levelname)s: %(message)s")
console_handler.setFormatter(console_formatter)
logger.addHandler(console_handler)

# Rotating file handler for errors
error_rotating_handler = RotatingFileHandler(
    "data_errors.log",
    maxBytes=1024*1024,
    backupCount=3
)
error_rotating_handler.setLevel(logging.ERROR)
error_rotating_handler.setFormatter(debug_formatter)
logger.addHandler(error_rotating_handler)

def process_data(records):
    logger.debug("Starting data processing")
    for record in records:
        logger.data_event(f"Transforming record ID {record['id']}")
        # Simulate an error
        if record.get("invalid"):
            logger.error(f"Error transforming record {record['id']}")
    logger.info("Data processing complete")

if __name__ == "__main__":
    sample_records = [
        {"id": 1, "data": "abc"},
        {"id": 2, "data": "def", "invalid": True},
        {"id": 3, "data": "ghi"},
    ]
    process_data(sample_records)
  • data_debug.log catches everything (DEBUG, INFO, DATA_EVENT, ERROR, etc.).

  • data_errors.log and its rotated backups capture only errors.

  • The console only displays errors.

  • DATA_EVENT messages appear in the debug log.

This is the real power of Python’s logging. You can filter logs by level, direct them to specific locations, and format them so they’re more helpful for diagnosing issues.

Common Pitfalls and How to Avoid Them

  1. Using print() Instead of logging: You lose the rich features of the logging system when you bypass it with simple prints.

  2. Ignoring Log Format: A bare message with no timestamp or context is nearly useless when something goes wrong.

  3. Excessive Debug Logs in Production: Too many logs can drain performance or fill your disk quickly. Use environment-specific configurations or levels.

  4. No Plan for Log Rotation: Without rotation, your logs will grow indefinitely. Use rotating handlers or external solutions to manage this.

  5. Not Protecting Sensitive Data: Carelessly logging passwords, tokens, or personal info can be a security risk. Redact or mask such details before they go into logs.

Links and Resources to Level Up Further

If you’re aiming for DevOps-level sophistication, you’ll probably push logs to a centralized aggregator. That way, you can analyze logs from multiple services in one location, pair them with metrics, and set up alerts.

Putting It All to Work with Caparra

You’ll be surprised at how powerful Python logging can be once it’s set up correctly. It’s like having X-ray vision into your code. Logs let you trace what happened and when, which is invaluable when diagnosing production issues at 2 am. The combination of log levels, custom events, and multiple handlers is a recipe for an efficient, maintainable, and robust DevOps practice.

And that’s just the start. There’s a ton you can do with logs if you pair them with AI-driven DevOps tools. If you’d like to get started on automating some of the repetitive tasks that come with building and maintaining modern systems, consider opening a free Caparra account. We’d love to help you lighten the burden of daily toil so you can focus on what you do best: building awesome software.

Until then, enjoy your newly upgraded logging capabilities. With the right approach to Python logging, you’ll catch problems earlier, fix them faster, and gain a real-time window into the health of your applications. Let’s keep building code that’s easy to manage and fun to maintain. Happy logging!

Previous
Previous

Why Linters Hate Unused Imports

Next
Next

The Elephant in the Room: Dealing with Legacy Code