print() is fine for small scripts. For anything that runs for a while — servers, training jobs, scheduled tasks — you want logging: structured messages with severity levels that you can filter and redirect.

Python’s logging module is built in. It’s verbose at first glance, but covers cases print can’t.

Why not just print?

print() has problems for real programs:

  • No timestamps.
  • No way to filter by severity (errors vs debug info).
  • No way to send some messages to a file and others to the terminal.
  • Always on, hard to turn off in production.

logging solves all of these.

A first logger

import logging

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

logging.info("Starting up")
logging.warning("Cache miss")
logging.error("Database not responding")
2026-05-11 09:30:01,234 [INFO] Starting up
2026-05-11 09:30:01,234 [WARNING] Cache miss
2026-05-11 09:30:01,234 [ERROR] Database not responding

Each line has a timestamp and severity tag. You can change the format or destination with basicConfig.

The five levels

LevelWhen
DEBUGDetailed information for diagnosis. Off in production.
INFOConfirmation things are working.
WARNINGSomething unexpected, but the program is fine.
ERRORSomething failed; some feature didn’t work.
CRITICALSomething failed badly; the program may abort.

basicConfig(level=logging.INFO) shows everything from INFO upward, hiding DEBUG. Change to logging.DEBUG during development.

Loggers per module

The pattern in real applications — one logger per module:

import logging

logger = logging.getLogger(__name__)


def load(path: str) -> list[str]:
    logger.info("Loading from %s", path)
    # ...

logging.getLogger(__name__) returns a logger named after the module. This lets you tune log levels per module:

logging.getLogger("requests").setLevel(logging.WARNING)

(Useful for muting noisy libraries.)

Lazy formatting — %s placeholders

You may have noticed:

logger.info("Loading from %s", path)        # standard
logger.info(f"Loading from {path}")          # also works, but...

Both work. The %s form is preferred because the formatting happens only if the log level is enabled. With an f-string, the formatting happens every time, even if the log is suppressed. For hot paths, that’s wasted work.

For most code the difference is negligible — pick the style you find readable. Most experts use the %s form for production loggers.

Logging exceptions

To log a full traceback when something fails:

try:
    risky()
except Exception:
    logger.exception("Something failed")

logger.exception() is like logger.error() but automatically attaches the current traceback. Use it inside except blocks.

Sending logs to a file

basicConfig can route logs to a file:

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    filename="app.log",
)

Or both:

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    handlers=[
        logging.FileHandler("app.log"),
        logging.StreamHandler(),     # also to stderr
    ],
)

For more complex setups (rotating files, structured JSON, multiple destinations), look up logging.config.dictConfig.

A practical pattern

A small but realistic module skeleton:

import logging

logger = logging.getLogger(__name__)


def main() -> None:
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    )
    logger.info("Application starting")
    try:
        do_work()
    except Exception:
        logger.exception("Application crashed")
        raise
    logger.info("Application finished")


def do_work() -> None:
    for i in range(3):
        logger.debug("processing item %d", i)
        # ...


if __name__ == "__main__":
    main()

To switch to debug-level output, change level=logging.INFO to level=logging.DEBUG. No other code changes.

A note on third-party loggers

For ML and data work, libraries like rich, loguru, and structlog provide nicer experiences:

  • rich — colourful terminal output, beautiful tracebacks.
  • loguru — drop-in replacement with much less configuration.
  • structlog — structured (JSON) logging for production services.

The standard logging module is what every other library uses underneath. Learn it first; reach for the nicer libraries when your project benefits.

What’s next

Logging tells you what happened. Next — Ruff, the tool that keeps your code clean before anything goes wrong.

Toggle theme (T)