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.
Before this lesson: Python Modules and Imports, Python Exceptions: try, except, else and finally
2Python Logging: diagram
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.
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.
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.
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.
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.
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.
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.
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.
- Line 1-2Import
loggingfor the log calls andsysso we can point the output at standard output. - Line 4-5
basicConfigconfigures the root logger once.level=logging.INFOlets INFO and above through,formatlays out each line as level, logger name and message,stream=sys.stdoutshows it in the normal output, andforce=Truereplaces any earlier setup. - Line 6
getLogger("orders")creates a named logger, so every line saysordersand you can see which part of the program spoke. In a real module you would writegetLogger(__name__). - Line 8-9The
DEBUGmessage is below theINFOthreshold, so it is dropped. Its values are passed as an argument (%s), so the list is not even formatted into text. - Line 10-12The list comprehension keeps the positive prices. If any were removed, a
WARNINGsays how many;%dis filled with the difference. - Line 13-14A normal milestone is logged at
INFO, then the function returns the sum. - Line 16The call prints the total 20.0. The log lines appear before it because the logging happens while the function runs.
- Line 17
log.errorrecords 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")✓ 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")✓ 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")✓ 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")✓ 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)✓ 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 ✓ CompletedWrite 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.
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())
logging.basicConfig(level=level, format="%(levelname)s:%(name)s:%(message)s", stream=stream, force=True).import io
import logging
def configure(stream, level=logging.INFO):
logging.basicConfig(level=level, format="%(levelname)s:%(name)s:%(message)s",
stream=stream, force=True)
out = io.StringIO()
configure(out)
logging.getLogger("demo").info("hello")
print(out.getvalue().strip())
2. Predict which messages appear
Predict the output Easy ✓ CompletedType 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.
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))
ERROR out of stock
CRITICAL database lost
False True
2 more exercises → 6 more examples
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