Introduction
In the previous post we wrote functions β clean, reusable pieces of logic. But functions assume that the inputs they receive are sensible, the files they open exist, and the network they query is available. In the real world, none of these things are guaranteed. Users make typos. Files get deleted. APIs go down. Disks fill up.
The question is not whether your program will encounter an error. It will. The question is whether it will crash with a cryptic message and lose all its work, or handle the situation gracefully and tell you β or the user β what actually went wrong.
Pythonβs exception system is your answer. And our birds, as patient as they are, are about to misbehave.
What Is an Exception?
When Python encounters a problem it cannot resolve, it raises an exception β an object that represents what went wrong. If nothing catches it, the program stops and prints a traceback. You have certainly seen this before:
wing_spans = {"Eagle": 200, "Pigeon": 50}
print(wing_spans["Albatross"])
KeyError: 'Albatross'
The KeyError is an exception. Python raises it because you asked for a key that does not exist. Without any error handling, the program stops here. That is sometimes acceptable in a quick script. It is never acceptable in production code, a shared tool, or anything a user touches.
try and except
The try/except block is how you catch exceptions and decide what to do about them:
wing_spans = {"Eagle": 200, "Pigeon": 50}
def get_wingspan(bird_name: str) -> int | None:
try:
return wing_spans[bird_name]
except KeyError:
print(f"Sorry, no wingspan data for '{bird_name}'.")
return None
print(get_wingspan("Eagle"))
print(get_wingspan("Albatross"))
200
Sorry, no wingspan data for 'Albatross'.
None
The code inside try runs normally. If an exception matching the type in except is raised, execution jumps to the except block. The program continues rather than crashing.
Catching Specific Exceptions
You should always catch the most specific exception you can. Catching everything with a bare except: or except Exception: is tempting but dangerous β it swallows errors you did not anticipate, including genuine bugs, making them very hard to find later.
def load_bird_data(filepath: str) -> dict:
try:
with open(filepath, "r") as f:
import json
return json.load(f)
except FileNotFoundError:
print(f"File not found: {filepath}")
return {}
except json.JSONDecodeError as e:
print(f"Could not parse JSON in {filepath}: {e}")
return {}
Two different exceptions, two different messages. A FileNotFoundError means the path is wrong; a json.JSONDecodeError means the file exists but the contents are malformed. Treating them identically would obscure which problem you actually have.
The as e syntax gives you access to the exception object itself, which usually has a helpful message.
The else Clause
Few beginners know about else on a try block, which is a shame because it is genuinely useful. The else block runs only if no exception was raised β meaning βthe thing succeededβ:
def read_flock_size(filepath: str) -> None:
try:
with open(filepath, "r") as f:
count = int(f.read().strip())
except FileNotFoundError:
print("Flock file not found.")
except ValueError:
print("Flock file does not contain a valid number.")
else:
# Only runs if try succeeded completely
print(f"Flock size loaded successfully: {count} birds.")
Without else you would have to put print(...) inside the try block, which means it would also be guarded by the exception handling β slightly misleading, because the print itself cannot raise FileNotFoundError or ValueError. The else clause keeps the βsuccess pathβ separate and clear.
finally: Cleanup That Always Runs
Sometimes you need code that runs regardless of whether an exception occurred β closing a file, releasing a lock, logging that the operation finished. This is what finally is for:
def process_migration_data(filepath: str) -> None:
f = None
try:
f = open(filepath, "r")
data = f.read()
# ... process data ...
print(f"Processed {len(data)} bytes of migration data.")
except FileNotFoundError:
print(f"Migration file not found: {filepath}")
finally:
if f:
f.close()
print("File closed.")
The finally block runs whether the try succeeded, whether an exception was caught, or even if an exception was raised that was not caught. It is the right place for cleanup.
In practice, for file handling you should use with (a context manager), which handles closing automatically and is cleaner than explicit finally. But finally remains essential for locks, database connections, temporary resources, and any cleanup that with does not cover.
raise: Signalling Problems Yourself
You are not limited to catching exceptions that Python raises. You can raise them yourself with raise when your own logic detects a problem:
def set_fly_speed(speed_kmh: float) -> float:
MAX_SPEED = 150.0
if speed_kmh < 0:
raise ValueError(f"Speed cannot be negative. Got: {speed_kmh}")
if speed_kmh > MAX_SPEED:
raise ValueError(
f"Speed {speed_kmh} km/h exceeds the maximum of {MAX_SPEED} km/h."
)
return speed_kmh
try:
set_fly_speed(200)
except ValueError as e:
print(f"Invalid speed: {e}")
Invalid speed: Speed 200 km/h exceeds the maximum of 150 km/h.
Remember the fly() method in our Bird class? It silently returned False when the speed was too high. That was fine as an example, but in production code raise ValueError is often the better choice β it forces the caller to deal with the problem explicitly rather than checking a return value that is easy to ignore.
Custom Exception Classes
For larger programs, Pythonβs built-in exceptions (ValueError, TypeError, KeyError, and so on) eventually become too generic. You want errors that describe your domain. This is done by subclassing Exception:
class BirdError(Exception):
"""Base class for all bird-related errors."""
pass
class InvalidWingspanError(BirdError):
"""Raised when a wingspan value is outside biological limits."""
def __init__(self, bird_name: str, wingspan_cm: int):
self.bird_name = bird_name
self.wingspan_cm = wingspan_cm
super().__init__(
f"{bird_name} wingspan of {wingspan_cm} cm is not realistic."
)
class UnknownBirdError(BirdError):
"""Raised when a bird species is not recognised."""
pass
def validate_wingspan(bird_name: str, wingspan_cm: int) -> int:
known_ranges = {
"Eagle": (150, 250),
"Pigeon": (30, 70),
"Albatross": (250, 350),
}
if bird_name not in known_ranges:
raise UnknownBirdError(f"Unknown bird species: '{bird_name}'")
min_ws, max_ws = known_ranges[bird_name]
if not (min_ws <= wingspan_cm <= max_ws):
raise InvalidWingspanError(bird_name, wingspan_cm)
return wingspan_cm
# Test it
test_cases = [
("Eagle", 200),
("Eagle", 500), # too large
("Pigeon", 50),
("Dragon", 400), # unknown
]
for name, ws in test_cases:
try:
validated = validate_wingspan(name, ws)
print(f"{name}: {validated} cm β OK")
except InvalidWingspanError as e:
print(f"Wingspan error: {e}")
except UnknownBirdError as e:
print(f"Unknown bird: {e}")
Eagle: 200 cm β OK
Wingspan error: Eagle wingspan of 500 cm is not realistic.
Pigeon: 50 cm β OK
Unknown bird: Unknown bird species: 'Dragon'
Notice the hierarchy: BirdError is the base, and InvalidWingspanError and UnknownBirdError extend it. This lets callers either catch specific errors or catch BirdError to handle all bird-related problems in one place. It is the same inheritance idea from the OOP post, applied to exceptions.
A Defensive Bird Registry
Here is a more complete example that pulls together everything from this post β a small bird registry that handles all the ways things can go wrong:
import json
from pathlib import Path
class BirdRegistryError(Exception):
pass
class DuplicateBirdError(BirdRegistryError):
pass
class BirdNotFoundError(BirdRegistryError):
pass
class BirdRegistry:
def __init__(self, filepath: str):
self.filepath = Path(filepath)
self.birds: dict[str, dict] = {}
self._load()
def _load(self) -> None:
try:
with open(self.filepath, "r") as f:
self.birds = json.load(f)
print(f"Loaded {len(self.birds)} birds from {self.filepath}.")
except FileNotFoundError:
print(f"No existing registry found at {self.filepath}. Starting fresh.")
except json.JSONDecodeError as e:
print(f"Registry file is corrupted: {e}. Starting fresh.")
finally:
print("Registry initialisation complete.")
def add_bird(self, name: str, **attributes) -> None:
if name in self.birds:
raise DuplicateBirdError(f"'{name}' is already in the registry.")
self.birds[name] = attributes
print(f"Added: {name}")
def get_bird(self, name: str) -> dict:
try:
return self.birds[name]
except KeyError:
raise BirdNotFoundError(f"'{name}' not found in registry.") from None
def save(self) -> None:
try:
with open(self.filepath, "w") as f:
json.dump(self.birds, f, indent=2)
print(f"Registry saved to {self.filepath}.")
except OSError as e:
raise BirdRegistryError(f"Could not save registry: {e}") from e
# Use it
registry = BirdRegistry("/tmp/birds.json")
try:
registry.add_bird("Eagle", color="brown", wingspan=200, can_fly=True)
registry.add_bird("Pigeon", color="grey", wingspan=50, can_fly=True)
registry.add_bird("Eagle", color="white") # duplicate!
except DuplicateBirdError as e:
print(f"Duplicate: {e}")
try:
print(registry.get_bird("Pigeon"))
print(registry.get_bird("Albatross")) # not found!
except BirdNotFoundError as e:
print(f"Not found: {e}")
registry.save()
No existing registry found at /tmp/birds.json. Starting fresh.
Registry initialisation complete.
Added: Eagle
Added: Pigeon
Duplicate: 'Eagle' is already in the registry.
{'color': 'grey', 'wingspan': 50, 'can_fly': True}
Not found: 'Albatross' not found in registry.
Registry saved to /tmp/birds.json.
The program never crashes. Every failure mode produces a clear, specific message. And if something truly unexpected happens β a full disk, a permission error β it propagates as a meaningful BirdRegistryError rather than a raw OSError that the caller has to decode.
Conclusion
Error handling is one of those things that feels like extra work when you first write a program, and like an obvious necessity when you have to debug one. The habits you build here β catching specific exceptions, raising early when something is wrong, writing custom exceptions for your domain β will save you hours of confusion later.
In the next post we look at Pythonβs standard library: the vast collection of tools that comes with Python and means you usually do not need to write things from scratch. File paths, dates, JSON, random numbers, data classes β it is all there, waiting.
Did you like this post? Please let me know if you have any comments or suggestions!