Lesson 17 of 25

Error Handling

Exceptions Are Information, Not Failure

When Python cannot carry out an instruction — dividing by zero, opening a missing file, converting the word "eighty" to an integer — it does not guess and it does not carry on. It creates an exception object describing what went wrong and stops the current line of execution. If nothing catches that object, the program ends and prints a traceback.

The traceback is the most useful thing on your screen and the thing beginners are most likely to scroll past. Read it from the bottom. The last line names the exception type and its message — ValueError: invalid literal for int() with base 10: 'eighty' — and that is usually enough to fix the problem. The lines above are the call chain that got you there, listed oldest first, so the second-to-last frame is the code that actually failed.

Exception types are themselves information, because they are arranged in a hierarchy. ZeroDivisionError is a kind of ArithmeticError; KeyError and IndexError are both kinds of LookupError; FileNotFoundError and PermissionError are both kinds of OSError. Everything you normally deal with sits under Exception. That structure lets you catch precisely what you expect, or a whole family of related problems.

It is worth being clear about what error handling is for. It is not for hiding bugs. A TypeError because you passed a string where a number was expected is a mistake in your code, and it should crash so you fix it. Handling belongs where failure is a legitimate possibility you cannot prevent — a user typing something odd, a file that has been moved, a network that is down.

Example
# An uncaught exception ends the program and prints a traceback:
#
# Traceback (most recent call last):
#   File "marks.py", line 9, in <module>
#     main()
#   File "marks.py", line 5, in main
#     total = int(value) + 10
#             ^^^^^^^^^^
# ValueError: invalid literal for int() with base 10: 'eighty'
#
# Read the LAST line first: the type and the message.

# The types you will meet most
examples = [
    lambda: 10 / 0,              # ZeroDivisionError
    lambda: int("eighty"),       # ValueError — right type, wrong content
    lambda: "5" + 5,             # TypeError  — wrong type entirely
    lambda: [1, 2][9],           # IndexError
    lambda: {"a": 1}["b"],       # KeyError
    lambda: open("nope.txt"),    # FileNotFoundError
    lambda: "text".appendx(1),   # AttributeError
]

for f in examples:
    try:
        f()
    except Exception as e:
        print(type(e).__name__, "->", e)

# The hierarchy is real and usable
print(issubclass(FileNotFoundError, OSError))    # True
print(issubclass(KeyError, LookupError))         # True
print(issubclass(ZeroDivisionError, Exception))  # True
  • ValueError — right type, unusable value (int("eighty"))
  • TypeError — wrong type altogether ("5" + 5)
  • KeyError / IndexError — no such key or position
  • FileNotFoundError / PermissionError — both kinds of OSError
  • AttributeError — that object has no such method or attribute
  • ZeroDivisionError, ImportError, NameError — the rest of the common set
Notes
  • type(e).__name__ gives the exception's class name as a string, which is handy when logging. Printing the exception itself gives only the message, not the type.

try / except: Catching What You Expect

try marks code that might fail; except SomeError says what to do when a matching exception appears. Python runs the try block, and the moment something raises, it jumps to the first matching except — the rest of the try block never runs.

as e gives you the exception object, whose message is what print(e) shows. Include it in whatever you tell the user or write to a log; "something went wrong" helps nobody, while "invalid literal for int(): 'eighty'" identifies the problem immediately.

You can catch several types, either with separate except clauses or with one clause taking a tuple: except (ValueError, TypeError):. Use separate clauses when the responses differ and a tuple when they do not.

Order matters, and it is a real trap. Python takes the first clause that matches, and a parent class matches all its children. Putting except Exception above except ValueError means the ValueError clause can never run — and Python will not warn you. Always list the specific types first and the general one last.

Finally, keep the try block small. Wrapping thirty lines means an exception from any of them lands in the same handler, and you no longer know which line failed or whether the handler is even appropriate. Wrap the one operation you expect to fail.

Example
# The basic shape
try:
    marks = int(input("Marks: "))
except ValueError as e:
    print(f"Not a whole number ({e}) — using 0")
    marks = 0
print(marks)

# Several types, one response
try:
    value = int(some_input)
except (ValueError, TypeError) as e:
    print("bad input:", e)

# Several types, different responses
try:
    with open("marks.txt", encoding="utf-8") as f:
        first = int(f.readline())
except FileNotFoundError:
    print("marks.txt is missing — run the export first")
except PermissionError:
    print("marks.txt exists but cannot be read — check permissions")
except ValueError:
    print("the first line is not a number")

# ORDER MATTERS: this ValueError clause is unreachable
try:
    int("x")
except Exception:
    print("caught by the general clause")
except ValueError:
    print("never runs — Exception already matched")

# Keep the try block small
# Bad: any of these failing lands in the same handler
try:
    data = load_file()
    total = int(data["marks"]) * 2
    save(total)
except ValueError:
    print("something, somewhere, was not a number")

# Better: wrap only what you expect to fail
data = load_file()
try:
    total = int(data["marks"]) * 2
except ValueError:
    print(f"marks field is not a number: {data['marks']!r}")
    total = 0
save(total)
Notes
  • An except clause only catches exceptions raised inside its own try block. An exception raised inside the except block itself is not caught by a later clause of the same statement — it propagates out.

Bare except and the Bugs It Hides

A bare except: with no exception type catches absolutely everything, and it is one of the few things in Python that is nearly always wrong. It looks like insurance and behaves like a blindfold.

It catches your typos. A misspelled variable name raises NameError; a wrong method name raises AttributeError. Both are bugs in your code, and a bare except quietly swallows them, so the program keeps running with wrong data and you spend an evening wondering why the totals are off.

It also catches KeyboardInterrupt and SystemExit, which are not errors at all. KeyboardInterrupt is you pressing Ctrl+C; a bare except inside a loop makes that keypress do nothing, so your runaway script becomes genuinely hard to stop. This is because those two inherit from BaseException rather than from Exception.

If you truly need a catch-all — a long-running service that must survive one bad record — write except Exception as e:. That still catches every ordinary error while leaving Ctrl+C alone. And whatever you catch, do something with it: log it with the message and traceback, or re-raise it. The pairing of except with a bare pass is the single most effective way to make a program impossible to debug.

The general principle is worth stating plainly: catch the narrowest exception you can name, at the place where you actually know what to do about it. Everywhere else, let it travel up. An exception that reaches the top and prints a traceback has told you the truth; one that was swallowed three functions down has told you nothing.

Example
records = ["87", "92", "seventy", "78"]

# Never do this
total = 0
for r in records:
    try:
        total += int(r)
    except:            # catches typos, Ctrl+C, everything
        pass           # ...and says nothing about any of it
print(total)           # 257, silently missing a record

# What a bare except hides
try:
    print(totl)        # NameError — a typo, i.e. a bug
except:
    pass               # you will never find out

# If you must be broad, be broad deliberately
import logging

for r in records:
    try:
        total += int(r)
    except ValueError as e:
        logging.warning("skipping bad record %r: %s", r, e)
    except Exception:            # still leaves Ctrl+C alone
        logging.exception("unexpected failure on %r", r)
        raise                    # and does not pretend it was fine

# Why Ctrl+C escapes 'except Exception'
print(issubclass(KeyboardInterrupt, Exception))      # False
print(issubclass(KeyboardInterrupt, BaseException))  # True
print(issubclass(ValueError, Exception))             # True
  • except: — catches BaseException, including Ctrl+C; avoid it
  • except Exception: — the honest broad catch; leaves Ctrl+C and exit alone
  • except ValueError: — name what you expect; this is the default habit
  • except ... : pass — silences the one thing that could have helped you
  • logging.exception(msg) inside an except records the full traceback
  • Catch where you can act; otherwise let it travel up
Notes
  • There is one respectable use for a broad catch: the outermost loop of a long-running program, where one bad item must not stop the rest. Even there, log the failure with its traceback — the goal is to continue, not to forget.

else, finally, and What with Replaces

The else clause of a try runs only when no exception was raised. Its purpose is to keep the try block down to the one risky line: put the operation that might fail in try, and everything that should happen on success in else. Without it, the follow-up code sits inside try, where an unrelated exception from it would be caught by the wrong handler.

The finally clause runs no matter what — after a successful run, after a handled exception, and on the way out when an exception is propagating. It even runs when the try block contains a return. That makes it the place for cleanup that must happen regardless: closing a connection, releasing a lock, deleting a temporary file.

One warning about finally: a return inside it overrides any return or exception from the try block, including swallowing the exception entirely. That is almost never what anyone intends, so keep finally for cleanup and let control flow happen elsewhere.

In practice you will write finally less often than you might expect, because the with statement already does the job for the common cases. with open(...) closes the file whether or not something failed, which is exactly the try/finally pair written once, correctly, by someone else. When a resource offers a context manager, prefer it; save finally for cleanup that has no such wrapper.

Example
def read_marks(path):
    try:
        f = open(path, encoding="utf-8")
    except FileNotFoundError:
        print(f"{path} not found")
        return []
    else:
        # only runs if the open succeeded
        try:
            return [int(line) for line in f if line.strip()]
        finally:
            f.close()          # runs even though we returned above

# The same thing, using the tool built for it
def read_marks_v2(path):
    try:
        with open(path, encoding="utf-8") as f:
            return [int(line) for line in f if line.strip()]
    except FileNotFoundError:
        print(f"{path} not found")
        return []

# else keeps the try block to the risky line only
try:
    marks = int("87")
except ValueError:
    print("not a number")
else:
    print(f"parsed {marks}")   # a bug here is NOT caught above
finally:
    print("done")              # always

# Do not return from finally
def confusing():
    try:
        raise ValueError("real problem")
    finally:
        return "fine"          # swallows the exception entirely

print(confusing())             # fine  — the ValueError vanished
Notes
  • Order of clauses is fixed: try, then any except clauses, then else, then finally. Writing them in a different order is a SyntaxError, and else without at least one except is not allowed.

raise: Failing Loudly and Usefully

raise creates an exception on purpose. You use it when your function is asked to do something that does not make sense — negative marks, an empty list to average, a withdrawal larger than the balance. Refusing loudly is better than returning a fake value, because the caller finds out at the point of the mistake rather than three functions later.

This is the argument against the tempting alternatives. Returning None on failure means every caller must remember to check, and the one that forgets gets a TypeError somewhere unrelated. Returning an error string from a function that normally returns a number is worse, because the wrong value flows onward and may even print without complaint. An exception cannot be ignored by accident.

Choose a type that matches the problem. ValueError for a value that is the right type but unusable, TypeError for the wrong type, KeyError for a missing entry. And put something useful in the message: include the offending value, because "marks out of range: 250" is a bug report and "invalid input" is not.

Inside an except block, a bare raise re-raises the exception you just caught, preserving the original traceback. That is the pattern for "log it here, but let it continue upward". If you want to raise a different, more meaningful exception instead, use raise NewError(...) from e: the chain is recorded, and the traceback shows both the high-level failure and the low-level cause. Leaving out from e still chains them, but as an incidental "during handling of the above" rather than a stated cause.

Example
def set_marks(marks):
    if not isinstance(marks, int):
        raise TypeError(f"marks must be an int, got {type(marks).__name__}")
    if not 0 <= marks <= 100:
        raise ValueError(f"marks out of range: {marks}")
    return marks

try:
    set_marks(250)
except ValueError as e:
    print("rejected:", e)      # rejected: marks out of range: 250

# Do not do this instead
def average_bad(marks):
    if not marks:
        return None            # every caller must remember to check
    return sum(marks) / len(marks)

def average(marks):
    if not marks:
        raise ValueError("cannot average an empty list")
    return sum(marks) / len(marks)

# Bare raise: log here, let it continue upward
import logging

def load(path):
    try:
        with open(path, encoding="utf-8") as f:
            return f.read()
    except FileNotFoundError:
        logging.error("config missing at %s", path)
        raise                  # same exception, original traceback

# raise ... from e: replace with something meaningful, keep the cause
class ConfigError(Exception):
    pass

def load_port(text):
    try:
        return int(text)
    except ValueError as e:
        raise ConfigError(f"port must be a number, got {text!r}") from e
Notes
  • Do not use assert for validating input. Assertions are removed entirely when Python runs with the -O flag, so a check written as an assert can silently disappear in production. Use if ...: raise for anything that must always be enforced.

Custom Exceptions and Designing for Failure

Your own exception classes cost one line: class InsufficientFunds(Exception): pass. That is enough to be raised, caught by name and distinguished from every built-in type. Give it an __init__ only if callers need structured details — the balance and the amount, say — rather than just a message.

The usual pattern in a real project is one base exception for the whole application and specific classes under it. A caller who cares about a particular failure catches the specific class; one that only wants to keep going catches the base and knows the problem came from your code rather than from a library. Naming them for the business problem rather than the mechanism is what makes the handling code readable.

Python has a distinctive attitude to when to check, summed up in two initialisms. LBYL — look before you leap — tests first: if key in d:. EAFP — easier to ask forgiveness than permission — just tries it and handles the failure. Python leans towards EAFP, partly because it reads better and partly because checking first is not always safe: a file can be deleted between os.path.exists() returning True and your open() call.

The practical guideline is about how often failure happens. If it is rare, EAFP is faster and cleaner, because the try costs almost nothing when nothing goes wrong. If failure is routine — half your input rows are blank — an if is clearer than an exception fired on every second item.

Finally, decide deliberately where each failure is handled. A low-level function usually should not decide what the user sees; it should raise. The layer that knows the context — the command loop, the web handler, the script's main() — catches, explains and decides whether to continue. Getting that split right is most of what "good error handling" means in practice.

Example
class AppError(Exception):
    """Base class for every error this program raises."""

class InsufficientFunds(AppError):
    def __init__(self, balance, amount):
        self.balance = balance
        self.amount = amount
        super().__init__(f"cannot withdraw {amount}; balance is {balance}")

class StudentNotFound(AppError):
    pass

def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFunds(balance, amount)
    return balance - amount

# Callers choose how specific to be
try:
    withdraw(100, 150)
except InsufficientFunds as e:
    print(e)                       # the message
    print(e.amount - e.balance)    # ...and the structured details: 50
except AppError:
    print("some other error from this application")

# LBYL — check first
scores = {"Asha": 87}
if "Ravi" in scores:
    print(scores["Ravi"])
else:
    print("no marks for Ravi")

# EAFP — try it and handle the failure
try:
    print(scores["Ravi"])
except KeyError:
    print("no marks for Ravi")

# Low level raises; the top level decides what the user sees
def main():
    try:
        withdraw(100, 150)
    except AppError as e:
        print(f"Sorry — {e}")
        return 1
    return 0
  • class MyError(Exception): pass — a usable custom exception in one line
  • One base class per application, with specific subclasses under it
  • Name exceptions for the business problem, not the mechanism
  • EAFP (try) when failure is rare; LBYL (if) when it is routine
  • Raise in low-level code; catch where you know what the user should see
  • Store extra details as attributes when callers need more than a message
Notes
  • Inherit from Exception, never from BaseException. Anything under BaseException escapes the ordinary except Exception catch-alls that well-behaved programs rely on.
Ask AI