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.
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:
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.
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:
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| Level | When to use |
|---|---|
| DEBUG | Detailed information for diagnosing problems during development |
| INFO | Confirmation that things are working as expected |
| WARNING | Something unexpected happened, but the program can continue |
| ERROR | A serious problem that prevented a function from working |
| CRITICAL | A 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.
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| Placeholder | Description |
|---|---|
| %(asctime)s | Human-readable timestamp |
| %(levelname)s | Log level (DEBUG, INFO, etc.) |
| %(name)s | Logger name |
| %(message)s | The log message |
| %(filename)s | Source filename |
| %(lineno)d | Source line number |
| %(funcName)s | Function 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.
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().
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:
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:
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:
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.
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.
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:
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.
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:
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:
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:
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
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()andlogging.StreamHandler()to send logs to multiple destinations. - Use lazy formatting with
%splaceholders:logging.info('Processing %s', filename). - Set the root logger level to control global verbosity.
Frequently Asked Questions
Why use logging instead of print()?
How do I log to both the console and a file?
Should I use the root logger or create named loggers?
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.
More in this topic
Format Dates and Times in Python
Learn how to use strftime and strptime to format dates as strings and parse date strings into Python datetime objects.
What Is the Python Standard Library?
Learn what the Python standard library is, why it matters, and how to use its built-in modules without installing anything extra.
Encode and Decode Base64 Data in Python
Learn how to use Python's base64 module to encode binary data as text and decode Base64 strings back to bytes.