Logging and Error Design

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:

python
import logging, sys

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

logging.debug("cache key computed")
logging.info("starting up")
logging.warning("disk almost full")
logging.error("upload failed")
Output
INFO: starting up
WARNING: disk almost full
ERROR: upload failed

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.

CRITICALshown ERRORshown WARNINGshown INFOshown <- level set here DEBUGfiltered out
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):
    pass

try:
    raise ConfigError("Missing API key")
except ConfigError as e:
    print(f"Caught: {e}")
Output
Caught: Missing API key

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

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, io

captured = io.StringIO()
logging.basicConfig(stream=captured, level=logging.ERROR, format="%(levelname)s: %(message)s")

try:
    1 / 0
except 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 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, sys

class DataLoadError(Exception):
    pass

logging.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".

python
  • Test 1 — expects "Caught\n"
Show solution (0 XP)
class ValidationError(Exception):
    pass

def test_exception():
    try:
        raise ValidationError("Invalid data")
    except ValidationError:
        return "Caught"

print(test_exception())

Challenge 2 +20 XP

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()