PythonLogging

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.

Why not just use print()?
`print()` always writes to standard output, has no concept of severity, cannot be turned off selectively, and is easy to forget about and leave in production code. Once a program is deployed, you generally cannot attach a debugger to it, so logging is often the *only* window into what it is doing. `print()` gives you no control over that window; `logging` does.
A minimal example

The simplest way to get started is logging.basicConfig(), which configures the root logger for the whole process in one call.

Python
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
Note
Notice the `debug()` call produced no output. `basicConfig(level=logging.INFO)` tells the logger to ignore anything below the INFO level — this is the core mechanism that lets you leave verbose diagnostics in the code permanently without them cluttering normal output.
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.

Python
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.

Python
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 + console
Note
This is why the two handlers are useful together: the console stays readable with only INFO and above, while the log file on disk keeps the full DEBUG-level trail for when you actually need to dig into a problem later.
One 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:

Python
# 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.

Avoid a common pitfall
Calling `logging.basicConfig()` more than once has no effect after the first call in the same process (unless you pass `force=True`). Configure logging once, near the entry point of your application — not inside every module that happens to import `logging`.