BeginnerErrors and debugging~35 minPython

Errors and Debugging

Handle exceptions with try/except/else/finally, raise them deliberately with useful messages, catch narrowly, and reach for logging instead of stray print statements.

By the end of this lesson, you can

  • Distinguish a syntax error, an exception, and an incorrect-but-non-crashing result
  • Use try/except/else/finally correctly, including when each clause actually runs
  • Raise a specific, descriptive exception instead of failing silently or crashing uninformatively
  • Explain why an assertion is not a substitute for real input validation

Why it matters

Every program encounters bad input, missing files, and unexpected states eventually. The difference between a program that fails predictably with a clear message and one that crashes confusingly (or worse, silently produces a wrong answer) almost always comes down to how deliberately errors were designed for, not how rarely they occur.

Mental model

Three genuinely different things all get called “an error” loosely, and it’s worth keeping them apart:

Three ways a program can be wrong
KindWhen it happensExample
Syntax errorBefore any code runs — the file isn't valid Python at allA missing colon after if x:
ExceptionWhile running, when an operation cannot proceedint("abc"), a missing dict key, division by zero
Incorrect resultThe program runs to completion and returns a wrong answer, with no error at allAn off-by-one slice that silently drops the last item

try/except handles the second category. except SpecificError: catches only what a function genuinely knows how to recover from; everything else propagates as a normal, loud traceback — which is the correct behavior for a bug that should be fixed, not hidden.

Worked example: validating input, and cleaning up either way

Pythonerrors_and_debugging.py
import logging

logger = logging.getLogger(__name__)


def parse_positive_int(raw: str) -> int:
    try:
        value = int(raw)
    except ValueError:
        raise ValueError(f"{raw!r} is not a valid integer") from None
    else:
        if value <= 0:
            raise ValueError(f"{value} is not positive")
        return value


def safe_divide(a: float, b: float) -> float | None:
    try:
        return a / b
    except ZeroDivisionError:
        logger.warning("Attempted division by zero: %s / %s", a, b)
        return None
    finally:
        logger.debug("safe_divide(%s, %s) finished", a, b)


def main() -> None:
    for raw in ["42", "-5", "abc"]:
        try:
            print(f"{raw!r} ->", parse_positive_int(raw))
        except ValueError as exc:
            print(f"{raw!r} -> error: {exc}")

    print("10 / 2 =", safe_divide(10, 2))
    print("10 / 0 =", safe_divide(10, 0))


if __name__ == "__main__":
    main()
Output
'42' -> 42
'-5' -> error: -5 is not positive
'abc' -> error: 'abc' is not a valid integer
10 / 2 = 5.0
10 / 0 = None

parse_positive_int shows all four try clauses working together: except ValueError catches only the specific failure int() can raise, re-raising a clearer message via raise ... from None (see try/except/else/finally for exactly what that suppresses); else runs the “now check it’s actually positive” logic only once int() has already succeeded, so it’s never accidentally caught by the same except ValueError above it.

safe_divide shows finally: logger.debug(...) runs every time, whether the division succeeded or a ZeroDivisionError was caught — and note that neither logger.warning nor logger.debug shows up in the program’s own printed output above, because logging writes to a separate stream from print, by design.

Common mistakes

  • A bare except: (no exception type) catches everything, including typos that raised NameError and even KeyboardInterrupt — always name the specific exception type being handled.
  • Using assert to validate real input (assert age >= 0) instead of raising ValueError explicitly — running Python with -O strips every assert statement from the program entirely, so validation that must always run cannot rely on assert.
  • Reaching for print() to report a problem instead of logging — print output can’t be filtered by severity or redirected independently of the program’s actual output, both of which logging handles for free.

Check your understanding

try: x = 1 / 0 except ZeroDivisionError: print("caught") else: print("no error") finally: print("cleanup")
Or reveal the answer without checking

Answer:caught, then cleanup
The division raises ZeroDivisionError, so except runs ('caught'). else is skipped entirely because the try block did raise. finally always runs regardless, printing 'cleanup' last.

A function validates that a user's age is non-negative before using it. Should this use assert age >= 0 or if age < 0: raise ValueError(...)?
Or reveal the answer without checking

Answer:raise ValueError — assertions can be stripped from a running program entirely and should not be relied on for real validation
assert statements are removed entirely when Python runs with the -O optimization flag, so any validation that must always execute — including checking real user input — needs an explicit if/raise, not assert.

Practice: warm-up

Predict, then check: does a return statement inside a try block skip a finally block that comes after it?

Stuck? Reveal one hint at a time.

  1. Hint 1

    finally is documented as running "no matter what" — that phrase is meant literally, including for a return.

  2. Hint 2

    Python actually runs the finally block before the function truly returns, even though the return statement executed first in the source order.

  3. Hint 3

    No — finally always runs, even when try (or except) contains a return. The returned value is determined first, then finally runs, then the function actually returns.

Reveal the answer

Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.

def f():
    try:
        return "from try"
    finally:
        print("finally ran")


f()
finally ran

finally ran prints even though try already hit a return — finally runs before the function actually hands control back to the caller, not instead of doing so.

Practice: apply it

Take a small function that reads a required key from a dict (config["timeout"]) and predict what happens when the key is missing, before checking. Then rewrite it to catch that specific failure and raise a clearer ValueError instead, the same way parse_positive_int does above.

config = {"retries": 3}; config["timeout"] — what happens?
Or reveal the answer without checking

Answer:A KeyError is raised, since "timeout" is not a key in config
Indexing a dict with a key that isn't present raises KeyError — the dict equivalent of indexing a list out of range, and exactly the kind of specific, catchable exception a try/except around the lookup should name explicitly.

Summary

  • A syntax error prevents the file from running at all; an exception happens while running when an operation can’t proceed; an incorrect-but-non-crashing result announces nothing at all and is the hardest of the three to catch.
  • try/except/else/finally: except catches a specific exception type; else runs only if nothing was raised; finally always runs, even after a return. See try/except/else/finally.
  • Raise a specific exception (like ValueError) with a clear message for invalid input, rather than crashing uninformatively or failing silently.
  • assert is a development-time invariant check that can be stripped from a running program entirely — it is not a substitute for real input validation. logging (writing to stderr, with severity levels) is the right tool for diagnostic messages, distinct from a program’s own print-based output.