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:
- Understand the most common types of Python errors and what causes them
- Distinguish between syntax errors, runtime errors, and logic errors
- Use
tryandexceptto catch errors and keep your program running - Handle specific error types differently depending on what went wrong
- Use the
finallyblock to run clean-up code no matter what happens - Raise your own errors for custom validation
- Understand the role of test data in building robust programs
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.
# Syntax Error - missing closing bracket
print("Hello world"
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.
# Runtime Error - valid syntax, but crashes during execution
number = 10
result = number / 0 # ZeroDivisionError!
print(result)
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.
# 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)
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.
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!
# NameError - using a variable that doesn't exist
print(my_score)
# TypeError - trying to add a string and a number
result = "Age: " + 15
print(result)
# ZeroDivisionError - dividing by zero
answer = 100 / 0
print(answer)
Each error message has two important parts: the error type (like NameError) and a description that explains what went wrong.
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.
# Without try/except, this would crash!
try:
result = 10 / 0
except ZeroDivisionError:
print("Can't divide by zero!")
print("Program keeps running!")
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.
# 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)
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.
# 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)
The finally block runs every single time, making it perfect for clean-up tasks like closing a file or saving progress.
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.
# 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).")
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.
["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.
# 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!")
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.
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.
# 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}")
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.
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 Type | What Causes It | Example |
|---|---|---|
SyntaxError | Code breaks Python’s grammar rules | print("hello" — missing bracket |
NameError | Variable doesn’t exist | print(score) when score was never created |
TypeError | Wrong type of data | "hello" + 5 |
ValueError | Right type, wrong value | int("abc") |
ZeroDivisionError | Dividing by zero | 10 / 0 |
IndexError | List index doesn’t exist | my_list[99] when list has 3 items |
KeyError | Dictionary key doesn’t exist | my_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
# 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:
- Python tries to run the code inside the
tryblock - If no error occurs, it skips all
exceptblocks and runsfinally - If an error occurs, Python stops the
tryblock, finds the matchingexcept, runs it, then runsfinally - If no matching
exceptis found, the program crashes (butfinallystill runs first)
Catching Specific vs General Errors
# 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!")
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:
- Closing a file that was opened
- Saving data before the program ends
- Printing a “finished” message
- Resetting a value back to its default
Common Misconceptions
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!
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
| Type | Description | Purpose |
|---|---|---|
| Normal data | Typical, expected values that should be accepted | Check the program works for everyday inputs |
| Boundary data | Values at the very edges of the valid range | Check limits are enforced correctly (off-by-one errors are common!) |
| Erroneous data | Invalid values — wrong type or out-of-range | Check the program handles bad input gracefully |
Example: Test Data for “Age” Input (valid range 0–120)
| Test Data | Type | Expected Outcome |
|---|---|---|
25 | Normal | Accepted — typical age |
65 | Normal | Accepted — another typical value |
0 | Boundary | Accepted — minimum valid age |
120 | Boundary | Accepted — maximum valid age |
-1 | Boundary (erroneous side) | Rejected — just below minimum |
121 | Boundary (erroneous side) | Rejected — just above maximum |
"abc" | Erroneous | Rejected — not a number |
-50 | Erroneous | Rejected — well below range |
999 | Erroneous | Rejected — well above range |
< 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.
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))
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.
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?
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
| Term | Definition |
|---|---|
| Error | A problem in a program that prevents it from working correctly |
| Exception | A runtime error that can be caught and handled using try/except |
| Bug | An error or flaw in a program that causes unexpected behaviour |
| Debugging | The process of finding and fixing bugs in a program |
| Syntax Error | An error caused by breaking the language’s grammar rules; detected before running |
| Runtime Error | An error that occurs while the program is running, causing it to crash |
| Logic Error | An error where the program runs but produces the wrong result |
try | A block of code that might cause an error; Python will attempt to run it |
except | A block that runs if a specific error occurs inside the try block |
finally | A block that always runs, regardless of whether an error occurred |
raise | A keyword used to deliberately trigger an error with a custom message |
ValueError | Raised when a value is the right type but inappropriate (e.g. int("abc")) |
TypeError | Raised when an operation is applied to the wrong type (e.g. "hello" + 5) |
ZeroDivisionError | Raised when dividing by zero |
IndexError | Raised when accessing a list index that does not exist |
KeyError | Raised when accessing a dictionary key that does not exist |
NameError | Raised when using a variable that has not been defined |
| Validation | Checking that data meets certain rules before accepting it |
| Robustness | A program’s ability to handle unexpected inputs and errors without crashing |
Your Turn
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.
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
numerator = 20
denominator = 0
try:
result = numerator / ____
print("The answer is:", ____)
except ____:
print("Oops! You can't divide by zero.")
print("Program finished safely.")
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.
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.
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
# 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.")
Replace pass with:
try:
number = int(item)
print("Converted:", number)
converted_count += 1except 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.
Create a calculator that handles all possible errors: invalid numbers, division by zero, and unsupported operators. Use multiple except blocks and a finally block.
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"
# Robust calculator - handle every possible error!
num1_text = "10"
num2_text = "3"
operator = "+"
# Write your try/except/finally code below
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 numbersexcept ZeroDivisionError: → division by zerofinally: → "Calculation complete."
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.
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
# 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
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.
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.
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
# 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)
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:
- Why is it better to catch specific error types rather than using a bare
except:? - Can you think of three situations where error handling would be useful in your own programs?
- What is the difference between a
SyntaxErrorand aValueError? Why can’t you catch aSyntaxError? - When would you use a
finallyblock? Why might it be important? - A classmate says “I just put all my code in one big
try/exceptso it never crashes.” What are the risks? - Why are logic errors the hardest type to find and fix?
- Why do you need all three types of test data (normal, boundary, erroneous)?
Summary
- Three types of errors: Syntax (detected before running), runtime (crash during execution), logic (wrong results, no crash)
- Error handling:
trycontains risky code;exceptcatches specific errors;finallyalways runs - Common exceptions:
ValueError,TypeError,ZeroDivisionError,IndexError,KeyError,NameError - Accessing error messages: Use
except ErrorType as e:to capture the description - Raising errors: Use
raiseto create custom errors for validation - Test data: Normal, boundary, and erroneous data should all be used
- Defensive programming: Anticipating what could go wrong and handling it
- Robustness: Handling unexpected inputs gracefully without crashing
Exam-Style Questions
Describe the difference between a syntax error, a runtime error, and a logic error. Give an example of each.
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.
Explain what is meant by “defensive programming” and give one technique used to implement it.
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.
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.
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
- Find the Errors — 6-level debugging game identifying and fixing syntax, runtime and logic errors
- Logic vs Runtime Errors — 8 challenges to identify error types, find the buggy line and choose the correct fix
- Trace Table Challenge — Step through code execution and track how variables change line by line
- Trace Table Challenge 2 — More advanced program tracing with output tracking and execution flow
Extra Practice & Resources
- Python Error Spotter — 20 questions on spotting syntax, logic, runtime and indentation errors
- Python Code Fixer — Fix 30 bugs across 3 real Python programs
- CS Escape Room — Solve computing puzzles under a 50-minute timer
- GCSE Topic 6: Testing & Debugging — Error types, test data, trace tables, and validation
- BBC Bitesize Edexcel Computer Science — GCSE revision for programming, data, and computational thinking
- W3Schools: Python Try Except — Interactive tutorial on error handling in Python
- Isaac Computer Science: Testing — In-depth material on testing strategies and test data
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!