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
printlines 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:
- **
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. - **
basicConfigis called once, inmain(),** not in every module. The program decides where messages go. Modules only send them. - **
%sand%dinstead 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
- **
basicConfigafter the first log call.** It's silently ignored. - **Calling
basicConfigin every module.** Only the entry point configures logging. - **
logger.error(str(error))in anexceptblock.** Uselogger.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
Reviews
Sign in to write a reviewComments (0)
Sign in to commentNo comments yet.