Logging: the print statements you can turn off
Why print runs out
print is right for the output of a program — the answer a person asked for. It is wrong for the running commentary. Prints scattered through working code cannot be switched off without editing, cannot be filtered by importance, carry no timestamp, and all go to standard output, where they mix with the actual result. A script whose real output is a CSV on stdout is ruined by one stray print("starting").
The standard library's logging module fixes all of that and needs no installation.
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
log = logging.getLogger(__name__)
log.info("loaded %d rows from %s", len(rows), path)
log.warning("skipping row %s: no date", row_id)
log.error("upload failed after %d attempts", attempts)2026-09-04 11:02:31,884 INFO billing.load loaded 4812 rows from sales.csvThe five levels, and what each is for
DEBUG— values you want while working on it. Off in production.INFO— the program did a normal, notable thing. Started, finished, processed 4,812 rows.WARNING— something is wrong but the program continues. Skipped rows, a retry, a deprecated option.ERROR— an operation failed. The program may continue with other work.CRITICAL— the program cannot continue.
Setting level=logging.INFO prints INFO and above and silently drops DEBUG. That one line is the whole point: the debug statements stay in the code, cost nothing, and come back when you set level=logging.DEBUG for one run. Nothing is deleted, nothing is re-added at 2 a.m.
Wire it to the -v counter from the command-line lesson and a user can choose their own verbosity:
level = [logging.WARNING, logging.INFO, logging.DEBUG][min(args.verbose, 2)]One logger per module
logging.getLogger(__name__) names the logger after the module — billing.load, billing.report. That name appears in every line, so you can see where a message came from, and you can turn one noisy module down without touching the others:
logging.getLogger("urllib3").setLevel(logging.WARNING)That single line silences the connection chatter that third-party HTTP libraries produce at DEBUG level, which is otherwise thousands of lines per run.
Call basicConfig once, in the entry point — the main() function or the if __name__ == "__main__" block. Libraries should create loggers and never configure them; configuring logging inside an imported module hijacks the settings of whoever imported you.
Why the odd %s formatting
log.debug("row %s took %.2fs", row_id, elapsed) # correct
log.debug(f"row {row_id} took {elapsed:.2f}s") # works, but wastefulThe first form hands the values to the logger and formats them only if the message is actually going to be emitted. With DEBUG switched off, the f-string version still builds the string on every iteration and throws it away. In a loop over a million rows that is measurable. It is the one place in modern Python where the old formatting style is the right answer.
Recording a failure properly
try:
upload(path)
except OSError:
log.exception("upload failed for %s", path)log.exception logs at ERROR level and attaches the full traceback. It only works inside an except block. Outside one, log.error(..., exc_info=True) does the same thing. A log line saying "upload failed" without a traceback is a note that something happened, not information.
Writing to a file, and rotating it
from logging.handlers import RotatingFileHandler
handler = RotatingFileHandler("app.log", maxBytes=5_000_000, backupCount=3)
logging.basicConfig(level=logging.INFO, handlers=[handler])Four files of 5 MB, oldest deleted. Without rotation, a long-running job's log file fills the disk, and a full disk breaks every program on the machine, not only yours.
What must never go in a log
Logs get copied to other systems, read by people who are not you, and kept for years. Keep out of them:
- passwords, API keys, tokens, anything from a
.envfile - full request bodies from a service handling personal data
- identity numbers, card numbers, health details
- anything a user told you in confidence
Log an identifier instead of the content: log.info("processed message %s from user %s", message_id, user_id). If you must log part of a key for debugging, log the last four characters and never the whole thing. A secret in a log file is a secret that has to be rotated, and the log file is usually the last place anyone thinks to look.
The one thing to keep
Logging separates commentary from output and lets you switch detail on by level rather than by editing code, and `%s` arguments avoid building a message that will be discarded.
Before you move on
A data job logs `log.debug(f"row {i}: {row}")` inside a loop over 5 million rows, with the level set to INFO. A colleague says the line is free because DEBUG is off. What is actually true?
Pick the one you would defend. Nobody sees your answer.