Logging
Every non-trivial program eventually needs to answer the question "what actually happened here?" The first instinct for most beginners is to sprinkle print() statements through the code. That works for a five-minute script, but it falls apart the moment the code runs unattended — in a web server, a scheduled job, or on a user's machine you can't watch in real time. Python's built-in logging module solves this properly: it gives you severity levels, timestamps, structured output, and the ability to route messages to files, the console, or external systems without changing a single line of the code that emits them.
A minimal example
The simplest way to get started is logging.basicConfig(), which configures the root logger for the whole process in one call.
import logging
logging.basicConfig(level=logging.INFO)
logging.debug("This won't show up (below INFO)")
logging.info("Server starting up")
logging.warning("Disk space is getting low")
logging.error("Failed to connect to database")
logging.critical("Out of memory, shutting down")INFO:root:Server starting up WARNING:root:Disk space is getting low ERROR:root:Failed to connect to database CRITICAL:root:Out of memory, shutting down
The five standard levels
Every log call is tagged with a severity level. Levels are ordered, and setting a logger's level means "show me this and anything more severe."
Level | Numeric value | When to use it |
|---|---|---|
DEBUG | 10 | Detailed diagnostic information, only useful while actively developing or investigating a bug. |
INFO | 20 | Confirmation that things are working as expected — request handled, job completed, config loaded. |
WARNING | 30 | Something unexpected happened, or a problem is looming, but the program can keep running. |
ERROR | 40 | A specific operation failed — a request could not be completed, a file could not be read. |
CRITICAL | 50 | A serious error — the program itself may be unable to continue running. |
Formatting messages
The default output is bare-bones. In practice you almost always want a timestamp, the level name, and the logger name in every line. You control this with a format string passed to basicConfig.
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logging.info("Application started")2026-07-06 09:12:03 [INFO] root: Application started
Common format fields you'll reach for: %(asctime)s (timestamp), %(levelname)s (DEBUG/INFO/etc.), %(name)s (logger name), %(module)s and %(lineno)d (where the call happened), and %(message)s (the actual text).
Logging to a file and to the console
A logger doesn't write anywhere by itself — it hands each record to one or more handlers, and each handler decides where the message goes. StreamHandler writes to the console (via stderr by default); FileHandler writes to a file. You can attach both to the same logger so messages go to both places at once, and each handler can even have its own level and format.
import logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter(
"%(asctime)s [%(levelname)s] %(name)s: %(message)s"
)
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(formatter)
file_handler = logging.FileHandler("app.log")
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
logger.addHandler(console_handler)
logger.addHandler(file_handler)
logger.debug("Connecting to database at 127.0.0.1") # file only
logger.info("Connected successfully") # file + consoleOne logger per module: `getLogger(__name__)`
Real applications are made of many modules, and you almost never want a single global logger for all of them. The standard convention is to create a module-level logger named after the module itself:
# in payments/gateway.py
import logging
logger = logging.getLogger(__name__)
def charge(amount):
logger.info("Charging %s", amount)
...Because __name__ is "payments.gateway" inside that file, the logger name shows exactly where each message came from, and the logging configuration can target specific modules — for example, turning on DEBUG output only for payments.* while leaving everything else at INFO — without touching the calling code at all.
Loggers form a hierarchy based on dotted names —
"payments"is the parent of"payments.gateway".Configure handlers and levels on the root or top-level loggers; leave library/module code just calling
logger.info(...)etc.Always prefer
logger.info("Charging %s", amount)over an f-string — the formatting only happens if the message is actually going to be emitted.