You are viewing a free preview of this lesson.
Subscribe to unlock all 10 lessons in this course and every other course on LearningBro.
A program that works perfectly on the programmer's own neat test data will still meet a hostile world once it ships: users type letters into number boxes, files go missing, networks drop, and divisions by zero lurk. Exception handling is the mechanism that lets a program detect such runtime problems and respond to them deliberately — recovering, retrying, or failing with a clear message — instead of crashing with an ugly stack trace. This lesson builds the topic up in order: what an exception is and the common types you will meet; the try / except / else / finally structure that catches them; raising your own exceptions to signal errors; defining custom exception types; and finally defensive programming — validating and checking before things go wrong — and how it complements exception handling to produce genuinely robust code that fails gracefully.
The guiding idea is the difference between prevention and recovery. Defensive programming prevents errors by checking inputs and assumptions up front; exception handling recovers from the errors that slip through anyway, especially those outside the program's control. The two are partners, not rivals, and the strongest exam answers — and the strongest code — use both. Examples below are in ```python with OCR-style pseudocode shown where the exam may expect it.
Within H446 2.2.1 this lesson covers run-time error handling and robustness. You should be able to:
try / except (the OCR pseudocode try/exception/endtry) to catch and handle exceptions, including catching several types and an else/finally clause;finally (cleanup) code always runs, and use it to release resources such as files;These points paraphrase the specification; nothing is quoted verbatim.
An exception is an event that occurs during execution and disrupts the normal flow of the program. It is distinct from a syntax error, which the interpreter catches before the program runs. A syntax error is a typo the program will not even start with; an exception is a problem that only appears while running, often because of data or conditions the programmer did not control.
| Term | Meaning |
|---|---|
| Exception | A runtime event that disrupts normal control flow. |
| Raising / throwing | Creating an exception to signal that something went wrong. |
| Catching / handling | Detecting a raised exception and running recovery code. |
| Propagating | An uncaught exception travelling up the call stack to the caller. |
| Stack trace | The chain of calls that led to the exception, shown on a crash. |
When an exception is raised and nothing catches it, it propagates up through the calling functions; if it reaches the top uncaught, the program crashes and prints a stack trace. Exception handling is simply the means of intercepting that exception somewhere useful on the way up.
You should recognise the everyday exceptions and what triggers each — questions often give a fragment and ask which exception it raises.
| Exception (Python) | Triggered by | Example |
|---|---|---|
ValueError | Right type, wrong value | int("hello") |
TypeError | Operation on the wrong type | "text" + 5 |
ZeroDivisionError | Dividing by zero | 10 / 0 |
IndexError | List index out of range | [1, 2][5] |
KeyError | Dictionary key absent | {"a": 1}["b"] |
FileNotFoundError | Opening a file that is not there | open("missing.txt") |
PermissionError | Insufficient OS permissions | writing to a protected file |
AttributeError | Method/attribute does not exist | "abc".push() |
KeyboardInterrupt | User presses Ctrl-C | — |
A nuance worth knowing: ValueError and TypeError are easily confused. int("hello") is a ValueError because a string is the right type for int() to accept — it is the value "hello" that cannot be converted. "text" + 5 is a TypeError because string-plus-integer is an operation that makes no sense for those types.
To see where an exception can be caught, follow one as it propagates. Suppose three functions call one another and the innermost divides by zero:
def level_three(x):
return 10 / x # raises ZeroDivisionError when x == 0
def level_two(x):
return level_three(x) # no handler here -> exception passes straight through
def level_one(x):
try:
return level_two(x) # the handler is here, two levels up from the error
except ZeroDivisionError:
print("Caught at level one")
return None
print(level_one(0)) # prints "Caught at level one", then None
The exception is raised in level_three but neither level_three nor level_two catches it, so it propagates up the call stack — popping each frame — until level_one's try/except intercepts it. The key insight is that an exception need not be handled where it occurs: it can be caught by any enclosing caller, which lets low-level code stay simple and report failures to whichever higher level is best placed to decide what to do. Had no function caught it, the exception would reach the top level and crash the program with a stack trace listing all three calls.
The try / except structure is the core mechanism. Code that might fail goes in the try block; if an exception is raised there, control jumps to a matching except block.
try:
result = 10 / 0 # this raises ZeroDivisionError
except ZeroDivisionError:
print("Error: cannot divide by zero") # runs only if that error occurs
finally:
print("This always runs") # runs whether or not an error occurred
List the specific exceptions you expect, most specific first, with a general fallback last:
try:
number = int(input("Enter a number: ")) # may raise ValueError
result = 100 / number # may raise ZeroDivisionError
print(f"Result: {result}")
except ValueError:
print("Please enter a valid whole number")
except ZeroDivisionError:
print("Cannot divide by zero")
except Exception as error: # last-resort catch-all for the unexpected
print(f"Unexpected error: {error}")
else and finallyelse runs only when no exception was raised; finally runs always, making it the right place for cleanup:
try:
number = int(input("Enter a number: "))
except ValueError:
print("Invalid input")
else:
print(f"You entered {number}") # only when the try succeeded
finally:
print("Done") # always, success or failure
flowchart TB
T["try block runs"]
T -- "no exception" --> E["else block"]
E --> F["finally block (always)"]
T -- "exception raised" --> M{"matching except?"}
M -- "yes" --> H["handle in except block"]
H --> F
M -- "no" --> P["propagate up the call stack"]
The diagram captures the key facts: on success the path is try → else → finally; on a caught error it is try → except → finally; and if no except matches, the exception propagates out (after finally still runs).
# OCR-style pseudocode for the same idea
try
number = input("Enter a number: ")
number = int(number)
result = 100 / number
print(result)
exception ValueError
print("Please enter a valid number")
exception ZeroDivisionError
print("Cannot divide by zero")
endtry
Exam Tip:
finallyruns even if thetryorexceptblock contains areturn. That guarantee is exactly why it is used to close files and release resources — the cleanup happens no matter how the block is left.
You can raise an exception yourself to signal that a rule has been broken. This is how a function refuses bad input rather than carrying on with a nonsensical value.
def set_age(age: int) -> int:
if age < 0:
raise ValueError("Age cannot be negative")
if age > 150:
raise ValueError("Age cannot exceed 150")
return age
try:
set_age(-5)
except ValueError as error:
print(f"Rejected: {error}") # Rejected: Age cannot be negative
Raising an exception stops the function immediately and hands the problem to the caller to handle. This is often better than returning a special "error" value (like -1 or None), because the caller cannot silently ignore an exception the way it might ignore a returned error code — the exception forces a decision.
When to raise: when validation fails, when a function's precondition is not met, when an impossible state is detected, or when a needed resource is unavailable.
For application-specific errors, define your own exception class by inheriting from Exception. A custom exception can carry extra data and be caught specifically, separately from unrelated errors.
class InsufficientFundsError(Exception):
"""Raised when a withdrawal exceeds the available balance."""
def __init__(self, balance: float, amount: float):
self.balance = balance
self.amount = amount
# build a helpful message for the default string form
super().__init__(f"Cannot withdraw {amount}: only {balance} available")
class BankAccount:
def __init__(self, balance: float):
self.__balance = balance # encapsulated state
def withdraw(self, amount: float) -> None:
if amount > self.__balance:
raise InsufficientFundsError(self.__balance, amount)
self.__balance -= amount
account = BankAccount(100)
try:
account.withdraw(200)
except InsufficientFundsError as error:
print(error) # Cannot withdraw 200: only 100 available
print(f"Short by {error.amount - error.balance}") # Short by 100
Because InsufficientFundsError is its own type, a caller can catch it specifically — handling a money problem differently from, say, a ValueError — and read the attached balance and amount to build a tailored response. This links to encapsulation and inheritance from the OOP lessons: the custom exception is-a Exception and bundles the relevant state with the error.
| Benefit of a custom exception | What it gives you |
|---|---|
| Descriptive type name | InsufficientFundsError documents the failure far better than a bare ValueError. |
| Carries data | The balance and amount travel with the error, so the handler can report specifics. |
| Caught selectively | Callers can handle this error differently from unrelated ones, because it is its own type. |
| Self-documenting code | raise InsufficientFundsError(...) reads as a statement of intent. |
| Hierarchies | You can derive several related exceptions from one base (e.g. an AccountError family) and catch the whole family at once. |
There are two broad styles for dealing with something that might fail, and the prevent-versus-recover theme runs straight through them.
Look before you leap (LBYL) checks the condition before acting — pure defensive programming:
# LBYL: validate the key exists before using it
scores = {"Alice": 80}
if "Bob" in scores: # check first
print(scores["Bob"])
else:
print("No score for Bob")
Ask forgiveness (EAFP — "easier to ask forgiveness than permission") just tries the action and catches the failure — exception handling:
# EAFP: attempt it, recover if it fails
scores = {"Alice": 80}
try:
print(scores["Bob"]) # may raise KeyError
except KeyError:
print("No score for Bob")
Both produce the same result here, and the choice is partly stylistic. LBYL reads naturally when the check is cheap and certain. EAFP is preferred when the check and the action could disagree in between — for example, a file might exist when you check but be deleted a microsecond before you open it (a race condition), so checking first is not actually safe; trying and catching is. This is the deeper reason file and network operations favour try/except: only attempting the operation tells you the truth at the exact moment it matters.
Defensive programming anticipates problems and guards against them before they cause an exception. Where exception handling is a safety net, defensive programming is the rail that stops you falling in the first place.
The single most important defensive technique is never trusting input. This loop refuses to return until the user supplies a valid positive integer — combining a try (to survive non-numbers) with a range check:
def get_positive_integer(prompt: str) -> int:
while True:
try:
value = int(input(prompt)) # guards against non-numbers
if value <= 0:
print("Please enter a positive number")
continue # re-prompt
return value # only valid values escape the loop
except ValueError:
print("Please enter a valid whole number")
Check indices and ranges before using them, so an IndexError never occurs:
def get_element(items: list, index: int):
if index < 0 or index >= len(items): # validate before access
print(f"Index {index} out of range (0..{len(items) - 1})")
return None
return items[index]
def process(data):
if data is None: # guard against missing data
print("Error: no data provided")
return
print(f"Processing {len(data)} items")
| Technique | Purpose |
|---|---|
| Input validation | Reject bad input at the boundary before it spreads |
| Boundary / range checking | Keep indices and values inside valid limits |
| Type checking | Confirm a value is the expected type before operating on it |
| None / null checks | Handle absent or undefined values |
| Sanitisation | Clean and normalise input (trim, case-fold, strip control chars) |
| Assertions | Document and check assumptions during development |
Exam Tip: Defensive programming and exception handling are complementary, not alternatives. Defensive programming prevents errors you can foresee (bad user input, out-of-range indices); exception handling recovers from errors you cannot prevent (the file is deleted while running). Strong answers show both.
A useful rule of thumb: validate what you can check in advance; catch what you cannot. You can check that a number is in range before using it (prevention). You cannot check in advance that a file will still exist at the instant you open it — another process might delete it — so opening a file is naturally wrapped in try/except (recovery).
Subscribe to continue reading
Get full access to this lesson and all 10 lessons in this course.