Python exceptions practice — 4 real programs that don’t break
In the previous article we covered Python exceptions theory. Now it’s time to write real programs that handle errors gracefully. In this article we build four programs from scratch — a data validator, a robust calculator, a file reader and a class with custom exceptions. Each one uses exceptions in a situation where they’re the natural, correct tool.
Table of Contents
Python exceptions practice — Program 1: Robust data validator
This program asks for a name, age and grade, and keeps asking for each field until the user enters a valid value. It’s a perfect try/except use case — you can’t know in advance whether the user will enter valid data.
def get_name():
while True:
try:
name = input('Full name: ').strip()
if not name:
raise ValueError('Name cannot be empty')
if not name.replace(' ', '').isalpha():
raise ValueError(f'Name can only contain letters: {name}')
return name.title()
except ValueError as err:
print(f' ✗ {err}')
print(' Try again\n')
def get_age():
while True:
try:
age = int(input('Age: '))
if age < 0 or age > 120:
raise ValueError(f'Age must be between 0 and 120, got {age}')
return age
except ValueError as err:
if 'invalid literal' in str(err):
print(' ✗ Age must be a whole number')
else:
print(f' ✗ {err}')
print(' Try again\n')
def get_grade():
while True:
try:
grade = float(input('Grade (0-10): '))
if grade < 0 or grade > 10:
raise ValueError(f'Grade must be between 0 and 10, got {grade}')
return round(grade, 2)
except ValueError as err:
if 'could not convert' in str(err):
print(' ✗ Grade must be a number')
else:
print(f' ✗ {err}')
print(' Try again\n')
def register_student():
print('=== STUDENT REGISTRATION ===\n')
name = get_name()
age = get_age()
grade = get_grade()
status = 'Passed' if grade >= 5.0 else 'Failed'
points = round(grade - 5.0, 2) if grade >= 5.0 else round(5.0 - grade, 2)
direction = 'above' if grade >= 5.0 else 'below'
print(f'\n--- Registration Complete ---')
print(f'Name: {name}')
print(f'Age: {age}')
print(f'Grade: {grade}')
print(f'Status: {status} ({points} points {direction} pass mark)')
register_student()
Output with deliberate mistakes:
=== STUDENT REGISTRATION === Full name: ✗ Name cannot be empty Try again Full name: Sergio123 ✗ Name can only contain letters: Sergio123 Try again Full name: Sergio Medina Age: twenty ✗ Age must be a whole number Try again Age: 20 Grade (0-10): abc ✗ Grade must be a number Try again Grade (0-10): 15 ✗ Grade must be between 0 and 10, got 15.0 Try again Grade (0-10): 8.5 --- Registration Complete --- Name: Sergio Medina Age: 20 Grade: 8.5 Status: Passed (3.5 points above pass mark)
Notice how each get_* function has its own while True loop with try/except inside. The loop keeps running until a valid value is returned — only a successful return exits the loop. The except ValueError catches both type errors (non-numeric input) and range errors (out-of-bounds values) because both raise ValueError. The if 'invalid literal' in str(err) trick distinguishes between the two without needing separate exception types.
Python exceptions practice — Program 2: Robust calculator
A calculator that never crashes — it handles every possible error gracefully and keeps running until the user decides to quit.
class CalculatorError(Exception):
pass
class DivisionByZeroError(CalculatorError):
pass
class InvalidOperationError(CalculatorError):
pass
def get_number(prompt):
while True:
try:
return float(input(prompt))
except ValueError:
print(f' ✗ Please enter a valid number')
def get_operation():
valid = {'+', '-', '*', '/', '//', '%', '**'}
while True:
op = input('Operation (+, -, *, /, //, %, **): ').strip()
if op in valid:
return op
print(f' ✗ Invalid operation. Choose from: {", ".join(sorted(valid))}')
def calculate(a, op, b):
try:
if op == '+': return a + b
if op == '-': return a - b
if op == '*': return a * b
if op == '/':
if b == 0:
raise DivisionByZeroError('Cannot divide by zero')
return a / b
if op == '//':
if b == 0:
raise DivisionByZeroError('Cannot floor divide by zero')
return a // b
if op == '%':
if b == 0:
raise DivisionByZeroError('Cannot compute modulo with zero')
return a % b
if op == '**':
if a == 0 and b < 0:
raise InvalidOperationError('0 cannot be raised to a negative power')
return a ** b
except OverflowError:
raise CalculatorError('Result too large to display')
def run_calculator():
print('=== ROBUST CALCULATOR ===')
print('Type "quit" at any time to exit\n')
history = []
while True:
try:
a_str = input('First number (or "quit"): ').strip()
if a_str.lower() == 'quit':
break
a = float(a_str)
except ValueError:
print(' ✗ Please enter a valid number\n')
continue
op = get_operation()
try:
b_str = input('Second number: ').strip()
if b_str.lower() == 'quit':
break
b = float(b_str)
except ValueError:
print(' ✗ Please enter a valid number\n')
continue
try:
result = calculate(a, op, b)
expression = f'{a} {op} {b} = {result}'
print(f' ✓ {expression}\n')
history.append(expression)
except DivisionByZeroError as err:
print(f' ✗ Error: {err}\n')
except InvalidOperationError as err:
print(f' ✗ Error: {err}\n')
except CalculatorError as err:
print(f' ✗ Calculator error: {err}\n')
if history:
print('\n--- Calculation history ---')
for i, expr in enumerate(history, 1):
print(f' {i}. {expr}')
print('\nGoodbye!')
run_calculator()
The custom exception hierarchy is the key design decision here. CalculatorError is the base class. DivisionByZeroError and InvalidOperationError inherit from it — which means you can catch all calculator errors with one except CalculatorError or handle each type specifically. The get_number function is called differently for the first and second number — the first has the quit option, the second uses get_number directly. This shows how the same validation function can be reused with different contexts.
Python exceptions practice — Program 3: Robust file reader
File operations are one of the most common real-world uses of try/except because so many things can go wrong — the file might not exist, you might not have permission to read it, it might be corrupted.
import os
class FileProcessingError(Exception):
pass
def read_file(filepath):
try:
with open(filepath, 'r', encoding='utf-8') as file:
content = file.read()
return content
except FileNotFoundError:
raise FileProcessingError(f'File not found: {filepath}')
except PermissionError:
raise FileProcessingError(f'No permission to read: {filepath}')
except UnicodeDecodeError:
raise FileProcessingError(f'File encoding error — not valid UTF-8: {filepath}')
except OSError as err:
raise FileProcessingError(f'OS error reading file: {err}')
def analyse_text(content):
try:
assert content.strip(), 'File is empty'
lines = content.splitlines()
words = content.split()
chars = len(content)
sentences = content.count('.') + content.count('!') + content.count('?')
word_count = {}
for word in words:
clean = word.lower().strip('.,!?;:"()[]')
if clean:
word_count[clean] = word_count.get(clean, 0) + 1
most_common = sorted(word_count.items(), key=lambda x: x[1], reverse=True)
return {
'lines': len(lines),
'words': len(words),
'characters': chars,
'sentences': max(sentences, 1),
'unique_words': len(word_count),
'most_common': most_common[:5],
'avg_words_per_line': round(len(words) / len(lines), 1) if lines else 0
}
except AssertionError as err:
raise FileProcessingError(str(err))
except Exception as err:
raise FileProcessingError(f'Error analysing text: {err}')
def write_report(stats, output_path):
try:
with open(output_path, 'w', encoding='utf-8') as file:
file.write('=== TEXT ANALYSIS REPORT ===\n\n')
file.write(f'Lines: {stats["lines"]}\n')
file.write(f'Words: {stats["words"]}\n')
file.write(f'Characters: {stats["characters"]}\n')
file.write(f'Sentences: {stats["sentences"]}\n')
file.write(f'Unique words: {stats["unique_words"]}\n')
file.write(f'Avg words/line: {stats["avg_words_per_line"]}\n')
file.write('\nMost frequent words:\n')
for word, count in stats['most_common']:
file.write(f' {word}: {count}\n')
return True
except PermissionError:
raise FileProcessingError(f'No permission to write: {output_path}')
except OSError as err:
raise FileProcessingError(f'Error writing report: {err}')
def process_file(input_path, output_path):
print(f'Processing: {input_path}')
try:
# Read
content = read_file(input_path)
print(f' ✓ File read ({len(content)} characters)')
# Analyse
stats = analyse_text(content)
print(f' ✓ Analysis complete ({stats["words"]} words)')
# Write report
write_report(stats, output_path)
print(f' ✓ Report saved to: {output_path}')
return stats
except FileProcessingError as err:
print(f' ✗ Error: {err}')
return None
finally:
print(' Processing complete\n')
# Create a test file
test_content = """Python is a versatile programming language.
Python is used in data science and machine learning.
Learning Python opens many doors in the tech industry.
Python syntax is clear and readable."""
with open('test.txt', 'w') as f:
f.write(test_content)
# Process it
stats = process_file('test.txt', 'report.txt')
if stats:
print('--- Statistics ---')
for key, value in stats.items():
if key != 'most_common':
print(f'{key}: {value}')
print('Most common:', stats['most_common'])
# Test error handling
process_file('nonexistent.txt', 'report.txt')
Output:
Processing: test.txt
✓ File read (189 characters)
✓ Analysis complete (31 words)
✓ Report saved to: report.txt
Processing complete
--- Statistics ---
lines: 4
words: 31
...
Most common: [('python', 4), ('is', 3), ('and', 2), ...]
Processing: nonexistent.txt
✗ Error: File not found: nonexistent.txt
Processing complete
The with open(...) statement (context manager) automatically closes the file even if an exception occurs — it’s the Python equivalent of C’s fclose. The finally block in process_file always prints “Processing complete” regardless of success or failure — exactly the pattern for cleanup code that must always run.
Python exceptions practice — Program 4: Class with custom exceptions
This program implements a bank account class with a complete custom exception hierarchy — showing how exceptions integrate naturally with object-oriented code.
# Custom exception hierarchy
class BankError(Exception):
"""Base class for all bank-related exceptions."""
pass
class InsufficientFundsError(BankError):
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
super().__init__(
f'Insufficient funds: balance €{balance:.2f}, '
f'requested €{amount:.2f}'
)
class NegativeAmountError(BankError):
def __init__(self, amount):
self.amount = amount
super().__init__(f'Amount must be positive, got €{amount:.2f}')
class AccountFrozenError(BankError):
def __init__(self, account_id):
self.account_id = account_id
super().__init__(f'Account {account_id} is frozen')
class DailyLimitError(BankError):
def __init__(self, limit, attempted):
self.limit = limit
self.attempted = attempted
super().__init__(
f'Daily withdrawal limit exceeded: '
f'limit €{limit:.2f}, attempted €{attempted:.2f}'
)
class BankAccount:
DAILY_LIMIT = 1000.0
def __init__(self, account_id, owner, initial_balance=0):
if initial_balance < 0:
raise NegativeAmountError(initial_balance)
self.account_id = account_id
self.owner = owner
self._balance = initial_balance
self._frozen = False
self._daily_withdrawn = 0
self._transactions = []
def _check_frozen(self):
if self._frozen:
raise AccountFrozenError(self.account_id)
def _record(self, type_, amount, balance_after):
self._transactions.append({
'type': type_,
'amount': amount,
'balance': balance_after
})
def deposit(self, amount):
self._check_frozen()
if amount <= 0:
raise NegativeAmountError(amount)
self._balance += amount
self._record('deposit', amount, self._balance)
return self._balance
def withdraw(self, amount):
self._check_frozen()
if amount <= 0:
raise NegativeAmountError(amount)
if self._daily_withdrawn + amount > self.DAILY_LIMIT:
raise DailyLimitError(self.DAILY_LIMIT,
self._daily_withdrawn + amount)
if amount > self._balance:
raise InsufficientFundsError(self._balance, amount)
self._balance -= amount
self._daily_withdrawn += amount
self._record('withdrawal', amount, self._balance)
return self._balance
def transfer(self, target_account, amount):
try:
self.withdraw(amount)
target_account.deposit(amount)
print(f' ✓ Transfer €{amount:.2f}: {self.owner} → {target_account.owner}')
except BankError:
raise # propagate — don't swallow bank errors
def freeze(self):
self._frozen = True
def unfreeze(self):
self._frozen = False
@property
def balance(self):
return self._balance
def statement(self):
print(f'\n--- Statement: {self.owner} ({self.account_id}) ---')
print(f'Current balance: €{self._balance:.2f}')
print(f'Daily withdrawn: €{self._daily_withdrawn:.2f} / €{self.DAILY_LIMIT:.2f}')
if self._transactions:
print('Transactions:')
for t in self._transactions:
symbol = '+' if t['type'] == 'deposit' else '-'
print(f' {symbol}€{t["amount"]:.2f} → balance: €{t["balance"]:.2f}')
# Demonstration
print('=== BANK SYSTEM ===\n')
try:
# Create accounts
sergio = BankAccount('ACC001', 'Sergio', 1000)
maria = BankAccount('ACC002', 'María', 500)
print('✓ Accounts created')
# Normal operations
sergio.deposit(250)
print(f'✓ Deposit €250 → balance: €{sergio.balance:.2f}')
sergio.withdraw(100)
print(f'✓ Withdrawal €100 → balance: €{sergio.balance:.2f}')
sergio.transfer(maria, 200)
# Test InsufficientFundsError
try:
sergio.withdraw(5000)
except InsufficientFundsError as err:
print(f'✗ {err}')
print(f' Balance: €{err.balance:.2f}, attempted: €{err.amount:.2f}')
# Test AccountFrozenError
sergio.freeze()
try:
sergio.deposit(100)
except AccountFrozenError as err:
print(f'✗ {err}')
finally:
sergio.unfreeze()
print(' Account unfrozen')
# Test DailyLimitError
try:
sergio.withdraw(900) # already withdrew 300 today
except DailyLimitError as err:
print(f'✗ {err}')
# Test NegativeAmountError
try:
sergio.deposit(-50)
except NegativeAmountError as err:
print(f'✗ {err}')
# Statements
sergio.statement()
maria.statement()
except BankError as err:
print(f'Unexpected bank error: {err}')
Output:
=== BANK SYSTEM === ✓ Accounts created ✓ Deposit €250 → balance: €1250.00 ✓ Withdrawal €100 → balance: €1150.00 ✓ Transfer €200: Sergio → María ✗ Insufficient funds: balance €950.00, requested €5000.00 Balance: €950.00, attempted: €5000.00 ✗ Account ACC001 is frozen Account unfrozen ✗ Daily withdrawal limit exceeded: limit €1000.00, attempted €1200.00 ✗ Amount must be positive, got €-50.00 --- Statement: Sergio (ACC001) --- Current balance: €950.00 Daily withdrawn: €300.00 / €1000.00 Transactions: +€250.00 → balance: €1250.00 -€100.00 → balance: €1150.00 -€200.00 → balance: €950.00
The exception hierarchy here is the most important design element. All bank errors inherit from BankError — so you can catch all of them with one except BankError when you want general handling, or catch specific ones like InsufficientFundsError when you need to access err.balance and err.amount. The transfer method uses raise without arguments to propagate exceptions upward — it doesn’t swallow them, because the caller needs to know if the transfer failed and why.
Visualise with Python Tutor
Copy this code into pythontutor.com and step through it:
class InsufficientFundsError(Exception):
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
super().__init__(f'Balance {balance}, requested {amount}')
def withdraw(balance, amount):
if amount > balance:
raise InsufficientFundsError(balance, amount)
return balance - amount
try:
new_balance = withdraw(100, 250)
except InsufficientFundsError as err:
print(err)
print(f'Have: {err.balance}')
print(f'Need: {err.amount}')
finally:
print('Always runs')
Step through and watch how raise InsufficientFundsError(100, 250) creates a new exception object with both balance and amount as attributes. When it’s caught as err, you can access err.balance and err.amount directly — they’re just regular object attributes. This is the key advantage of custom exceptions over generic ones: they carry structured information, not just a string message.
Summary and next step
In this article you practised Python exceptions with four real programs. You used try/except for input validation with automatic retry loops, custom exception hierarchies for a calculator and bank account, finally for guaranteed cleanup in file processing, raise to propagate and re-raise exceptions, and assert for internal consistency checks.
In the next article you’ll find exercises to solve on your own.
