Notifications

No notifications

/Phase 3

Error Handling

Error Handling in Python

Errors are inevitable in programming. Python's exception handling system lets you catch, handle, and recover from errors gracefully using try, except, else, and finally.

Basic try/except

try:
    result = 10 / 0
except ZeroDivisionError:
    print("Cannot divide by zero!")

Full Syntax

try:
    value = int(input("Enter a number: "))
except ValueError:
    print("Invalid input!")
else:
    print(f"You entered: {value}")   # Runs only if no exception
finally:
    print("Cleanup here.")           # Always runs

Common Built-in Exceptions

ExceptionCause
ValueErrorWrong value type (e.g., int("abc"))
TypeErrorWrong operation for type (e.g., "a" + 1)
KeyErrorMissing dictionary key
IndexErrorList index out of range
FileNotFoundErrorFile doesn't exist
ZeroDivisionErrorDivision by zero
AttributeErrorObject has no such attribute
ImportErrorModule import fails

Catching Multiple Exceptions

try:
    data = [1, 2, 3]
    print(data[10])
except (IndexError, KeyError) as e:
    print(f"Lookup error: {e}")
except Exception as e:
    print(f"Unexpected: {e}")

Raising Exceptions

def set_age(age):
    if age < 0:
        raise ValueError("Age cannot be negative")
    return age

Custom Exceptions

class InsufficientFundsError(Exception):
    def __init__(self, balance, amount):
        self.balance = balance
        self.amount = amount
        super().__init__(
            f"Cannot withdraw ${amount}: only ${balance} available"
        )

Assert Statements

assert len(data) > 0, "Data must not be empty"

> Tip: Catch specific exceptions — never use bare except:. The else block is useful for code that should only run when no error occurred, and finally is perfect for cleanup like closing connections.

On this page

Detailed Theory

Errors aren't bugs to hide — they're *information*. Python's exception system lets you signal that something went wrong, decide where to handle it, and clean up resources cleanly. Get this right and your programs become honest, debuggable, and resilient.

What an Exception Actually Is

When something goes wrong, Python raises an exception — a special object that walks back up the call stack until something catches it (or the program crashes with a traceback). Examples:

int("abc")            # ValueError: invalid literal for int()
[1, 2][5]             # IndexError
{"a": 1}["b"]         # KeyError
open("missing.txt")   # FileNotFoundError
1 / 0                  # ZeroDivisionError

try / except / else / finally

try:
    x = int(user_input)
except ValueError:
    print("not a number")
except (TypeError, KeyError) as e:
    print("some other error:", e)
else:
    print("parsed:", x)        # only if no exception
finally:
    print("always runs")        # cleanup

  • try — the risky code.
  • except — catches specific types (catch the narrowest type you expect).
  • else — runs if no exception fired (keeps the success path tiny).
  • finally — always runs, even on return or unhandled re-raises.

Raising Errors

def set_age(age):
    if not isinstance(age, int):
        raise TypeError("age must be int")
    if age < 0:
        raise ValueError("age must be non-negative")

Validate at function boundaries; raise *specific* exception types so callers can react. Don't raise Exception(...) — too broad.

Beginner Mistakes to Skip

1. Bare except:. Catches *everything* — including KeyboardInterrupt and SystemExit. At minimum use except Exception:; better, name the type. 2. Silently swallowing errors (except: pass). Hides bugs forever. At least log the exception. 3. Catching too wide. except Exception: around 50 lines means you can't tell what failed. Wrap only the line that may raise. 4. Using exceptions for control flow in hot loops. They're slower than checks. (Some EAFP is fine — see below — just don't loop on it.) 5. raise e losing the traceback. Just raise (no argument) re-raises with full context. 6. Using assert for input validation. Asserts can be disabled with python -O. Use real raise for production checks.

Intermediate: EAFP vs LBYL

Python culture prefers EAFP — "Easier to Ask Forgiveness than Permission":

# EAFP — try and handle
try:
    value = config["port"]
except KeyError:
    value = 8080

# LBYL — look before you leap if "port" in config: value = config["port"] else: value = 8080

EAFP is often clearer and avoids race conditions (especially for files). For dicts, config.get("port", 8080) is cleanest of all.

Intermediate: Custom Exceptions

class AppError(Exception):
    """Base for all app-level errors."""

class UserNotFound(AppError): def __init__(self, user_id): super().__init__(f"user {user_id} not found") self.user_id = user_id

try: fetch_user(42) except UserNotFound as e: log.warn("missing", id=e.user_id)

Creating your own exception hierarchy lets callers catch your library's errors without catching unrelated ones.

Intermediate: Cleanup with with

Whenever you'd reach for try / finally to release a resource, prefer with:

with open("file") as f:
    data = f.read()        # auto-close, even on exceptions

import threading lock = threading.Lock() with lock: critical_section() # auto-release

For anything that has "acquire / release" semantics, there's almost always a context manager.

Intermediate: Reading Tracebacks Like a Pro

Traceback (most recent call last):
  File "main.py", line 10, in <module>
    process(items)
  File "main.py", line 4, in process
    return totals[i] / count
ZeroDivisionError: division by zero

Read bottom up:

1. Last line = the actual exception class + message. 2. Line above = the file/line where it was raised. 3. Earlier frames = how you got there.

90% of debugging is calmly reading this.

Advanced: Exception Chaining

try:
    value = int(text)
except ValueError as e:
    raise RuntimeError("could not parse config") from e

from e preserves the original cause in the traceback (The above exception was the direct cause of...). Use it when re-raising as a higher-level error — keeps the diagnostic trail intact.

Use raise NewError(...) from None to suppress the chain when the underlying cause is implementation noise.

Advanced: Exception Groups (Python 3.11+)

try:
    run_parallel_tasks()
except* TimeoutError as eg:
    log.warn("timeouts:", eg.exceptions)
except* ValueError as eg:
    log.error("bad inputs:", eg.exceptions)

ExceptionGroup and except* let you handle *multiple* simultaneous failures (e.g., from asyncio.TaskGroup). Modern concurrency depends on this.

Advanced: logging vs print for Errors

import logging
log = logging.getLogger(__name__)

try: risky() except SomeError: log.exception("risky failed") # logs full traceback at ERROR level

log.exception includes the full traceback automatically. In production, configure logging once and forget about print.

Advanced: Where to Catch — Boundaries

A solid rule: handle exceptions at system boundaries (HTTP request handlers, CLI entry points, job runners). Inner code should *raise*, not *swallow*. This keeps business logic clean and gives you exactly one place to log + return a friendly response.

@app.post("/users")
def create_user(payload):
    try:
        user = users.create(payload)
        return user.to_json()
    except UserAlreadyExists:
        return {"error": "duplicate"}, 409
    except ValidationError as e:
        return {"error": str(e)}, 400
    except Exception:
        log.exception("unexpected")
        return {"error": "server error"}, 500

Practice Path

1. Wrap int(input("n: ")) in a try/except ValueError loop that re-prompts until valid. 2. Define a custom InsufficientFunds exception and raise it from a withdraw method; catch it at the call site. 3. Use raise ... from e when wrapping a low-level KeyError as a higher-level ConfigMissing. 4. Replace a try/finally that closes a file with a with open(...) block; verify cleanup still happens on a thrown exception.