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
-
basicConfigonly works once. If any handler is already configured on the root logger, later calls do nothing (unless you passforce=True). Call it once, at the entry point of your program. - Pass values as arguments, not f-strings. Write
log.info("got %d rows", n)instead oflog.info(f"got {n} rows"). The string is only built if the message is actually emitted. - 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
-
basicConfigonce, with a format that includes time and level -
RotatingFileHandlerso logs do not eat your disk -
log.exceptioninsideexceptfor 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?


