Exceptions in Python — try, except, raise and assert without complications
Python exceptions are the mechanism that lets a program handle unexpected situations without crashing. In FP1 you learned to prevent errors with if/else. In FP2 you go a step further — catching them when they happen and raising them yourself when needed. This article covers everything: try/except, specific and generic exceptions, finally, raise and assert.
Table of Contents
What is an exception?
When Python encounters a situation it can’t handle — dividing by zero, converting text that isn’t a number, accessing an index that doesn’t exist — it raises an exception and the program stops. An exception is an object that represents that error.
a = int(input('a: '))
b = int(input('b: '))
print(a // b)
a: 7 b: 0 ZeroDivisionError: integer division or modulo by zero
The program shows the exception type (ZeroDivisionError) and terminates. Without exception handling, any unexpected error kills the program.
try/except vs if/else — when to use each
The question that comes up most in FP2: when do I use if/else and when do I use try/except?
# if/else — prevention
b = int(input('b: '))
if b != 0:
print(a // b)
else:
print('Cannot divide by zero')
# try/except — management
try:
b = int(input('b: '))
print(a // b)
except ZeroDivisionError:
print('Cannot divide by zero')
The practical rule is clear.
Use if/else when you can check the condition before the error occurs and the check is cheap — like checking if a number is zero before dividing.
Use try/except when you can’t know in advance whether an error will occur — trying to open a file, converting user input, accessing external resources. Also when the code that might fail is complex and wrapping it in conditions would make it unreadable.
In Python there’s also a philosophy known as EAFP (Easier to Ask Forgiveness than Permission): it’s more Pythonic to attempt the operation and catch the error than to check in advance whether it’s possible.
The try/except structure
try:
# code that might raise an exception
except ExceptionType:
# code that runs if that exception occurs
When an exception occurs inside the try, Python immediately jumps to the matching except — the remaining instructions in the try are not executed:
try:
a = int(input('a: '))
b = int(input('b: '))
print(a // b) # (1)
print('Done') # (2)
except ZeroDivisionError:
print('Division by zero') # (3)
If b is 0, the exception occurs at (1), (2) is not executed, and control passes directly to (3).
Specific except — catching concrete exceptions
The recommended approach is to catch specific exceptions — that way you know exactly what error occurred and can handle it appropriately:
try:
a = int(input('a: '))
b = int(input('b: '))
print(a // b)
except ValueError:
print('Enter integer numbers')
except ZeroDivisionError:
print('Cannot divide by zero')
If the user enters text, ValueError occurs and the first except catches it. If they enter zero, ZeroDivisionError occurs and the second catches it. Each exception has its own handling block.
The most common exceptions in FP2:
ValueError # wrong value for the operation — int('hello')
ZeroDivisionError # division by zero
TypeError # wrong data type
IndexError # index out of range in list or string
KeyError # key not found in dictionary
FileNotFoundError # file not found
AttributeError # attribute or method doesn't exist on the object
Capturing multiple exceptions in one block
If you want to handle several exceptions the same way, group them in a tuple:
try:
a = int(input('a: '))
b = int(input('b: '))
print(a // b)
except (ValueError, ZeroDivisionError):
print('Error in input data')
Generic except — the difference that confuses most
except without a type catches any exception — it’s the equivalent of the final else in an if/elif chain:
try:
a = int(input('a: '))
b = int(input('b: '))
print(a // b)
except ValueError:
print('Enter integer numbers')
except ZeroDivisionError:
print('Cannot divide by zero')
except:
print('An unexpected error occurred')
The generic except always goes last and catches any exception not handled before. Use it sparingly — if you catch everything without knowing what it is, you can hide real bugs in your code.
Accessing exception information
You can name the exception object to access its information:
try:
a = int(input('a: '))
b = int(input('b: '))
print(a // b)
except Exception as err:
print('Error:', err) # error message
print(type(err)) # exception type
print(err.args) # additional info as tuple
a: 7
b: 0
Error: integer division or modulo by zero
<class 'ZeroDivisionError'>
('integer division or modulo by zero',)
Exception is the base class from which all exception types inherit. Use it when you want to catch any exception but need access to its information.
The additional blocks — else and finally
Besides except, a try block can have else and finally:
try:
a = int(input('a: '))
b = int(input('b: '))
result = a // b
except ValueError:
print('Enter integer numbers')
except ZeroDivisionError:
print('Cannot divide by zero')
else:
print(result) # only runs if NO exception occurred
finally:
print('Execution finished') # ALWAYS runs
else runs only if the try block raised no exception. It’s the right place for code that depends on the try having succeeded but doesn’t itself need protection.
finally always runs — whether or not an exception occurred. Its main use is releasing resources — closing files, network connections, databases — that must be closed no matter what.
# Without exception: a: 10 / b: 2 → 5 Execution finished # With exception: a: 10 / b: 0 → Cannot divide by zero Execution finished
The finally block is the one that confuses people most at first. Remember: it always runs — even if there’s an uncaught exception or a return inside the try.
raise — throwing your own exceptions
Until now Python has been raising the exceptions. With raise you can raise them yourself when you detect an abnormal situation:
import math
def circle_area(radius):
if radius < 0:
raise ValueError('Radius cannot be negative')
return math.pi * radius ** 2
try:
print(circle_area(5)) # → 78.539...
print(circle_area(-2)) # raises ValueError
except ValueError as err:
print(err) # → Radius cannot be negative
raise accepts any exception type with a descriptive message:
raise ValueError('descriptive message')
raise TypeError('wrong data type')
raise ZeroDivisionError('denominator is zero')
raise ValueError # no message — uses default
raise without arguments — propagating exceptions
Inside an except you can use bare raise to propagate the exception to the code that called your function:
def read_number():
attempts = 0
while True:
try:
return int(input('Number: '))
except ValueError:
attempts += 1
print('Enter an integer')
if attempts >= 3:
raise # propagates the ValueError upward
try:
read_number()
except ValueError:
print('Too many errors — program terminated')
Number: a Enter an integer Number: b Enter an integer Number: c Enter an integer Too many errors — program terminated
Bare raise inside an except re-raises the same exception currently being handled. If nobody catches it, the program terminates.
Custom exceptions
You can define your own exception types by creating a class that inherits from Exception:
class NegativeRadiusError(Exception):
pass
class RadiusTypeError(Exception):
pass
import math
def circle_area(radius):
if not isinstance(radius, (int, float)):
raise RadiusTypeError('Radius must be a number')
if radius < 0:
raise NegativeRadiusError('Radius cannot be negative')
return math.pi * radius ** 2
try:
print(circle_area('five'))
except RadiusTypeError as err:
print(err)
except NegativeRadiusError as err:
print(err)
Radius must be a number
By convention, custom exception names end in Error — just like the built-in ones (ValueError, TypeError…). A custom exception can be as simple as pass or include additional attributes with information about the error:
class InsufficientFundsError(Exception):
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
super().__init__(
f'Insufficient funds: balance {balance}€, requested {amount}€'
)
class BankAccount:
def __init__(self, balance):
self.balance = balance
def withdraw(self, amount):
if amount > self.balance:
raise InsufficientFundsError(self.balance, amount)
self.balance -= amount
try:
account = BankAccount(100)
account.withdraw(250)
except InsufficientFundsError as err:
print(err)
print(f'You have {err.balance}€ but tried to withdraw {err.amount}€')
Insufficient funds: balance 100€, requested 250€ You have 100€ but tried to withdraw 250€
Assertions — assert
An assertion is a condition that must be true at a specific point in the program for it to continue:
def average(items):
assert len(items) != 0, 'List cannot be empty'
return sum(items) / len(items)
print(average([1, 2, 3])) # → 2.0
print(average([])) # → AssertionError: List cannot be empty
assert condition, message — if the condition is false it raises AssertionError with the message. The message is optional.
Assertions are equivalent to:
if __debug__:
if len(items) == 0:
raise AssertionError('List cannot be empty')
When to use assert vs raise
# USE assert for — conditions that should never occur
# if the program is correctly written (programming errors)
assert len(list) > 0
assert result is not None
# USE raise for — abnormal situations that are expected in production
# that the user or environment can cause
if radius < 0:
raise ValueError('Negative radius')
if not isinstance(radius, (int, float)):
raise TypeError('Radius must be a number')
Assertions can be disabled by running Python with the -O (optimisation) flag. That’s why you should never use them to validate user input — use raise for that.
A complete program
class AgeError(Exception):
pass
class NameError(Exception):
pass
def validate_name(name):
if not name.strip():
raise NameError('Name cannot be empty')
if not name.replace(' ', '').isalpha():
raise NameError(f'Name can only contain letters: {name}')
return name.strip().title()
def validate_age(age_str):
try:
age = int(age_str)
except ValueError:
raise ValueError(f'Age must be an integer: {age_str}')
if age < 0 or age > 150:
raise AgeError(f'Age out of valid range: {age}')
return age
def register_student(name, age_str, grade_str):
errors = []
try:
clean_name = validate_name(name)
except NameError as err:
errors.append(f'Name: {err}')
clean_name = None
try:
age = validate_age(age_str)
except (ValueError, AgeError) as err:
errors.append(f'Age: {err}')
age = None
try:
grade = float(grade_str)
assert 0 <= grade <= 10, f'Grade must be between 0 and 10: {grade}'
except ValueError:
errors.append(f'Grade: must be a number: {grade_str}')
grade = None
except AssertionError as err:
errors.append(f'Grade: {err}')
grade = None
if errors:
print('Registration failed:')
for error in errors:
print(f' ✗ {error}')
return None
student = {
'name': clean_name,
'age': age,
'grade': grade,
'status': 'Passed' if grade >= 5.0 else 'Failed'
}
print(f'✓ Student registered: {student}')
return student
# Tests
register_student('Sergio Medina', '20', '8.5')
register_student('', '200', 'abc')
register_student('Ana123', '-5', '11')
✓ Student registered: {'name': 'Sergio Medina', 'age': 20, 'grade': 8.5, 'status': 'Passed'}
Registration failed:
✗ Name: Name cannot be empty
✗ Age: Age out of valid range: 200
✗ Grade: must be a number: abc
Registration failed:
✗ Name: Name can only contain letters: Ana123
✗ Age: Age out of valid range: -5
✗ Grade: Grade must be between 0 and 10: 11.0
Visualise with Python Tutor
Copy this code into pythontutor.com and step through it:
def divide(a, b):
if b == 0:
raise ZeroDivisionError('Cannot divide by zero')
return a / b
try:
result = divide(10, 0)
print(result)
except ZeroDivisionError as err:
print('Caught:', err)
finally:
print('Always runs')
Step through and observe three things. When raise is executed inside divide, the function is interrupted immediately — execution jumps out of the function without returning. Control lands in the except block, which catches the ZeroDivisionError and prints the message. Then finally runs — even though an exception occurred. Try changing the 0 to a 2 and step through again — this time result = divide(10, 2) works, print(result) executes, and finally still runs. The finally block executes in both scenarios — with and without an exception.
Quick summary
# BASIC STRUCTURE
try:
# code that might fail
except SpecificType:
# handle that specific exception
except (Type1, Type2):
# handle multiple types the same way
except Exception as err:
# any exception + access to info
# err — message, type(err) — type, err.args — extra info
except:
# any exception (no info) — use sparingly
else:
# only if NO exception occurred in try
finally:
# ALWAYS runs — with or without exception
# RAISE
raise ValueError('message') # raise new exception
raise TypeError # raise without message
raise # propagate current exception (inside except)
# CUSTOM EXCEPTION
class MyError(Exception):
pass
class DetailedError(Exception):
def __init__(self, value, message):
self.value = value
super().__init__(message)
raise MyError('something went wrong')
# ASSERT
assert condition, 'message' # AssertionError if False
# Use for: programming errors, internal invariants
# NOT for: user input validation — use raise for that
# try/except vs if/else
# if/else → prevent known, cheaply checkable errors
# try/except → manage errors you can't predict: files, input, external
# EAFP — Python philosophy
# Try the operation and catch the error
# rather than checking in advance if it's possible
# MOST COMMON EXCEPTIONS
# ValueError — wrong value (int('hello'))
# ZeroDivisionError — division by zero
# TypeError — wrong type
# IndexError — index out of range
# KeyError — dictionary key not found
# FileNotFoundError — file doesn't exist
# AttributeError — attribute/method doesn't exist
# AssertionError — assert condition failed
In the next article we practice Python exceptions with real programs — robust validators, file handling and custom exceptions in action.

2 Comments