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
| Level | When |
|---|---|
DEBUG | Detailed information for diagnosis. Off in production. |
INFO | Confirmation things are working. |
WARNING | Something unexpected, but the program is fine. |
ERROR | Something failed; some feature didn’t work. |
CRITICAL | Something 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.