Python Logging
Practical Python Practical Python Practical example

Python Logging

1Python Logging: overview

The logging module records messages from your program with a severity level, a source name and a destination you configure once, so you can turn detail up or down without editing your code.

GoalBy the end you can configure logging with basicConfig, choose the right level, create named loggers, route messages to the screen or a file with handlers and formatters, log tracebacks with logger.exception, and avoid the default-level and duplicate-handler traps.

Before this lesson: Python Modules and Imports, Python Exceptions: try, except, else and finally

2Python Logging: diagram

Python Logginglog.info("saved %d", n)your call, with a levelyesLogger level allows INFO?no: droppedyesHandler level allows INFO?no: droppedFormatter builds the line%(levelname)s:%(message)sOutput: screen or file
A message must pass two checks, the logger's level and then each handler's level, before the formatter lays it out and the handler writes it somewhere.

3Python Logging: explained step by step

Every beginner debugs with print(). It works until the program grows: you cannot switch the messages off without deleting them, you cannot tell a harmless note from a disaster, and nothing says which part of the program spoke. The standard logging module fixes all three. You write log calls once, give each a severity level, and decide later, in one place, what to show and where to send it. If you already know modules and exceptions, you are ready.

Logging levels from DEBUG to CRITICAL

Every message has a level, and every level is a number. A logger shows a message only when its level is at least the configured threshold.

Python
import logging

for name in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
    print(f"{name:<9}{getattr(logging, name)}")

Use DEBUG for details only a developer needs, INFO for normal milestones ("loaded 120 rows"), WARNING for something odd that the program survived, ERROR when an operation failed, and CRITICAL when the whole program may not continue. Set the threshold to WARNING in production and to DEBUG while hunting a bug, without touching a single log call.

Why logging.info shows nothing: the default WARNING level

The root logger starts with the threshold WARNING, so debug() and info() are silently dropped. logging.basicConfig() sets the threshold, the message layout and the destination. Here stream=sys.stdout sends output to the normal output (the default is stderr), and force=True replaces any earlier configuration so the example behaves the same every time you run it.

Python
import logging
import sys

logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s:%(message)s",
                    stream=sys.stdout, force=True)

logging.debug("cache contents: {}")
logging.info("server started")
logging.warning("disk is 91 percent full")
logging.error("could not save report")

The debug line is missing because DEBUG (10) is below the INFO (20) threshold. The format string uses fields such as %(levelname)s, %(name)s and %(message)s; in real programs you would usually add %(asctime)s for a timestamp, which these lessons leave out so the output stays identical on every run.

Named loggers and the logger hierarchy

Instead of the root logger, each module should create its own with logging.getLogger(__name__). Names are separated by dots, and that forms a tree: "shop.cart" is a child of "shop". A child passes its records up to its parents, so one handler on the root can serve the whole program, and the %(name)s field tells you which part spoke.

Python
import logging
import sys

logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s:%(message)s",
                    stream=sys.stdout, force=True)

cart = logging.getLogger("shop.cart")
payment = logging.getLogger("shop.payment")
logging.getLogger("shop.payment").setLevel(logging.ERROR)

cart.info("added 2 items")
payment.info("card checked")
payment.error("card declined")
print(cart.parent.name, payment.getEffectiveLevel(), cart.getEffectiveLevel())

Setting a level on shop.payment quiets only that branch. A logger with no level of its own uses the level of the nearest parent, which is why cart still logs at INFO.

Handlers and formatters send logs anywhere

A handler decides where records go and a formatter decides how they look. One logger can feed several handlers, each with its own level: show warnings on the screen but keep everything in a file. The in-browser file system is temporary, but the code is identical to what you would write on your own computer.

Python
import logging
import sys

log = logging.getLogger("app")
log.handlers.clear()
log.setLevel(logging.DEBUG)
log.propagate = False

screen = logging.StreamHandler(sys.stdout)
screen.setLevel(logging.WARNING)
screen.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))

journal = logging.FileHandler("app.log", mode="w")
journal.setFormatter(logging.Formatter("%(levelname)s:%(name)s:%(message)s"))

log.addHandler(screen)
log.addHandler(journal)

log.debug("opening config")
log.warning("config key missing, using default")
journal.close()

print("--- file ---")
with open("app.log") as f:
    print(f.read().strip())

The logger threshold (DEBUG) is checked first and the handler threshold second, so the screen shows one line while the file keeps both. Reading the file back uses a plain with statement and the ideas from file handling.

Log a traceback with logger.exception

Inside an except block, logger.exception("message") logs at ERROR level and appends the full traceback. That is far more useful than print(err), because it shows where the failure happened. The traceback contains file paths, so this demo captures the output in a StringIO and prints only the first and last lines.

Python
import io
import logging

buffer = io.StringIO()
log = logging.getLogger("importer")
log.handlers.clear()
log.propagate = False
log.addHandler(logging.StreamHandler(buffer))

try:
    price = float("12,50")
except ValueError:
    log.exception("bad price in row 7")

lines = buffer.getvalue().splitlines()
print(lines[0])
print(lines[-1])
print(len(lines) > 2)

Pass values as arguments, not f-strings

logger.info("loaded %d rows from %s", count, name) hands the template and the values to the logger separately, and formatting only happens if the message is actually emitted. With an f-string Python builds the text every time, even for a DEBUG message that is switched off. Use %s, %d and %r in log calls; it is the one place where this older style is still the recommended choice.

Capture logs with StringIO in tests

Because handlers accept any stream, a test can attach one that writes to io.StringIO, run the code, and compare the text. The testing lesson builds on this idea.

Python
import io
import logging

def make_logger(stream):
    log = logging.getLogger("checkout")
    log.handlers.clear()
    log.setLevel(logging.INFO)
    log.propagate = False
    handler = logging.StreamHandler(stream)
    handler.setFormatter(logging.Formatter("%(levelname)s:%(message)s"))
    log.addHandler(handler)
    return log

def apply_discount(total, code, log):
    if code == "SAVE10":
        log.info("discount %s applied to %.2f", code, total)
        return round(total * 0.9, 2)
    log.warning("unknown discount code %r", code)
    return total

stream = io.StringIO()
log = make_logger(stream)
print(apply_discount(50, "SAVE10", log), apply_discount(50, "FREE", log))
print(stream.getvalue().splitlines())

Two common logging traps

basicConfig() does nothing if the root logger already has handlers, and calling logging.warning() before it configures logging implicitly, so a later basicConfig(level=DEBUG) is ignored unless you pass force=True. The second trap is adding a handler inside a function that runs repeatedly: every call stacks another handler and each message is printed again and again. Configure logging once, at startup.

4Python Logging: syntax

import logging, sys

logging.basicConfig(level=logging.INFO, stream=sys.stdout, force=True,
                    format="%(levelname)s:%(name)s:%(message)s")   # once, at startup

log = logging.getLogger(__name__)         # one named logger per module
log.debug("details %s", value)            # 10  developer details
log.info("loaded %d rows", count)         # 20  normal milestones
log.warning("low disk: %s%%", percent)    # 30  odd but survived
log.error("save failed: %s", path)        # 40  an operation failed
log.critical("cannot continue")           # 50  program may stop
log.exception("parse failed")             # ERROR + traceback, inside except only

handler = logging.StreamHandler(sys.stdout)       # or logging.FileHandler("app.log")
handler.setLevel(logging.WARNING)
handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
log.addHandler(handler)
log.setLevel(logging.DEBUG)
log.propagate = False                     # do not also send to parent handlers

5Python Logging: run the example

Press Run (or Ctrl + Enter). The output (step 6) appears directly below the code. Change anything and run it again.

Interactive example
import logging
import sys

logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s:%(message)s",
                    stream=sys.stdout, force=True)
log = logging.getLogger("orders")

def total_price(prices):
    log.debug("raw prices: %s", prices)
    clean = [p for p in prices if p > 0]
    if len(clean) < len(prices):
        log.warning("dropped %d invalid prices", len(prices) - len(clean))
    log.info("pricing %d items", len(clean))
    return sum(clean)

print(total_price([4.5, 12.0, -1, 3.5]))
log.error("payment gateway timeout")
Expected outputWARNING:orders:dropped 1 invalid prices
INFO:orders:pricing 3 items
20.0
ERROR:orders:payment gateway timeout

7Python Logging: how the example works

Hover or tap a step to highlight its lines in the example above.

  1. Line 1-2Import logging for the log calls and sys so we can point the output at standard output.
  2. Line 4-5basicConfig configures the root logger once. level=logging.INFO lets INFO and above through, format lays out each line as level, logger name and message, stream=sys.stdout shows it in the normal output, and force=True replaces any earlier setup.
  3. Line 6getLogger("orders") creates a named logger, so every line says orders and you can see which part of the program spoke. In a real module you would write getLogger(__name__).
  4. Line 8-9The DEBUG message is below the INFO threshold, so it is dropped. Its values are passed as an argument (%s), so the list is not even formatted into text.
  5. Line 10-12The list comprehension keeps the positive prices. If any were removed, a WARNING says how many; %d is filled with the difference.
  6. Line 13-14A normal milestone is logged at INFO, then the function returns the sum.
  7. Line 16The call prints the total 20.0. The log lines appear before it because the logging happens while the function runs.
  8. Line 17log.error records a failure. Notice that it is just a message; unlike an exception it does not stop the program.

8Python Logging: common mistakes

info() prints nothing because the default level is WARNING

✗ Wrong

import logging

logging.info("Server started")
logging.debug("Loaded 3 plugins")
print("done")
No error and no log lines: only `done` is printed

✓ Right

import logging
import sys

logging.basicConfig(level=logging.DEBUG, stream=sys.stdout, force=True)
logging.info("Server started")
logging.debug("Loaded 3 plugins")
print("done")

The root logger starts at WARNING, so info() and debug() are dropped. Set the threshold with basicConfig(level=...) before you log.

Calling basicConfig a second time

✗ Wrong

import logging
import sys

logging.basicConfig(level=logging.WARNING, stream=sys.stdout)
logging.basicConfig(level=logging.DEBUG, stream=sys.stdout)
logging.debug("cache miss")
No error, but `cache miss` never appears

✓ Right

import logging
import sys

logging.basicConfig(level=logging.WARNING, stream=sys.stdout)
logging.basicConfig(level=logging.DEBUG, stream=sys.stdout, force=True)
logging.debug("cache miss")

basicConfig does nothing when the root logger already has a handler. Calling logging.warning() first also configures it silently. Configure once at startup, or pass force=True to replace the old setup.

Adding a handler every time a function runs

✗ Wrong

import logging
import sys

def get_logger():
    log = logging.getLogger("worker")
    log.setLevel(logging.INFO)
    log.addHandler(logging.StreamHandler(sys.stdout))
    return log

get_logger().info("start")
get_logger().info("stop")
No error, but the output is `start`, `stop`, `stop`: each call stacks one more handler

✓ Right

import logging
import sys

def get_logger():
    log = logging.getLogger("worker")
    if not log.handlers:
        log.setLevel(logging.INFO)
        log.addHandler(logging.StreamHandler(sys.stdout))
    return log

get_logger().info("start")
get_logger().info("stop")

getLogger("worker") always returns the same object, so every addHandler adds another copy. Only add a handler when the logger has none, or configure logging once at startup.

More arguments than placeholders

✗ Wrong

import logging

logging.basicConfig(level=logging.INFO, force=True)
logging.info("loaded %d files", 3, "reports")
--- Logging error --- TypeError: not all arguments converted during string formatting Message: 'loaded %d files' Arguments: (3, 'reports')

✓ Right

import logging
import sys

logging.basicConfig(level=logging.INFO, stream=sys.stdout, force=True)
logging.info("loaded %d files from %s", 3, "reports")

Logging never crashes your program, so this mistake only prints a report to stderr and the message is lost. Give every argument its own placeholder, or remove the extra one.

Logging the exception without its traceback

✗ Wrong

import logging
import sys

logging.basicConfig(level=logging.INFO, stream=sys.stdout, force=True)
try:
    1 / 0
except ZeroDivisionError as err:
    logging.error(err)
ERROR:root:division by zero (no traceback, so you cannot see which line failed)

✓ Right

import logging
import sys

logging.basicConfig(level=logging.INFO, stream=sys.stdout, force=True)
try:
    1 / 0
except ZeroDivisionError:
    logging.exception("calculation failed")

error(err) records only the message text. Inside an except block use logging.exception("what failed"), which logs at ERROR level and adds the full traceback.

9Python Logging: exercises

Write your answer, then press Check. The checks run in your browser.

1. Configure the root logger

Write code Easy

Write configure(stream, level=logging.INFO) that configures the root logger with logging.basicConfig so it writes to stream, uses the level given, and formats lines as %(levelname)s:%(name)s:%(message)s. Use force=True so calling it twice works.

Your code
import io
import logging

def configure(stream, level=logging.INFO):
    # call logging.basicConfig with level, format, stream and force=True
    pass

out = io.StringIO()
configure(out)
logging.getLogger("demo").info("hello")
print(out.getvalue().strip())

2. Predict which messages appear

Predict the output Easy

Type exactly what this program prints. The logger threshold is WARNING (30) and the handler threshold is ERROR (40). Remember that both must allow a message.

Read this code
import logging
import sys

log = logging.getLogger("store")
log.handlers.clear()
log.propagate = False
log.setLevel(logging.WARNING)

handler = logging.StreamHandler(sys.stdout)
handler.setLevel(logging.ERROR)
handler.setFormatter(logging.Formatter("%(levelname)s %(message)s"))
log.addHandler(handler)

log.info("opened")
log.warning("low stock")
log.error("out of stock")
log.critical("database lost")
print(log.isEnabledFor(logging.INFO), log.isEnabledFor(logging.WARNING))

2 more exercises → 6 more examples

Finished this lesson?
Progress is saved in this browser — create a free account to keep it across devices.

Questions learners ask

What is the difference between print and logging in Python?

print always writes to the screen and has no severity. Logging adds levels, names and optional timestamps, and lets you change what is shown and where it goes (screen, file, test buffer) from one place without editing the calls. That makes it the right tool once a program is larger than a few lines.

Why does logging.info() print nothing?

The root logger's default threshold is WARNING (30), so INFO (20) and DEBUG (10) messages are dropped. Call logging.basicConfig(level=logging.INFO) once, before logging, to see them. If it still has no effect, something already configured logging; pass force=True.

Should I use logging.getLogger(__name__) or the root logger?

Use a named logger per module, usually getLogger(name). Its name appears in the output, you can silence or tune one part of the program, and libraries stay well-behaved. Reserve the root logger and basicConfig for the application's entry point, where you configure everything once.

Further reading

10Next lesson

Up nextPython TestingA test is code that calls your code with known inputs and automatically checks that the results are what you expect.

Related:
Python Modules and ImportsPython Exceptions: try, except, else and finallyPython with StatementPython Testing

← Python SQLite · All Practical Python lessons · Practise in the IDE