Log Messages with the Python `logging` Module

Learn how to use Python's logging module to add structured, leveled log messages to your applications instead of relying on print statements.

7 min read

The logging module in the Python standard library provides a flexible framework for recording diagnostic messages from your application, whether it runs as a small script or a large production service. Unlike print(), which always outputs to the console, logging lets you control message severity, add timestamps, route output to files, and change verbosity without editing code.

pythonpython
import logging
 
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
 
logging.debug("This will not appear.")
logging.info("Server started on port 8080.")
logging.warning("Disk space below 10%.")
logging.error("Failed to connect to database.")

Four calls are made but only three lines print, since the debug message falls below the configured INFO threshold and is silently dropped:

texttext
INFO: Server started on port 8080.
WARNING: Disk space below 10%.
ERROR: Failed to connect to database.

basicConfig(level=logging.INFO) sets the minimum severity to INFO. DEBUG messages are suppressed. Each message gets the level prefix defined by the format string; add %(asctime)s to the format if you also want a timestamp.

Log levels

Choosing the right severity for each message is what makes logging useful later, since it lets you filter noise without deleting anything. Logging defines five standard severity levels, and you control which levels appear by setting the threshold.

pythonpython
import logging
 
logging.basicConfig(level=logging.DEBUG)
 
logging.debug("Variable x = 42")
logging.info("Processing file data.csv")
logging.warning("Config file missing, using defaults")
logging.error("Cannot open database connection")
logging.critical("Out of memory, shutting down")

With the threshold set to DEBUG, every level passes through and prints, using the default format that includes the level name and logger name:

texttext
DEBUG:root:Variable x = 42
INFO:root:Processing file data.csv
WARNING:root:Config file missing, using defaults
ERROR:root:Cannot open database connection
CRITICAL:root:Out of memory, shutting down
LevelWhen to use
DEBUGDetailed information for diagnosing problems during development
INFOConfirmation that things are working as expected
WARNINGSomething unexpected happened, but the program can continue
ERRORA serious problem that prevented a function from working
CRITICALA fatal error that may prevent the program from continuing

Set the level to DEBUG during development, INFO or WARNING in production, so you get rich detail while building the feature and a quieter signal once it ships. You can change it without modifying code by reading a config value or environment variable.

Formatting log messages

Control the output format with format in basicConfig(). The format string supports placeholders for time, level, logger name, message, and more.

pythonpython
import logging
 
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)-8s] %(name)s: %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)
 
logging.info("Application started")  # 2026-07-12 14:30:05 [INFO    ] root: Application started
PlaceholderDescription
%(asctime)sHuman-readable timestamp
%(levelname)sLog level (DEBUG, INFO, etc.)
%(name)sLogger name
%(message)sThe log message
%(filename)sSource filename
%(lineno)dSource line number
%(funcName)sFunction name

The -8 in [%(levelname)-8s] left-aligns the level name to 8 characters, keeping columns aligned regardless of level name length, so a short "INFO" and a longer "WARNING" both start their message text at the same position.

Logging to a file

Pass filename to basicConfig() to write logs to a file instead of the console.

pythonpython
import logging
 
logging.basicConfig(
    filename="app.log",
    level=logging.INFO,
    format="%(asctime)s %(levelname)s: %(message)s",
)
 
logging.info("This goes to app.log, not the console.")

All log messages are written to app.log. The console shows nothing at all, since basicConfig only sends output to one destination at a time. To log to both console and file simultaneously, add multiple handlers instead of relying on the filename shortcut.

Using multiple handlers

basicConfig only sets up one destination at a time, which is not enough once you need different output going to different places at different levels. For advanced setups, configure handlers explicitly instead of using basicConfig().

pythonpython
import logging
 
logger = logging.getLogger("myapp")
logger.setLevel(logging.DEBUG)
 
console = logging.StreamHandler()
console.setLevel(logging.WARNING)
console.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
 
file_handler = logging.FileHandler("debug.log")
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))

With both handlers configured, attach them to the logger and log a couple of messages at different levels to see how each handler filters independently:

pythonpython
logger.addHandler(console)
logger.addHandler(file_handler)
 
logger.debug("Debug detail, file only.")
logger.warning("Warning, console and file.")

Console output, which only shows WARNING and above because of the console handler's own level, regardless of the logger's overall DEBUG threshold:

texttext
WARNING: Warning, console and file.

File output (debug.log), which captures both messages since the file handler's own level is set all the way down to DEBUG:

texttext
2026-07-12 14:30:05,123 [DEBUG] Debug detail, file only.
2026-07-12 14:30:05,124 [WARNING] Warning, console and file.

Each handler has its own level and format. The console shows only WARNING and above. The file captures everything from DEBUG upward.

Named loggers

Use logging.getLogger(name) to create loggers named after the current module instead of relying only on the shared root logger. This follows the Python module hierarchy and lets you control verbosity per module, which matters once your project grows past a single script.

pythonpython
import logging
 
logger = logging.getLogger(__name__)
 
def process_data(filename):
    logger.info("Processing %s", filename)
    logger.debug("Opening file handle...")

The %s placeholder in logger.info("Processing %s", filename) is lazy: the string formatting only happens if the message is actually logged. This is more efficient than f-strings, which always format even when the level suppresses the message.

Configuring logging from a file

Use logging.config.fileConfig() or logging.config.dictConfig() to load logging configuration from a file.

pythonpython
import logging
import logging.config
 
LOGGING_CONFIG = {
    "version": 1,
    "formatters": {"default": {"format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s"}},
    "handlers": {
        "console": {"class": "logging.StreamHandler", "level": "INFO", "formatter": "default"},
        "file": {"class": "logging.FileHandler", "filename": "app.log", "level": "DEBUG", "formatter": "default"},
    },
    "root": {"level": "DEBUG", "handlers": ["console", "file"]},
}

The dictionary mirrors the same formatters, handlers, and levels you would otherwise configure with code, but it can be loaded from JSON or YAML instead:

pythonpython
logging.config.dictConfig(LOGGING_CONFIG)
logging.info("Configured from dict")

This keeps logging setup out of your application code and makes it easy to change without a code edit, since ops teams can update the configuration file directly.

Practical example: logging in a data processing script

Add logging to a script that reads CSV and writes JSON.

pythonpython
import csv
import json
import logging
from pathlib import Path
 
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    datefmt="%H:%M:%S",
)
logger = logging.getLogger(__name__)

With logging configured, the conversion function logs its progress at each stage, from reading the input file to writing the final output:

pythonpython
def convert_csv_to_json(input_path, output_path):
    logger.info("Reading %s", input_path)
    with open(input_path, newline="") as file:
        reader = csv.DictReader(file)
        rows = list(reader)
    logger.info("Read %d rows", len(rows))
    logger.debug("Columns: %s", reader.fieldnames)
    with open(output_path, "w") as file:
        json.dump(rows, file, indent=2)
    size = Path(output_path).stat().st_size
    logger.info("Wrote %s (%d bytes)", output_path, size)

Calling the function logs progress at each step of the conversion, from reading the source file to writing the final byte count:

pythonpython
convert_csv_to_json("data.csv", "output.json")

Each INFO line marks a milestone in the conversion, while the DEBUG line with the column names stays hidden at this INFO threshold:

texttext
14:30:05 [INFO] Reading data.csv
14:30:05 [INFO] Read 150 rows
14:30:05 [INFO] Wrote output.json (45231 bytes)

The script logs key events at INFO level and details at DEBUG level. In production, set the level to WARNING to suppress everything except problems.

Common mistakes

Using print() for diagnostics in production code. print() output cannot be filtered, timestamped, or redirected without shell tricks, and there is no way to turn it off without editing the code. Use logging for any code that runs outside a quick one-off script.

Logging sensitive data. Log messages can end up in files, log aggregation services, and error reports, often retained far longer and shared more widely than the original code author expects. Never log passwords, API keys, personal data, or authentication tokens.

Using f-strings with logging calls. logging.info(f"Processing {filename}") always formats the string immediately, even when DEBUG messages are suppressed and the result is thrown away. Use logging.info("Processing %s", filename) for lazy formatting instead.

Calling basicConfig more than once. Only the first call to basicConfig() has any effect; later calls are silently ignored. For complex setups, configure handlers directly or use dictConfig instead of relying on repeated basicConfig calls.

Rune AI

Rune AI

Key Insights

  • Use logging.basicConfig() for quick setup with level, format, and output destination.
  • Log levels, from lowest to highest: DEBUG, INFO, WARNING, ERROR, CRITICAL.
  • Use logging.getLogger(__name__) for module-level loggers in larger projects.
  • Add logging.FileHandler() and logging.StreamHandler() to send logs to multiple destinations.
  • Use lazy formatting with %s placeholders: logging.info('Processing %s', filename).
  • Set the root logger level to control global verbosity.
RunePowered by Rune AI

Frequently Asked Questions

Why use logging instead of print()?

Logging lets you control the severity level (DEBUG, INFO, WARNING, ERROR, CRITICAL) and route messages to different destinations (console, file, network). You can change the verbosity without editing code, add timestamps automatically, and suppress debug messages in production without removing them.

How do I log to both the console and a file?

Add multiple handlers to the root logger or your custom logger. Use `logging.StreamHandler()` for console output and `logging.FileHandler('app.log')` for file output. Each handler can have its own level and format.

Should I use the root logger or create named loggers?

For scripts, `logging.basicConfig()` and the root logger are fine. For modules and libraries, create named loggers with `logging.getLogger(__name__)`. This lets you control verbosity per module and avoids interfering with other libraries' logging configuration.

Conclusion

The logging module gives you fine control over diagnostic output in Python. Use logging.basicConfig() for simple scripts and named loggers with getLogger(__name__) for larger applications. Choose the right level for each message: DEBUG for development, INFO for key events, WARNING for potential problems, and ERROR for failures.