Elena' s AI Blog

Python Error Handling: When Birds Misbehave

10 May 2026 (updated: 10 May 2026) / 17 minutes to read

Elena Daehnhardt


Midjourney 7.0: Three glowing monitors showing Python code, HD


TL;DR:
  • Use try/except to catch errors, finally for cleanup that always runs, raise to signal problems yourself, and custom exception classes to make your errors meaningful. Write code that fails loudly and helpfully, not silently and mysteriously.

πŸ“š This post is part of the "Python Basics" series

Series: Python Basics (Part 3 of 3)

Previous: Part 1 β€” Python Programming Language

Next: Part 5 β€” Python classes and pigeons

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!

References

  1. Python Documentation β€” Errors and Exceptions
  2. Python Built-in Exceptions
  3. PEP 3151 β€” Reworking the OS and IO Exception Hierarchy
  4. Python Functions β€” Elena Daehnhardt
  5. Python Classes and OOP β€” Elena Daehnhardt
desktop bg dark

About Elena

Elena, a PhD in Computer Science, simplifies AI concepts and helps you use machine learning.

Citation
Elena Daehnhardt. (2026) 'Python Error Handling: When Birds Misbehave', daehnhardt.com, 10 May 2026. Available at: https://daehnhardt.com/blog/2026/05/10/python-error-handling/
All Posts