You are viewing a free preview of this lesson.
Subscribe to unlock all 10 lessons in this course and every other course on LearningBro.
This lesson covers exception handling — the structured mechanism by which a program detects a run-time error, reports it, and recovers gracefully instead of crashing. Exception handling is the cornerstone of writing robust software: programs that behave sensibly when given bad input, missing files or unexpected conditions. It works hand in hand with defensive programming and is closely tied to the object-oriented and procedural paradigms.
This lesson addresses structured exception handling and the writing of robust programs within the Fundamentals of programming section of the AQA A-Level Computer Science (7517) specification (subject content area 4.1.1, with links to program design and testing in 4.1.3). It covers: the distinction between syntax, run-time and logic errors; what an exception is and how it disrupts control flow; try/except/finally (try/catch) structures; handling multiple specific exception types; deliberately raising (throwing) exceptions; defining custom exception classes (which links to inheritance, 4.1.2); and defensive-programming practices such as input validation and authentication of data. You are expected to add exception handling to code, explain why a program crashes, and evaluate whether handling is appropriate.
It helps to place exceptions among the three categories of program error:
| Error type | When detected | Example | Caught by exception handling? |
|---|---|---|---|
| Syntax error | Before execution (compile/parse) | A missing colon or bracket | No — the program will not run at all |
| Run-time error (exception) | During execution | Dividing by zero; opening a missing file | Yes |
| Logic error | Program runs but gives wrong results | Using + where * was intended | No — the program does not "fail loudly" |
An exception is therefore a run-time event that disrupts the normal flow of instructions. Common causes include:
| Cause | Example |
|---|---|
| Invalid input | A user types "abc" when a number is expected. |
| Division by zero | Evaluating 100 / 0. |
| File not found | Opening a file that does not exist. |
| Index out of range | Accessing an array element beyond its bounds. |
| Type errors | Applying an operation to an incompatible type. |
| Network errors | A connection to a server fails. |
Without handling, any of these aborts the program and dumps a stack trace — unhelpful and alarming for an end user. Exception handling lets the program intercept the error and respond appropriately.
Exception handling uses a structured block: a try clause containing the risky code; one or more except clauses (called catch in Java/C#) that respond to particular errors; and an optional finally clause that always runs.
# AQA-style pseudocode
TRY
x = INPUT("Enter a number: ")
x = INT(x)
result = 100 / x
OUTPUT "Result: " + STR(result)
EXCEPT ZeroDivisionError
OUTPUT "Error: Cannot divide by zero."
EXCEPT ValueError
OUTPUT "Error: Please enter a valid integer."
FINALLY
OUTPUT "Program complete."
END TRY
try:
x = int(input("Enter a number: "))
result = 100 / x
print(f"Result: {result}")
except ZeroDivisionError:
print("Error: Cannot divide by zero.")
except ValueError:
print("Error: Please enter a valid integer.")
finally:
print("Program complete.")
The control flow of a try block is worth internalising, because exam questions often ask which lines run for a given input.
flowchart TB
A["Enter try block"] --> B{"Exception raised?"}
B -- No --> C["Run remaining try code"]
B -- Yes --> D{"Matching except clause?"}
D -- Yes --> E["Run that except handler"]
D -- No --> F["Propagate to caller / crash"]
C --> G["Run finally block"]
E --> G
F --> G
G --> H["Continue after the try statement"]
The crucial points: the moment an exception is raised, the rest of the try body is skipped; control jumps to the first matching except; if none matches the exception propagates up the call stack; and the finally block runs in every path — whether the code succeeded, was handled, or is propagating.
This "skip the rest of the try" behaviour has an important implication for where you place code. Any statement after the line that might raise, but still inside the try, will not run if that line fails. So if three statements must either all happen or none happen, putting them in one try gives a useful all-or-nothing quality — but if a later statement should run regardless of an earlier failure, it must go outside the try or in finally. Reasoning carefully about exactly which lines are protected, and therefore which are skipped on failure, is the key to predicting a program's behaviour under error conditions, and it is precisely what "trace this code for the given input" questions assess.
| Exception | When it occurs |
|---|---|
ValueError | Right type but inappropriate value (int("abc")). |
TypeError | Operation applied to the wrong type ("hello" + 5). |
ZeroDivisionError | Division or modulo by zero. |
IndexError | List/array index outside its bounds. |
KeyError | Dictionary key not present. |
FileNotFoundError | Requested file or directory does not exist. |
IOError / OSError | An input/output operation fails. |
OverflowError | A numeric result is too large to represent. |
RecursionError | Maximum recursion depth exceeded (see the recursion lesson). |
These types form an inheritance hierarchy rooted at BaseException, with Exception as the base of almost all everyday errors. Because ValueError is an Exception (an "is-a" relationship), an except Exception clause will also catch a ValueError — a direct application of polymorphism from the OOP lessons.
Separate except clauses give each error its own tailored response. Order matters: more specific exceptions must precede more general ones, because the first matching clause wins.
def safe_divide(a, b):
try:
return a / b
except ZeroDivisionError: # specific
print("Cannot divide by zero")
return None
except TypeError: # specific
print("Both arguments must be numbers")
return None
try:
risky_operation()
except Exception as e:
print(f"An error occurred: {e}")
Exam Tip: A bare
except(orexcept Exception) catches everything, which can mask genuine bugs by swallowing exceptions you did not anticipate. Best practice is to catch the specific exceptions you can actually handle. A broad catch is acceptable only as a deliberate top-level "safety net" that logs the error.
finally blockThe finally block runs unconditionally — after success, after a handled exception, and even while an unhandled exception is propagating. It is the correct place for clean-up that must happen no matter what: closing files, releasing locks, closing database or network connections.
def read_file(filename: str) -> str:
file = None
try:
file = open(filename, "r")
return file.read()
except FileNotFoundError:
print(f"Error: {filename} not found")
return ""
finally:
if file:
file.close() # guaranteed to run, even after 'return'
print("File closed")
Note that finally runs even though the try and except branches contain return statements — a point examiners like to probe. (In Python, the with statement provides the same guarantee more concisely for files; see the file-processing lesson.)
You can deliberately raise (Python/pseudocode raise/THROW) an exception to signal that a precondition has been violated. This is how a method reports an error to its caller and lets the caller decide what to do.
# AQA-style pseudocode
PROCEDURE setAge(age: INTEGER)
IF age < 0 OR age > 150 THEN
THROW ValueError("Age must be between 0 and 150")
END IF
this.age = age
END PROCEDURE
def set_age(self, age: int) -> None:
if not 0 <= age <= 150:
raise ValueError("Age must be between 0 and 150")
self.__age = age
Raising exceptions is especially natural in OOP: a setter validates its argument and raises on bad data rather than silently storing it, preserving the class invariant introduced in the encapsulation lesson. Crucially, the method does not decide how to recover — it merely reports — keeping the class decoupled from the policy of whatever code is using it.
Defining your own exception type makes error handling expressive and lets callers catch your error specifically. A custom exception is simply a class that inherits from Exception — a direct use of inheritance (4.1.2).
class InsufficientFundsError(Exception):
"""Raised when a withdrawal exceeds the available balance."""
def __init__(self, balance: float, amount: float):
self.balance = balance
self.amount = amount
super().__init__(
f"Cannot withdraw {amount}: only {balance} available"
)
class BankAccount:
def __init__(self, balance: float):
self.__balance = balance
def withdraw(self, amount: float) -> float:
if amount > self.__balance:
raise InsufficientFundsError(self.__balance, amount)
self.__balance -= amount
return self.__balance
account = BankAccount(100)
try:
account.withdraw(150)
except InsufficientFundsError as e:
print(e) # Cannot withdraw 150: only 100 available
print(e.balance) # 100 — extra context carried on the exception
Because the exception object carries the balance and amount, the handler has rich context to log or display — far better than a generic message.
| Practice | Reason |
|---|---|
| Catch specific exceptions | Avoids masking unexpected bugs. |
Use finally (or with) for clean-up | Guarantees resources are released even on error. |
| Don't use exceptions for ordinary control flow | They signal exceptional conditions, not normal logic; misuse hurts clarity and speed. |
| Provide informative messages | Aids debugging and the user experience. |
| Raise early ("fail fast") | Detect invalid data at the boundary, before it corrupts state. |
| Log exceptions in production | Enables diagnosis after the fact. |
Exam Tip: When asked to add handling to a snippet, name the specific exception (
except ValueError) and explain what it catches and why. Generictry/exceptwith no type, or with no explanation, loses the precision marks.
Defensive programming is the discipline of writing code that anticipates misuse and invalid input. Exception handling is one tool among several:
assert statements that check conditions which should always hold; used to catch programmer errors during development.IndexError).def calculate_average(numbers: list) -> float:
assert isinstance(numbers, list), "Input must be a list" # programmer check
if len(numbers) == 0:
raise ValueError("Cannot calculate average of empty list") # input check
return sum(numbers) / len(numbers)
Validation and exceptions are complementary: validation prevents many errors up front, while exception handling copes with the errors that slip through or are genuinely unforeseeable (such as a disk failing mid-write).
A subtle but examinable behaviour is propagation: when an exception is raised and the current subroutine has no matching handler, the exception does not vanish — it is passed up the call stack to the caller, then that caller's caller, and so on, until a matching except is found. If it reaches the top with no handler, the program crashes. This means you can raise an error deep inside your code and catch it at a single sensible place far higher up.
def parse_age(text: str) -> int:
return int(text) # may raise ValueError, NOT handled here
def load_record(text: str) -> int:
return parse_age(text) # no handler here either — just passes through
def main():
try:
age = load_record("abc") # the only handler is here, at the top
except ValueError:
print("Invalid age in record; using default")
age = 0
print("Age is", age)
main()
The ValueError is raised in parse_age, propagates up through load_record (which has no handler), and is finally caught in main. The table traces the exception's journey up the stack:
| Stack level | Subroutine | Has a matching handler? | Action |
|---|---|---|---|
| 3 (deepest) | parse_age | No | int("abc") raises ValueError; propagate up |
| 2 | load_record | No | Frame discarded; propagate up |
| 1 (top) | main | Yes (except ValueError) | Handled; program recovers |
This is the same call stack that manages recursion (4.2). The benefit is separation of concerns: low-level routines report problems by raising, and a single high-level routine decides the recovery policy, instead of cluttering every function with handling.
Python's exceptions are organised as an inheritance tree, which directly explains why except Exception catches so many different errors. A simplified view of the hierarchy:
flowchart TB
BE["BaseException"] --> EX["Exception"]
BE --> SE["SystemExit"]
BE --> KI["KeyboardInterrupt"]
EX --> AE["ArithmeticError"]
EX --> LE["LookupError"]
EX --> VE["ValueError"]
EX --> OSE["OSError"]
AE --> ZD["ZeroDivisionError"]
LE --> IE["IndexError"]
LE --> KE["KeyError"]
OSE --> FNF["FileNotFoundError"]
Because ZeroDivisionError is an ArithmeticError is an Exception, a handler written as except ArithmeticError will also catch a ZeroDivisionError — catching a superclass catches all its subclasses. This is polymorphism (4.1.2) applied to error handling, and it explains the earlier warning about clause order: a broad superclass handler placed first will intercept the specific subclass errors before their own handlers are reached. Note too that SystemExit and KeyboardInterrupt deliberately sit outside Exception, which is why except Exception does not (and should not) swallow a user pressing Ctrl-C.
A very common real-world and exam pattern combines a loop with exception handling to keep asking the user until they supply valid data — the program never crashes on bad input and never proceeds with it either.
def get_positive_int(prompt: str) -> int:
while True: # loop until valid input is returned
try:
value = int(input(prompt)) # may raise ValueError
if value <= 0:
raise ValueError("must be positive")
return value # only reached on fully valid input
except ValueError as e:
print(f"Invalid input ({e}). Please try again.")
age = get_positive_int("Enter your age: ")
Subscribe to continue reading
Get full access to this lesson and all 10 lessons in this course.