Python 3.14 ·
✓ verified by execution on 2026-08-01
print() is how you debug on your own machine. It is not how you find out why something broke at 3am on a server you cannot see. Two tools take over at that point: logging, which records what happened, and well-designed exceptions, which say what went wrong and why.
Logs Have Levels
The point of a level is triage. Not every line deserves to wake someone up, and logging lets you say how loud each message is. Run this — the logs are routed to stdout here so you can actually read them:
INFO: starting up
WARNING: disk almost full
ERROR: upload failed
Your output
Notice what is missing: the debug line never printed. level=logging.INFO means “INFO and above”, and DEBUG sits below it. That single argument is the whole triage system.
Set the level and everything below it goes quiet. The default is WARNING — which is why a fresh logging.info() call so often appears to do nothing.
The default level is WARNING. That trips up almost everyone: you sprinkle logging.info() through your code, see nothing at all, and assume logging is broken. It is working exactly as configured.
Errors Should Say What Broke
A ValueError tells you a value was wrong. It does not tell you your config file was missing an API key. Naming your own exception turns a vague failure into a specific one — and lets callers catch precisely that case:
python
class ConfigError(Exception): passtry: raise ConfigError("Missing API key")except ConfigError as e: print(f"Caught: {e}")
Output
Caught: Missing API key
Your output
That is the entire definition. You do not need an __init__; inheriting from Exception already handles the message.
Inherit from Exception, never BaseException. BaseException also covers KeyboardInterrupt and SystemExit — subclass it and your error slips past every ordinary except Exception: handler, while your Ctrl-C might get swallowed by one.
Keep the Original Cause
Real failures happen in layers: a parse fails, so a load fails, so a request fails. If each layer throws away the one beneath it, you are left holding the least useful error. The from keyword keeps the chain intact:
python
try: int("abc")except ValueError as e: try: raise RuntimeError("Parsing failed") from e except RuntimeError as r: print(f"Error: {r}") print(f"Cause: {r.__cause__.__class__.__name__}")
Output
Error: Parsing failed
Cause: ValueError
Your output
RuntimeError("Parsing failed") is what your caller cares about. __cause__ still holds the ValueError that actually started it, and a real traceback prints both, separated by “The above exception was the direct cause…”.
Log the Traceback, Not Just the Message
Inside an except block, logging.exception() does what logging.error() does and attaches the traceback automatically — so the log tells you the line that failed, not merely that something did:
Here the log is captured into a string so we can inspect it (a real traceback prints a file path from your machine, which would differ on every computer):
python
import logging, iocaptured = io.StringIO()logging.basicConfig(stream=captured, level=logging.ERROR, format="%(levelname)s: %(message)s")try: 1 / 0except ZeroDivisionError: logging.exception("Could not compute average")log = captured.getvalue()print(log.splitlines()[0])print("traceback included:", "ZeroDivisionError: division by zero" in log)print("service still running")
Output
ERROR: Could not compute average
traceback included: True
service still running
Your output
Your one-line message went to the log, the full traceback came along with it, and the program kept running — exactly what you want from a background job that hit one bad record. Swap logging.exception for logging.error and that second line becomes False: same message, no evidence.
Watch Out
Myth:logging.basicConfig() can be called again to change the configuration.
Reality: It does nothing if the root logger already has handlers. Configure once, at startup.
Myth:raise ... from None is a tidier raise ... from err.
Reality:from None deliberately suppresses the original context. Useful for hiding internals, but you lose the cause — do not reach for it by habit.
See It Step by Step
Step through a failure being caught, logged, and survived:
python · visualize
import logging, sysclass DataLoadError(Exception): passlogging.basicConfig(stream=sys.stdout, level=logging.ERROR, format="%(levelname)s: %(message)s")try: raise DataLoadError("Failed to fetch data")except DataLoadError as e: logging.error("Application error: %s", e)
Check yourself
You call logging.debug('hello') and nothing appears. Why?
Reveal answer
The default level is WARNING, so DEBUG messages are filtered out. — The root logger defaults to WARNING, so DEBUG and INFO are dropped until you lower the level with basicConfig(level=logging.DEBUG).
Why use logging.exception() instead of logging.error() inside an except block?
Reveal answer
It automatically attaches the current traceback to the log record. — logging.exception() logs at ERROR level AND includes the traceback, so you can see where the failure came from.
What should a custom exception inherit from?
Reveal answer
Exception — because BaseException also catches KeyboardInterrupt and SystemExit. — Subclassing BaseException means `except Exception:` will not catch your error, and you risk swallowing Ctrl-C. Always subclass Exception.
What does `raise ValueError(...) from err` preserve that a bare raise does not?
Reveal answer
The original exception, reachable as __cause__ — The `from` keyword records the original error as __cause__, so the traceback shows both what failed and what it was doing at the time.
Challenges
Challenge 1 +20 XP
Define a custom exception `ValidationError`. Raise it with the message "Invalid data" and catch it, returning the string "Caught".
Catch a KeyError and raise a ValueError from it, printing the original cause.
python
Test 1 — expects "KeyError\n"
Show solution (0 XP)
def chain_error(): try: d = {} _ = d["missing"] except KeyError as e: try: raise ValueError("Bad dict") from e except ValueError as v: print(type(v.__cause__).__name__)chain_error()