Addaly is in open beta. Things will change, and AI answers can be wrong — check anything that matters.

Python, From Zero, For AI

From your first line of code to your first API call.

Lesson 33 of 898 min

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.

python
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.csv

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

Five levels, and the one line that decides what you seeCRITICALThe program cannot continueERRORThis operation failed. log.exception adds thetracebackWARNINGOdd but survivable — a retry, a missing optionalfieldINFOA normal notable event: started, finished, 412 rowswrittenDEBUGValues you want while working on it. Off inproductionbasicConfig(level=logging.INFO) prints INFO and above and drops DEBUG silently, so the debugstatements stay in the code and cost nothing. Wire the level to a -v counter and the person runningthe program chooses the detail without editing anything.
Five levels, and the one line that decideswhat you seeCRITICALThe program cannot continueERRORThis operation failed. log.exception adds thetracebackWARNINGOdd but survivable — a retry, a missing optionalfieldINFOA normal notable event: started, finished, 412rows writtenDEBUGValues you want while working on it. Off inproductionbasicConfig(level=logging.INFO) prints INFO andabove and drops DEBUG silently, so the debugstatements stay in the code and cost nothing. Wirethe level to a -v counter and the person running theprogram chooses the detail without editing anything.

Wire it to the -v counter from the command-line lesson and a user can choose their own verbosity:

python
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:

python
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

python
log.debug("row %s took %.2fs", row_id, elapsed)     # correct
log.debug(f"row {row_id} took {elapsed:.2f}s")      # works, but wasteful

The 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

python
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

python
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 .env file
  • 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.

No ads. No data sale. No public scores on people. Ever.

© 2026 Addaly