FreeCampus Python

Raise Useful Errors and Handle Expected Failures

Raise precise built-in exceptions, catch only anticipated failures at informed boundaries, and preserve causes while adding useful context.
python-foundations errors-exceptions-debugging exception-handling validation
Open in Colab
  • Level: Python Foundations
  • Estimated time: 4–5 hours
  • You will learn: Raise meaningful built-in exceptions, place narrow try blocks, handle only failures a boundary can resolve, and chain contextual errors to their original causes.
  • Practice in: Google Colab, JupyterLab, or a local editor

A text adventure receives commands such as "OPEN chest" and "TAKE key". Malformed input is expected: a player may omit the target or type an unsupported action. A programming defect is not expected: if movement code uses an undefined name, the game should not pretend the player merely mistyped a command.

Good exception design preserves that difference. It answers four questions:

  1. What does this function promise to return?
  2. Which inputs violate that promise, and which exception explains why?
  3. Where does the program know enough to recover, retry, report, or continue?
  4. Which failures must remain visible because this layer cannot handle them?

1. Return normal results and raise exceptional failures

Start with a command parser whose normal result is one normalized tuple:

def parse_quest_command(text):
    """Return a normalized (action, target) command."""
    parts = text.split()
    if len(parts) != 2:
        raise ValueError("command must contain an action and a target")

    action, target = parts
    return action.upper(), target.lower()


print(parse_quest_command("OPEN Chest"))

Line by line:

  1. split() turns whitespace-separated input into parts.
  2. The function checks the supported shape before unpacking.
  3. raise ValueError(...) stops this function call when the value violates its contract, even though the input is still a string.
  4. The normal path returns normalized data with one predictable shape.

Do not use a magic return such as None, False, or an empty tuple unless that value is a normal, documented result. A caller can accidentally pass a magic value deeper into the program and lose the original explanation.

def find_key(inventory):
    """Return the key name if present, otherwise None."""
    for item in inventory:
        if item.endswith(" key"):
            return item
    return None

Here, not finding a key is an ordinary search result, so None is reasonable. In contrast, a command with three words violates the parser’s accepted shape, so ValueError makes the failed contract explicit.

2. Choose an exception that describes the broken contract

For foundation-level interfaces, built-in exceptions cover many useful cases:

Exception Use it when Example message
TypeError the object type is unsupported command must be a string
ValueError the type is acceptable but its value or shape is unsupported steps must be positive
KeyError a required mapping key is absent and mapping-style lookup is the contract Python’s normal missing-key message
IndexError a requested sequence position is outside the supported range Python’s normal invalid-index message
RuntimeError the operation cannot proceed because of runtime state and no more specific exception fits quest has not started

Validate type and value separately when the distinction helps the caller:

def parse_repeat_count(raw_count):
    """Return a positive repeat count from an integer string."""
    if not isinstance(raw_count, str):
        raise TypeError("repeat count must be text")

    count = int(raw_count)
    if count <= 0:
        raise ValueError("repeat count must be positive")
    return count

parse_repeat_count(3) violates the type contract. parse_repeat_count("0") has the accepted type but violates the range rule. parse_repeat_count("many") lets int raise its own ValueError; a later section adds command context.

Messages should help the reader act without leaking secrets. Mention the field, rule, or position; avoid dumping credentials or an entire private record.

def require_energy(energy):
    """Return energy if it can power one move."""
    if energy < 1:
        raise ValueError(f"energy must be at least 1; received {energy}")
    return energy

Checkpoint: design the function contract

3. Exceptions travel upward until a caller handles them

When a function raises, its normal path stops. Python leaves that frame and returns control to its caller only if a matching except block exists there. Otherwise, unwinding continues up the call stack.

A low-level parser can report the violation while an outer interaction boundary decides what the player should see.

flowchart BT
  parser["parse_quest_command raises ValueError"] --> turn["play_turn has no matching handler"]
  turn --> loop["game loop catches anticipated ValueError"]
  loop --> report["show feedback and request another command"]

Unexpected exceptions continue past this boundary and remain visible to the developer.

Use a small example to observe propagation:

def decode_steps(raw_steps):
    return int(raw_steps)


def build_move(raw_steps):
    steps = decode_steps(raw_steps)
    return {"action": "MOVE", "steps": steps}


try:
    build_move("many")
except ValueError as error:
    print("command rejected:", error)

decode_steps does not need to catch ValueError merely to raise the same thing. build_move cannot recover either. The outer boundary can turn an anticipated conversion failure into player feedback.

Catch an exception only where you can make a responsible decision:

  • ask for another value;
  • skip one independently invalid item while recording why;
  • translate a technical failure into domain context;
  • release a resource and re-raise; or
  • end one operation cleanly.

If a layer can do none of those, let the exception propagate.

4. Keep the try block as narrow as the claim

This handler is too broad in two ways:

def play_turn_too_broad(text, inventory):
    try:
        action, target = parse_quest_command(text)
        inventory.apend(target)
        return f"completed {action} {target}"
    except Exception:
        return "invalid command"

The misspelled .apend raises AttributeError, a programming defect. The broad handler relabels it as player error. The program appears resilient while losing the very evidence needed to repair it.

A better boundary surrounds only the anticipated operation:

def play_turn(text, inventory):
    try:
        action, target = parse_quest_command(text)
    except ValueError as error:
        return f"command rejected: {error}"

    inventory.append(target)
    return f"completed {action} {target}"

Now the except claim is precise: “invalid command shape is expected here.” If inventory code raises AttributeError, the defect stays visible.

WarningA passing program can still be hiding failures

except Exception: pass does not prove recovery. It proves only that many failures were silenced. Handle a specific anticipated type, retain evidence, and keep the protected region narrow.

5. Match specific exceptions before general ones

Handlers are tested from top to bottom. Put more specific responses first:

def parse_slot(raw_slot, slots):
    """Return the item stored at a text index."""
    try:
        index = int(raw_slot)
        return slots[index]
    except ValueError as error:
        return f"slot must be an integer: {error}"
    except IndexError as error:
        return f"slot is outside the inventory: {error}"

This example catches two anticipated failures from a deliberately small region. If the result is needed as data rather than UI text, a better design may let the exceptions propagate instead of returning mixed result shapes. Boundary design depends on who calls the function.

You can group exceptions only when the same response is truly appropriate:

def read_required_pair(mapping):
    """Return two required integer fields or explain malformed input."""
    try:
        left = int(mapping["left"])
        right = int(mapping["right"])
    except (KeyError, ValueError) as error:
        raise ValueError("pair needs integer 'left' and 'right' fields") from error
    return left, right

Do not group types simply to write fewer lines. Ask whether the caller should react identically and whether the new message remains accurate for each cause.

6. Use else and finally for different jobs

An else suite runs only when the try suite finishes without a matching exception. It keeps success work outside the protected region:

def describe_repeat(raw_count):
    try:
        count = int(raw_count)
    except ValueError as error:
        return f"invalid repeat count: {error}"
    else:
        return f"The spell will repeat {count} times."

If string formatting in else had a defect, this handler would not mislabel it as an integer-conversion problem.

A finally suite runs whether the protected operation returns, raises, or is handled. Use it for cleanup that must happen:

def demonstrate_cleanup(raw_count):
    events = []
    try:
        events.append("start conversion")
        count = int(raw_count)
    except ValueError:
        events.append("conversion rejected")
    else:
        events.append(f"converted {count}")
    finally:
        events.append("close turn record")
    return events


print(demonstrate_cleanup("3"))
print(demonstrate_cleanup("many"))

Avoid return in finally. A return there can replace a normal return or suppress an active exception, erasing evidence:

def safe_cleanup_pattern(raw_count):
    try:
        result = int(raw_count)
    finally:
        print("cleanup happened")
    return result

Later units use context managers such as with for resource cleanup. The same principle applies: cleanup should not disguise the operation’s outcome.

Checkpoint: place the boundary precisely

7. Add context and preserve the original cause

Low-level exceptions know technical details. Outer layers know domain details. Exception chaining preserves both:

def parse_damage(raw_damage, turn_number):
    """Return positive damage or raise a turn-specific ValueError."""
    try:
        damage = int(raw_damage)
    except ValueError as error:
        raise ValueError(
            f"turn {turn_number}: damage must be an integer; got {raw_damage!r}"
        ) from error

    if damage <= 0:
        raise ValueError(
            f"turn {turn_number}: damage must be positive; got {damage}"
        )
    return damage

There are two ValueError paths, but only conversion chains an underlying cause. A nonpositive integer was parsed successfully; the function itself rejects the domain value, so there is no hidden conversion exception to invent.

Inspect the chain:

try:
    parse_damage("many", 4)
except ValueError as error:
    print("context:", error)
    print("cause type:", type(error.__cause__).__name__)
    print("cause message:", error.__cause__)

Use a bare raise inside a handler when you record evidence but cannot actually recover:

def parse_with_observation(raw_damage):
    try:
        return int(raw_damage)
    except ValueError:
        print("conversion failed for", repr(raw_damage))
        raise

Bare raise preserves the current exception and traceback. raise error can alter traceback details; creating an unrelated new exception discards the causal link unless you chain it explicitly.

8. Assertions check developer assumptions, not public input

An assertion is excellent for an internal condition that should be true if the program is correct:

def apply_quest_rewards(rewards):
    total = sum(rewards)
    assert total >= 0, "reward total should never be negative"
    return total

Do not make assert the only validation for player input:

def set_volume(volume):
    """Return a supported game volume from 0 through 10."""
    if not isinstance(volume, int):
        raise TypeError("volume must be an integer")
    if not 0 <= volume <= 10:
        raise ValueError("volume must be between 0 and 10")
    return volume

Assertions can be disabled with Python optimization settings, and AssertionError does not communicate the public input category as precisely. Use explicit exceptions for an interface contract; use assertions to make developer assumptions executable.

9. EAFP and LBYL are choices, not slogans

Python code often uses EAFP: attempt the operation and handle an anticipated exception (“easier to ask forgiveness than permission”). LBYL checks a condition first (“look before you leap”). Neither style means catch everything.

LBYL can clearly express a domain rule:

def spend_energy(energy, cost):
    if cost < 0:
        raise ValueError("cost cannot be negative")
    if cost > energy:
        raise ValueError("not enough energy")
    return energy - cost

EAFP can avoid duplicating an operation’s own validation:

def convert_coordinates(raw_x, raw_y):
    try:
        return int(raw_x), int(raw_y)
    except ValueError as error:
        raise ValueError("coordinates must be integers") from error

Choose based on clarity, supported races or state changes, and the contract. Do not probe with an unreliable check and then assume the later operation cannot fail. Do not catch a broad type merely to call the code “Pythonic.”

Custom exception classes become valuable when callers need to distinguish domain failures that built-in types cannot express cleanly. Unit 11 introduces classes; this unit deliberately practices precise built-in exceptions first.

10. Build a reliable quest command boundary

Use this input set:

quest_commands = [
    "OPEN chest",
    "TAKE",
    "MOVE north",
    "CAST lantern",
    "TAKE key",
]

Implement the contract in stages:

SUPPORTED_ACTIONS = {"OPEN", "TAKE", "MOVE"}


def parse_quest_command(text, turn_number):
    """Return normalized (action, target) or raise contextual ValueError."""
    if not isinstance(text, str):
        raise TypeError(f"turn {turn_number}: command must be text")

    parts = text.split()
    if len(parts) != 2:
        raise ValueError(
            f"turn {turn_number}: command needs exactly two words"
        )

    action, target = parts
    action = action.upper()
    target = target.lower()
    if action not in SUPPORTED_ACTIONS:
        raise ValueError(
            f"turn {turn_number}: unsupported action {action!r}"
        )
    return action, target

Check the parser independently:

assert parse_quest_command("open Chest", 1) == ("OPEN", "chest")
assert parse_quest_command("TAKE key", 2) == ("TAKE", "key")

try:
    parse_quest_command("TAKE", 3)
except ValueError as error:
    assert "turn 3" in str(error)
    assert "two words" in str(error)
else:
    raise AssertionError("one-word command should be rejected")

Then add the boundary that can continue after anticipated command mistakes:

def collect_quest_commands(commands):
    """Return accepted commands and player-readable rejection messages."""
    accepted = []
    rejected = []

    for turn_number, text in enumerate(commands, start=1):
        try:
            command = parse_quest_command(text, turn_number)
        except (TypeError, ValueError) as error:
            rejected.append(str(error))
        else:
            accepted.append(command)

    return accepted, rejected


accepted, rejected = collect_quest_commands(quest_commands)
assert accepted == [
    ("OPEN", "chest"),
    ("MOVE", "north"),
    ("TAKE", "key"),
]
assert len(rejected) == 2
assert all("turn" in message for message in rejected)

Try these controlled changes:

  1. replace one string with None; confirm TypeError is reported with its turn;
  2. add extra spaces around a valid command; explain why split() accepts it;
  3. introduce an AttributeError after parsing and verify this boundary does not hide it;
  4. change the supported-action set and rerun all checks.
Show one way to verify the unexpected-defect boundary

Use a temporary collaborator that fails after parsing, then confirm the failure is not converted into a command rejection:

def apply_parsed_command(command):
    """Represent later game behavior with a deliberate unexpected defect."""
    action, target = command
    raise AttributeError(f"unfinished handler for {action} {target}")


def run_one_turn(text, turn_number):
    try:
        command = parse_quest_command(text, turn_number)
    except (TypeError, ValueError) as error:
        return f"command rejected: {error}"
    return apply_parsed_command(command)


try:
    run_one_turn("OPEN chest", 1)
except AttributeError as error:
    print("unexpected defect remained visible:", error)

The parsing handler is still narrow. Remove the deliberate defect after the experiment; it is evidence about the boundary, not finished game behavior.

Checkpoint: preserve the useful failure

11. Key points for debugging

  • Return ordinary outcomes; raise when a function cannot honor its documented contract.
  • Choose a specific built-in exception and a safe message that names the broken rule or location.
  • Let exceptions propagate until a boundary knows how to recover, report, retry, skip, clean up, or terminate responsibly.
  • Keep try regions narrow and handlers specific so unrelated defects remain visible.
  • Use else for success work and finally for unavoidable cleanup; do not erase results or exceptions with a finally return.
  • Chain contextual exceptions with raise ... from error, and use bare raise when recording evidence before re-raising the current exception.
  • Use explicit validation for public input and assertions for developer assumptions.

References and next steps

The next lesson begins with a program that raises no exception at all. You will use expected-versus-actual evidence, state traces, minimal cases, and controlled experiments to locate the first wrong decision.

Back to top