Expect the Unexpected

Every programmer makes mistakes. The difference between a beginner and an expert? Experts plan for errors.

Think about it: what happens if a user types their name when your program is asking for a number? What if you try to open a file that doesn’t exist? What if your code tries to divide by zero? Without error handling, your program would simply crash — it stops running and shows an angry red message. That’s a terrible experience for the person using your program.

Errors aren’t failures — they’re information. When something goes wrong, Python tells you exactly what happened and where it happened. Once you learn to read error messages, they become your best friend for debugging. And once you learn to handle errors, you can write programs that recover gracefully instead of crashing.

You see error handling everywhere in the real world. When a website says “Are you sure you want to delete this?”, that’s handling a potential mistake before it happens. When a form highlights a field in red and says “Please enter a valid email address”, that’s catching bad input. When your phone says “No internet connection — try again later” instead of just freezing, that’s graceful error handling. Professional software is full of it.

By the end of this chapter, you will be able to:

Three Types of Errors (GCSE Essential)

In your GCSE exam, you must know the three categories of programming error. Understanding these is fundamental to writing reliable software and debugging effectively.

1. Syntax Errors

A syntax error occurs when your code breaks Python’s grammar rules. Python cannot understand the instruction, so it refuses to run the program at all. Syntax errors are detected before the program runs.

Python Code
# Syntax Error - missing closing bracket
print("Hello world"
(click "Run Code" to see output)

How to identify: Python says SyntaxError and points to the line where it got confused. Common causes: missing colons, brackets, quotation marks, or incorrect indentation.

How to fix: Read the error message carefully. Check the line number it points to and the line above it — sometimes the real mistake is on the previous line.

2. Runtime Errors

A runtime error (also called an exception) occurs when the code is valid Python but something goes wrong while the program is running. The syntax is correct, but the operation is impossible.

Python Code
# Runtime Error - valid syntax, but crashes during execution
number = 10
result = number / 0  # ZeroDivisionError!
print(result)
(click "Run Code" to see output)

How to identify: The program starts running but crashes partway through. Python shows the error type (e.g. ZeroDivisionError) and a description.

How to fix: Use try/except blocks to catch the error gracefully, or add validation checks before the risky operation.

3. Logic Errors

A logic error is the trickiest type. The program runs without crashing but produces the wrong result. Python does not report any error because the code is technically valid — it just does not do what you intended.

Python Code
# Logic Error - runs fine but gives the WRONG answer!
score1 = 80
score2 = 70
score3 = 90

# Bug: division happens before addition (operator precedence!)
average = score1 + score2 + score3 / 3
print("Average:", average)  # Prints 180.0 instead of 80.0!

# Correct version:
correct_average = (score1 + score2 + score3) / 3
print("Correct average:", correct_average)
(click "Run Code" to see output)

How to identify: Logic errors are the hardest to find because Python gives no error message. You only notice them when you test your program and the output is not what you expected. This is why testing is so important.

How to fix: Trace through your code line by line, checking the value of each variable. Use print() statements to display intermediate values. Compare expected output with actual output using test data.

Key Concept: The Three Error Types at a Glance Syntax Error = Python cannot understand your code (detected before running).
Runtime Error = Code is valid but crashes during execution (detected while running).
Logic Error = Code runs without crashing but produces wrong results (detected by testing).

See It in Action

Example 1: Common Errors

Let’s see what errors look like. Python has many different error types, each telling you something specific about what went wrong. Don’t worry — errors won’t break anything!

Python Code
# NameError - using a variable that doesn't exist
print(my_score)
(click "Run Code" to see output)
Python Code
# TypeError - trying to add a string and a number
result = "Age: " + 15
print(result)
(click "Run Code" to see output)
Python Code
# ZeroDivisionError - dividing by zero
answer = 100 / 0
print(answer)
(click "Run Code" to see output)

Each error message has two important parts: the error type (like NameError) and a description that explains what went wrong.

Try This: Edit the code above to cause different errors. What happens if you try print("hello" * "world")? What about int("abc")? Read the error messages carefully.

Example 2: Try/Except to the Rescue

We can catch errors before they crash our program using try and except. Put the “risky” code inside a try block, and if an error occurs, Python jumps to the except block instead of crashing.

Python Code
# Without try/except, this would crash!
try:
    result = 10 / 0
except ZeroDivisionError:
    print("Can't divide by zero!")

print("Program keeps running!")
(click "Run Code" to see output)

The program doesn’t crash. When Python hits the error inside the try block, it jumps to the except block. Then it carries on as normal.

Python Code
# A practical example: safely converting user input
user_input = "hello"  # Imagine the user typed this

try:
    number = int(user_input)
    print("You entered:", number)
except ValueError:
    print("That's not a valid number!")
    print("You typed:", user_input)
(click "Run Code" to see output)
Try This: Change user_input to "42" and run again. What happens when the conversion succeeds? Try "3.14" or "" too.

Example 3: Multiple Except Blocks and Finally

You can use multiple except blocks to handle each type of error differently, and a finally block that always runs.

Python Code
# Handling different errors differently
def divide(a, b):
    try:
        result = a / b
        print("Result:", result)
    except ZeroDivisionError:
        print("Error: You can't divide by zero!")
    except TypeError:
        print("Error: Both values must be numbers!")
    finally:
        print("--- Calculation attempt finished ---")

print("Test 1: Normal division")
divide(10, 3)
print()
print("Test 2: Division by zero")
divide(10, 0)
print()
print("Test 3: Wrong type")
divide("ten", 2)
(click "Run Code" to see output)

The finally block runs every single time, making it perfect for clean-up tasks like closing a file or saving progress.

Try This: Add another test: divide(10, "two"). Which except block catches it? Try divide() with no arguments — what error do you get?

Example 4: Input Validation Loop

In real programs, when a user enters invalid input, you ask them again. This uses a while loop with try/except. Since we cannot use input() in the browser, we simulate attempts with a list.

Python Code
# Simulating an input validation loop
user_attempts = ["abc", "twelve", "3.5", "7"]
attempt_index = 0
valid_number = None

while valid_number is None:
    user_input = user_attempts[attempt_index]
    print(f"User types: '{user_input}'")
    attempt_index += 1
    try:
        valid_number = int(user_input)
        print(f"Valid! The number is {valid_number}")
    except ValueError:
        print("That's not a whole number. Please try again.\n")

print(f"\nSuccessfully got a valid number after {attempt_index} attempt(s).")
(click "Run Code" to see output)

The loop keeps running until a valid integer is entered. Each time the conversion fails, the except block catches the error and the loop goes around again.

Try This: Change the list so the first value is valid (e.g. ["42", "abc"]). What if none are valid — what would happen?

Example 5: Accessing the Error Message

You can capture the error object using as and display its message. This is incredibly useful for debugging.

Python Code
# Capturing the error message with 'as'
test_values = ["42", "hello", "3.14", "", "99"]

for value in test_values:
    try:
        number = int(value)
        print(f"'{value}' converted to {number}")
    except ValueError as e:
        print(f"'{value}' failed: {e}")

print("\nAll values processed!")
(click "Run Code" to see output)

The as e part captures the error into a variable. When you print e, Python shows the detailed error message, telling you exactly what caused the problem.

Try This: Try as e with other error types. Create a list and access my_list[99] inside a try block, catching IndexError as e. What message does it give?

Example 6: Raising Your Own Errors

You can create and raise your own errors using the raise keyword. This is useful for custom validation — when the value is technically the right type but does not meet your program’s rules.

Python Code
# Raising your own errors for custom validation
def set_age(age):
    if not isinstance(age, int):
        raise TypeError("Age must be a whole number")
    if age < 0:
        raise ValueError("Age must be positive")
    if age > 150:
        raise ValueError("Age cannot be greater than 150")
    print(f"Age set to {age}")

# Test with various inputs
test_ages = [25, -3, 200, "twenty", 0, 150]

for age in test_ages:
    try:
        set_age(age)
    except (ValueError, TypeError) as e:
        print(f"Invalid age '{age}': {e}")
(click "Run Code" to see output)

The raise keyword throws an error on purpose with a custom message. This is defensive programming — checking for invalid data before it causes problems deeper in your code.

Try This: Modify set_age to also reject ages that are not whole numbers (e.g. 25.5). Add 25.5 to the test list.

How Does It Work?

Common Exception Types

Python has many built-in error types (officially called exceptions). Here are the most common:

Error TypeWhat Causes ItExample
SyntaxErrorCode breaks Python’s grammar rulesprint("hello" — missing bracket
NameErrorVariable doesn’t existprint(score) when score was never created
TypeErrorWrong type of data"hello" + 5
ValueErrorRight type, wrong valueint("abc")
ZeroDivisionErrorDividing by zero10 / 0
IndexErrorList index doesn’t existmy_list[99] when list has 3 items
KeyErrorDictionary key doesn’t existmy_dict["age"] when no "age" key

Important: SyntaxError is different — Python can’t even start running the code. You cannot catch it with try/except.

The Try/Except Structure

Python Code
# The basic structure
try:
    # Code that might cause an error
    pass
except SomeErrorType:
    # What to do if that specific error happens
    pass
except AnotherErrorType:
    # What to do if a different error happens
    pass
finally:
    # Code that ALWAYS runs, error or not (optional)
    pass

How Python executes this:

  1. Python tries to run the code inside the try block
  2. If no error occurs, it skips all except blocks and runs finally
  3. If an error occurs, Python stops the try block, finds the matching except, runs it, then runs finally
  4. If no matching except is found, the program crashes (but finally still runs first)

Catching Specific vs General Errors

Python Code
# Catching ANY error (use with caution!)
try:
    result = 10 / 0
except:
    print("Something went wrong!")

# Better: catch the specific error
try:
    result = 10 / 0
except ZeroDivisionError:
    print("Can't divide by zero!")
(click "Run Code" to see output)
Key Concept: Always Catch Specific Errors When Possible A bare except: catches every error, including ones you didn’t expect. This can hide bugs. Always name the specific error types you expect, like except ValueError:. Unexpected errors should still show up so you can fix them.

The Finally Block

The finally block always runs — whether the try succeeded, an error was caught, or even if an error was not caught. Ideal for:

Common Misconceptions

Watch out: Wrapping everything in try/except is not always a good idea. Catch errors you expect and know how to recover from. If your code has a NameError because you misspelt a variable, you want to see that error so you can fix it — not sweep it under the carpet!
Watch out: SyntaxError cannot be caught with try/except. If Python can’t understand your code at all, it never gets as far as running the try block. The fix is always to correct the code itself.

Test Data (GCSE Essential)

Once you have written a program, how do you know it works? You test it. Professional developers use three types of test data to make sure their programs handle every situation.

The Three Types of Test Data

TypeDescriptionPurpose
Normal dataTypical, expected values that should be acceptedCheck the program works for everyday inputs
Boundary dataValues at the very edges of the valid rangeCheck limits are enforced correctly (off-by-one errors are common!)
Erroneous dataInvalid values — wrong type or out-of-rangeCheck the program handles bad input gracefully

Example: Test Data for “Age” Input (valid range 0–120)

Test DataTypeExpected Outcome
25NormalAccepted — typical age
65NormalAccepted — another typical value
0BoundaryAccepted — minimum valid age
120BoundaryAccepted — maximum valid age
-1Boundary (erroneous side)Rejected — just below minimum
121Boundary (erroneous side)Rejected — just above maximum
"abc"ErroneousRejected — not a number
-50ErroneousRejected — well below range
999ErroneousRejected — well above range
Key Concept: Why Boundary Testing Matters Many bugs hide at the edges. A classic mistake is using < instead of <=, meaning a value of exactly 120 would be rejected when it should be accepted. Boundary testing catches these “off-by-one” errors. Always test values at and around the limits.

Worked Example: Building Robust Input Validation

Let’s build a function that validates a test score. The score must be an integer between 0 and 100 (inclusive). We then test it with normal, boundary, and erroneous data.

Python Code
def validate_score(value):
    """Validate that a test score is an integer between 0 and 100."""
    try:
        score = int(value)
    except (ValueError, TypeError):
        return f"REJECTED '{value}': not a valid whole number"
    if score < 0:
        return f"REJECTED {score}: score cannot be negative"
    if score > 100:
        return f"REJECTED {score}: score cannot exceed 100"
    return f"ACCEPTED: {score} is a valid score"

# --- Normal test data ---
print("=== Normal Data ===")
print(validate_score(55))
print(validate_score(73))

# --- Boundary test data ---
print("\n=== Boundary Data ===")
print(validate_score(0))     # Lower boundary
print(validate_score(100))   # Upper boundary
print(validate_score(-1))    # Just below
print(validate_score(101))   # Just above

# --- Erroneous test data ---
print("\n=== Erroneous Data ===")
print(validate_score("abc"))
print(validate_score(""))
print(validate_score(-500))
print(validate_score(999))
(click "Run Code" to see output)

The function handles every type of bad input without crashing. The try/except catches values that cannot be converted, while if statements catch out-of-range values. This is defensive programming in action.

Try This: Add a counter that tracks how many values were accepted vs rejected. Can you also label each test as normal, boundary, or erroneous?

Real-World Applications

Error handling is not just an exam topic — it is a critical part of every piece of software you use daily.

ATM Machines

When you use a cash machine, error handling runs constantly behind the scenes. Wrong PIN? The machine asks you to try again. Insufficient funds? It says so politely. Card expired? It detects this and refuses the transaction. Every scenario is handled by try/except-style logic — the ATM never crashes.

Web Servers

Every time you visit a website, a web server processes your request. A non-existent URL returns a 404 Not Found error page. A server-side bug returns a 500 Internal Server Error. These are error handling in action — the server responds gracefully rather than crashing entirely.

Video Games

Imagine loading a corrupted save file. A well-written game tells you the file is damaged and offers to start a new game, rather than crashing to the desktop. Games handle controller disconnections, network drops, and invalid inputs — all without crashing.

Medical Software

Software in hospitals must never crash — lives depend on it. Heart monitors, ventilators, and medication systems all use extensive error handling so that if a sensor fails or data is corrupted, the system continues operating safely and alerts medical staff.

Space Software

NASA’s Mars rovers operate millions of miles from the nearest engineer. A signal takes up to 24 minutes to reach Mars, so the rover must handle errors autonomously. If a wheel gets stuck or a sensor malfunctions, the software catches the error, enters safe mode, and attempts recovery on its own.

Did You Know?

The Ariane 5 Disaster (1996) The European Space Agency’s Ariane 5 rocket exploded 37 seconds after launch because of an unhandled integer overflow error. A 64-bit number was converted to a 16-bit integer, but the value was too large. The software had no error handling for this case, so it crashed, sent garbage data to the navigation system, and the rocket veered off course and self-destructed. Cost: approximately £290 million. One missing error handler destroyed an entire rocket.
The First Computer “Bug” (1947) The term “bug” was popularised when engineers working on the Harvard Mark II computer found an actual moth trapped in one of the machine’s electrical relays. Grace Hopper taped the moth into the logbook with the note “First actual case of bug being found.” The moth is still preserved in the Smithsonian Institution. Today we use “bug” to mean any program error, and “debugging” to mean the process of fixing them.
Python’s Exception Hierarchy All of Python’s exceptions are organised in a class hierarchy (a family tree). At the top is BaseException. Below that is Exception, the parent of ValueError, TypeError, KeyError, IndexError, and many more. This is why catching Exception handles multiple error types at once — though it is usually better to catch specific ones.

Key Vocabulary

TermDefinition
ErrorA problem in a program that prevents it from working correctly
ExceptionA runtime error that can be caught and handled using try/except
BugAn error or flaw in a program that causes unexpected behaviour
DebuggingThe process of finding and fixing bugs in a program
Syntax ErrorAn error caused by breaking the language’s grammar rules; detected before running
Runtime ErrorAn error that occurs while the program is running, causing it to crash
Logic ErrorAn error where the program runs but produces the wrong result
tryA block of code that might cause an error; Python will attempt to run it
exceptA block that runs if a specific error occurs inside the try block
finallyA block that always runs, regardless of whether an error occurred
raiseA keyword used to deliberately trigger an error with a custom message
ValueErrorRaised when a value is the right type but inappropriate (e.g. int("abc"))
TypeErrorRaised when an operation is applied to the wrong type (e.g. "hello" + 5)
ZeroDivisionErrorRaised when dividing by zero
IndexErrorRaised when accessing a list index that does not exist
KeyErrorRaised when accessing a dictionary key that does not exist
NameErrorRaised when using a variable that has not been defined
ValidationChecking that data meets certain rules before accepting it
RobustnessA program’s ability to handle unexpected inputs and errors without crashing

Your Turn

Exercise 1: Safe Division
Guided

Write a program that divides two numbers safely. If the user tries to divide by zero, print a friendly error message instead of crashing. Fill in the blanks.

Pseudocode:
STEP 1: Set the numerator to 20 and the denominator to 0
STEP 2: Inside a try block, divide the numerator by the denominator
STEP 3: Print the result if the division succeeds
STEP 4: In the except block, catch ZeroDivisionError and print a helpful message
Your Code
numerator = 20
denominator = 0

try:
    result = numerator / ____
    print("The answer is:", ____)
except ____:
    print("Oops! You can't divide by zero.")

print("Program finished safely.")
(click "Run Code" to see output)
Need a hint?

Fill in the three blanks:

  • First blank: denominator
  • Second blank: result
  • Third blank: ZeroDivisionError

Once it works, change denominator to 4 and run again to see the successful result.

Exercise 2: Number Converter
Partially Guided

Write a program that tries to convert a list of strings into integers. Convert valid ones and print a helpful message for invalid ones, without crashing.

Pseudocode:
STEP 1: Create a list of strings: ["10", "hello", "42", "3.5", "seven", "99"]
STEP 2: Loop through each item
STEP 3: Inside a try block, convert using int()
STEP 4: If it works, print the number and increment the count
STEP 5: If ValueError occurs, print which value could not be converted
Your Code
# Number converter - handle invalid conversions gracefully
values = ["10", "hello", "42", "3.5", "seven", "99"]
converted_count = 0

for item in values:
    # Add your try/except code here
    pass

print("Done! Successfully converted", converted_count, "values.")
(click "Run Code" to see output)
Need a hint?

Replace pass with:

try:
    number = int(item)
    print("Converted:", number)
    converted_count += 1
except ValueError:
    print("Cannot convert '" + item + "' to a whole number")

Note that "3.5" will also cause a ValueError because int() cannot directly convert a decimal string.

Exercise 3: Robust Calculator
Open-Ended

Create a calculator that handles all possible errors: invalid numbers, division by zero, and unsupported operators. Use multiple except blocks and a finally block.

Pseudocode:
STEP 1: Set two number variables (as strings) and an operator
STEP 2: Try to convert both to float, then use if/elif for +, -, *, /
STEP 3: Add except blocks for ValueError and ZeroDivisionError
STEP 4: Add a finally block that prints "Calculation complete"
Your Code
# Robust calculator - handle every possible error!
num1_text = "10"
num2_text = "3"
operator = "+"

# Write your try/except/finally code below

(click "Run Code" to see output)
Need a hint?

try:
    num1 = float(num1_text)
    num2 = float(num2_text)
    Then use if operator == "+": and elif for each. In the else clause, print unknown operator.

except ValueError: → bad numbers
except ZeroDivisionError: → division by zero
finally: → "Calculation complete."

Exercise 4: List Safe Access
Partially Guided

Write a function safe_get(my_list, index) that returns the item at the given index, or "Index out of range" if the index does not exist.

Pseudocode:
STEP 1: Define safe_get with parameters my_list and index
STEP 2: Try to return my_list[index]
STEP 3: Catch IndexError and return "Index out of range"
STEP 4: Also catch TypeError for non-integer indices
Your Code
# Safe list access - never crash on a bad index!
def safe_get(my_list, index):
    # Add your try/except code here
    pass

# Test your function
fruits = ["apple", "banana", "cherry"]

print(safe_get(fruits, 0))    # Should print: apple
print(safe_get(fruits, 2))    # Should print: cherry
print(safe_get(fruits, 10))   # Should print: Index out of range
print(safe_get(fruits, -1))   # Should print: cherry (negative indexing)
print(safe_get(fruits, 99))   # Should print: Index out of range
(click "Run Code" to see output)
Need a hint?

Replace pass with:

try:
    return my_list[index]
except IndexError:
    return "Index out of range"
except TypeError:
    return "Index must be a whole number"

Remember: negative indices are valid in Python — my_list[-1] returns the last item.

Exercise 5: Data Validator
Open-Ended

Write a function that validates a dictionary of student data: name (non-empty string), age (integer 11–18), score (number 0–100). Return a list of error messages — empty means valid.

Pseudocode:
STEP 1: Define validate_student taking a dictionary
STEP 2: Create an empty errors list
STEP 3: Check "name" exists, is a string, and is not empty
STEP 4: Check "age" exists, is an int, and is between 11 and 18
STEP 5: Check "score" exists, is a number, and is between 0 and 100
STEP 6: Return the errors list
Your Code
# Student data validator
def validate_student(student):
    errors = []
    # Add your validation code here
    return errors

# Test with valid data
student1 = {"name": "Alice", "age": 15, "score": 87}
result1 = validate_student(student1)
print("Student 1:", result1 if result1 else "All valid!")

# Test with invalid data
student2 = {"name": "", "age": 25, "score": -10}
result2 = validate_student(student2)
print("Student 2:", result2)

# Test with missing fields
student3 = {"name": "Bob"}
result3 = validate_student(student3)
print("Student 3:", result3)

# Test with wrong types
student4 = {"name": 123, "age": "fifteen", "score": "A+"}
result4 = validate_student(student4)
print("Student 4:", result4)
(click "Run Code" to see output)
Need a hint?

For each field, check: (1) does it exist? (2) is it the right type? (3) is the value valid?

if "name" not in student:
    errors.append("Missing 'name' field")
elif not isinstance(student["name"], str):
    errors.append("Name must be a string")
elif student["name"] == "":
    errors.append("Name cannot be empty")

Follow a similar pattern for age (int, 11–18) and score (int or float, 0–100).

Think About It

Take a moment to think about what you’ve learnt. Try to answer these questions in your head or discuss them with a classmate:

Summary

Exam-Style Questions

Question 1 (6 marks)

Describe the difference between a syntax error, a runtime error, and a logic error. Give an example of each.

Show suggested answer

Syntax error (2 marks): Occurs when code breaks the language’s grammar rules. Detected before running. Example: print("hello" — missing closing bracket.

Runtime error (2 marks): Code is valid but crashes during execution. Example: 10 / 0 causes ZeroDivisionError.

Logic error (2 marks): Program runs without crashing but gives wrong results. Example: score1 + score2 + score3 / 3 instead of (score1 + score2 + score3) / 3.

Question 2 (3 marks)

Explain what is meant by “defensive programming” and give one technique used to implement it.

Show suggested answer

Definition (2 marks): Defensive programming is writing code that anticipates and handles potential errors or invalid inputs, ensuring the program fails gracefully rather than crashing.

Technique (1 mark): Input validation — checking that user input meets requirements before processing it. Also acceptable: using try/except blocks.

Question 3 (3 marks)

A program asks the user for their age (valid range: 0–120). Describe three pieces of test data you would use and explain why each is important.

Show suggested answer

Normal data (1 mark): e.g. 25 — a typical age. Tests the program works for standard inputs.

Boundary data (1 mark): e.g. 0 or 120 — at the valid range edge. Catches off-by-one errors.

Erroneous data (1 mark): e.g. "abc" or -5 — invalid input. Tests graceful handling without crashing.

Interactive Activities

Extra Practice & Resources

What’s Next?

You now have all the tools to build something amazing. You can store data in variables, make decisions with selection, repeat actions with loops, organise code with functions, work with lists and dictionaries, manipulate strings, and now — handle errors like a professional. In Chapter 11: Capstone — Quiz Game, you’ll bring everything together to build a complete, interactive quiz game from scratch. This is where all your hard work pays off!