Logging & debugging
The logging module, levels, per-module loggers, handlers and files; debugging with breakpoint() and pdb.
Every program misbehaves sometimes. The difference between a junior and a senior developer is often how quickly they find out why. This lesson covers two complementary skills: logging, which records what your program does while it runs (essential in production, where you can't attach a debugger), and debugging, which lets you pause a program and inspect it interactively.
Why not just print?#
print is fine for quick experiments, but in real applications it falls short:
- You can't turn messages on or off without editing code.
- There's no severity — a debugging detail looks the same as a critical failure.
- No timestamps, no module names, no way to send output to a file or a log service.
- Leftover
printcalls clutter output for your users.
The standard library's logging module solves all of this.
Logging basics#
The five standard levels, from least to most severe:
Setting level=logging.INFO shows INFO and above; DEBUG messages are skipped (cheaply). Without any configuration, only WARNING and above appear.
Notice the %-style arguments: logging.info("port %d", 8000) rather than an f-string. The message is only formatted if it's actually going to be emitted, and log aggregators can group messages by their template. f-strings work too and are common in practice, but the lazy form is the recommended style.
Use a logger per module#
Instead of calling logging.info directly, create a named logger at the top of each module:
Loggers form a hierarchy based on dotted names: configuring shop also affects shop.payments and shop.cart. That lets you, say, show DEBUG logs only for your own package while keeping noisy libraries at WARNING:
Rule: libraries only create loggers and log to them; the application's entry point configures handlers and levels, once, at start-up.
Logging exceptions#
Inside an except block, logger.exception() logs at ERROR level and includes the full traceback:
This is the pattern for top-level error handlers: catch, log with traceback, and either continue or exit cleanly.
Handlers, formatters and files#
basicConfig covers simple scripts. Real applications attach handlers (where logs go) with formatters (how they look). A common setup: INFO to the console, DEBUG to a rotating file:
RotatingFileHandler starts a new file when the current one reaches maxBytes, keeping backupCount old files — so logs never fill your disk. For larger apps, configure everything from a dict with logging.config.dictConfig. In containers and cloud platforms, the norm is to log to stdout, often as JSON lines, and let the platform collect them.
Never log secrets: passwords, tokens, full card numbers or personal data.
Debugging with breakpoint() and pdb#
When you need to see what's happening inside a running program, drop a breakpoint() call where you want to pause:
Running it opens the pdb prompt:
(p total and the other commands are what you type. On Python 3.12 the arrow points at the line after breakpoint(); 3.13 stops on the call itself.)
The essential pdb commands:
Other ways in: python3 -m pdb script.py starts a script under the debugger; pytest --pdb opens pdb at the point a test fails; and after a crash in the REPL, import pdb; pdb.pm() inspects the post-mortem state. Set the environment variable PYTHONBREAKPOINT=0 to disable all breakpoint() calls without removing them.
Graphical debuggers
VS Code and PyCharm offer the same features with a GUI: click in the gutter to set breakpoints, press F5, then hover over variables, watch expressions and step through code. Conditional breakpoints ("pause only when order_id == 1042") are invaluable for bugs that only show up on the 10,000th iteration.
A systematic debugging process#
Tools help, but method matters more:
- Reproduce the bug reliably — ideally as a failing test.
- Read the traceback bottom-up. The last line says what; the frames above say where.
- Form a hypothesis ("
scoresis empty here") and check it with a breakpoint, a log line, or anassert. - Narrow it down: comment out halves, use smaller inputs,
git bisectto find the commit that broke it. - Fix, then add a test so the bug can never come back.
- Explain the problem out loud (to a colleague or a rubber duck) — it works surprisingly often.
Quick inspection helpers worth knowing: print(f"{value=}"), type(x), dir(obj), vars(obj), repr(x) (reveals hidden whitespace like 'abc\n'), and the pprint module for nested data.
Assertions for internal sanity checks#
assert condition, message raises AssertionError if the condition is false. Use it to document and check assumptions inside your code — never for validating user input, because asserts are removed when Python runs with -O:
Common mistakes#
- Leaving
printdebugging andbreakpoint()calls in committed code — a linter like ruff can flag them. - Calling
basicConfigin library modules — only the application should configure logging. - Logging and re-raising at every level, producing the same traceback five times. Log once, where you handle the error.
- Swallowing exceptions silently (
except: pass) — at least log them. - Logging sensitive data.
- Guessing instead of observing — use the debugger and verify hypotheses.
What's next#
Your programs are now tested and observable. Next we'll make them do several things at once: concurrency with threads, processes and asyncio.
Check your understanding
Quick quiz
1.What is the default logging level of the root logger, i.e. which messages appear if you don't configure anything?
2.Which is the recommended way to get a logger in a module?
3.What does
breakpoint()do when Python reaches it?
Finished reading?
Mark this lesson complete to track your progress.