Skip to Content
Python logging: Replacing print() Before It Gets Out of Hand
Intermediate
0 likes
Tutorials

Python logging: Replacing print() Before It Gets Out of Hand

print() is for the output a person asked for. logging is for messages about the program: levels you can turn down, a file with times, and tracebacks for errors. Set it up once in main() and use a logger per module.

print() is fine for figuring out a bug in the next five minutes. It's the wrong tool for messages a program should keep giving after you've stopped watching it. That's what logging is for. It adds levels, so you can turn the noise down without deleting lines, and it can write to a file with the time on every line.

Skip ahead: If you just want the setup, copy the code under "The setup for a small script". Read "Logging an error with its traceback" before you write your first except block with logging.

When print stops being enough

You can tell it's time when:

  • you add print lines to debug, then delete them, then add them again next week
  • a scheduled script fails at night, and there's nothing on screen to read in the morning
  • you can't tell a debug message from a real warning, because they all look the same

None of these is a problem for a five-line script. Once a script runs without you, all three are.

Levels: one switch for the noise

import logging

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")

logging.debug("Row 1 raw value: ' 95.00 '")
logging.info("Read 120 rows from sales.csv")
logging.warning("Row 48 has no quantity, skipped")
logging.error("Could not write report.csv")
INFO Read 120 rows from sales.csv
WARNING Row 48 has no quantity, skipped
ERROR Could not write report.csv

There are five levels, from least to most serious: DEBUG, INFO, WARNING, ERROR, CRITICAL. level=logging.INFO means "show INFO and above". The debug line is still in the code. It just isn't shown. Change the level to DEBUG while you hunt a bug, and every debug line comes back. Change it back when you're done. No deleting.

A simple way to pick a level:

  • DEBUG: details only you care about while fixing something.
  • INFO: normal progress. Started, read 120 rows, finished.
  • WARNING: something odd happened, the program handled it, a person might want to know.
  • ERROR: something failed. The program may continue with the rest.
  • CRITICAL: the program can't continue.

The setup for a small script

import logging

logger = logging.getLogger(__name__)


def total(rows):
    result = 0
    for number, row in enumerate(rows, start=1):
        if row.get("qty") is None:
            logger.warning("Row %d has no qty, skipped", number)
            continue
        result += row["qty"] * row["price"]
    logger.debug("Total is %s", result)
    return result


def main():
    logging.basicConfig(
        level=logging.INFO,
        format="%(levelname)s %(name)s: %(message)s",
    )
    rows = [{"qty": 2, "price": 95}, {"price": 35}, {"qty": 1, "price": 1250}]
    logger.info("Starting with %d rows", len(rows))
    logger.info("Total: %s", total(rows))


if __name__ == "__main__":
    main()
INFO __main__: Starting with 3 rows
WARNING __main__: Row 2 has no qty, skipped
INFO __main__: Total: 1440

Three habits in that code:

  1. **logger = logging.getLogger(__name__)** at the top of each file. Each module gets a logger with its own name, which shows up as %(name)s. In a bigger project, you can see which file a message came from. Here it's __main__, because the file was run directly.
  2. **basicConfig is called once, in main(),** not in every module. The program decides where messages go. Modules only send them.
  3. **%s and %d instead of f-strings.** logger.debug("Total is %s", result) only builds the text if the message will actually be shown. With an f-string, the text is built every time, even for hidden debug lines. For a few lines it doesn't matter. In a loop over a million rows, it does. It's also the style most code checkers expect.

⚠ Warning: basicConfig only works the first time it's called, and only if nothing was set up before. If you call logging.info(...) before basicConfig, Python sets up a default handler with level WARNING, and your later basicConfig call does nothing. Your INFO messages then quietly disappear. Call basicConfig first, at the start of main().

Writing to a file

Add filename= and every message goes to a file instead of the screen. Add %(asctime)s for the time:

import logging

logging.basicConfig(
    filename="app.log",
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
    encoding="utf-8",
)
logging.info("Job started")
logging.warning("Skipped 1 row")
print(open("app.log", encoding="utf-8").read(), end="")
2026-10-13 12:43:28,456 INFO Job started
2026-10-13 12:43:28,456 WARNING Skipped 1 row

Your times will be different. The file is appended to on every run, so it becomes a history of what the script did. That's the thing print can't give you: a record you can read after the fact.

💡 Tip: A log file grows forever. For something that runs every day, look at logging.handlers.RotatingFileHandler, which starts a new file when the current one reaches a size you choose and keeps only the last few.

Logging an error with its traceback

Inside an except block, use logger.exception. It logs at ERROR level and adds the full traceback:

import logging

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger(__name__)

try:
    rate = int("twelve")
except ValueError:
    logger.exception("Could not read the VAT rate")
ERROR Could not read the VAT rate
Traceback (most recent call last):
  File "/home/you/project/exc.py", line 7, in <module>
    rate = int("twelve")
ValueError: invalid literal for int() with base 10: 'twelve'

logger.error("Could not read the VAT rate") would give you only the first line. The traceback is the part you need at 8 AM, when you're trying to understand what happened at 2 AM.

Keep print for its real job

print isn't banned. Use it for the output a person asked for: the report, the result, the answer. Use logging for messages about the program: what it's doing and what went wrong. If you redirect a script's output to a file, the result should be there, and the log messages shouldn't be mixed into it. By default, logging writes to stderr and print to stdout, which keeps them apart.

Common mistakes

  • **basicConfig after the first log call.** It's silently ignored.
  • **Calling basicConfig in every module.** Only the entry point configures logging.
  • **logger.error(str(error)) in an except block.** Use logger.exception(...) to keep the traceback.
  • Logging passwords, tokens or full personal data. Log files get copied, shared and attached to support tickets. Log an ID, not the secret.

What happens if you stick with print

It works while you watch. The day the script runs on a schedule and fails, there's no record of what it did, what it skipped, or what went wrong. You run it again by hand to see the error, and this time it works.

If your script failed last night, what would you have to read this morning to know why?

About

Log levels, basicConfig and its first-call trap, one logger per module, logging to a file, and logger.exception for tracebacks. After this, your scripts leave a record you can read after they run without you.

Responsible
Jay P
Last Update
09/19/2026
Reading Time
6 min
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.