Dev.to WebDev 🛠 Dev 👁 0 📖 2 min read

Your Python script's print() output is useless at 3 a.m. Here is how to switch to logging in 10 lines

You wrote a script, put it on a cron job, and one night it fails. You open the output and find a wall of print() lines with no timestamps, no severity, and no idea which line came from which step. Python's built-in logg

You wrote a script, put it on a cron job, and one night it fails. You open the output and find a wall of print() lines with no timestamps, no severity, and no idea which line came from which step.

Python's built-in logging module fixes this, and you do not need to install anything. Here is the smallest setup that is actually worth using.

Step 1: replace print with a configured logger

import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
log = logging.getLogger(__name__)

log.info("starting job")
log.warning("config file missing, using defaults")
log.error("could not reach the API")

Output:

2026-10-25 16:40:02,114 INFO __main__: starting job
2026-10-25 16:40:02,114 WARNING __main__: config file missing, using defaults
2026-10-25 16:40:02,115 ERROR __main__: could not reach the API

Every line now has a time, a level, and the module that wrote it.

Step 2: log to a file that does not grow forever

A plain file handler will keep growing until your disk is full. RotatingFileHandler keeps a few capped files instead:

import logging
from logging.handlers import RotatingFileHandler

handler = RotatingFileHandler("job.log", maxBytes=1_000_000, backupCount=3)
handler.setFormatter(logging.Formatter(
    "%(asctime)s %(levelname)s %(name)s: %(message)s"
))

root = logging.getLogger()
root.setLevel(logging.INFO)
root.addHandler(handler)
root.addHandler(logging.StreamHandler())  # keep console output too

This gives you job.log, job.log.1, job.log.2 and job.log.3, each up to about 1 MB.

Step 3: log exceptions with the traceback

This is the one that saves you at 3 a.m. Inside an except block, use log.exception:

try:
    data = fetch_report()
except Exception:
    log.exception("fetch_report failed")
    raise

It logs at ERROR level and appends the full traceback automatically. No manual traceback.format_exc() needed.

Step 4: change verbosity without editing code

import os
logging.getLogger().setLevel(os.environ.get("LOG_LEVEL", "INFO"))

Now LOG_LEVEL=DEBUG python job.py gives you detail when you need it, and normal runs stay quiet.

Three gotchas

  1. basicConfig only works once. If any handler is already configured on the root logger, later calls do nothing (unless you pass force=True). Call it once, at the entry point of your program.
  2. Pass values as arguments, not f-strings. Write log.info("got %d rows", n) instead of log.info(f"got {n} rows"). The string is only built if the message is actually emitted.
  3. Libraries should not configure logging. If you write a module other people import, only call logging.getLogger(__name__) and let the application decide the handlers.

The official reference is worth bookmarking: the Logging HOWTO and the Logging Cookbook in the Python docs cover everything above in more depth, including structured output and multiple processes.

Quick recap

  • basicConfig once, with a format that includes time and level
  • RotatingFileHandler so logs do not eat your disk
  • log.exception inside except for free tracebacks
  • an environment variable for the log level

What is the worst thing you have ever had to debug with nothing but print() output, and what finally made you switch?

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.