Skip to content

Typing & Errors

Python gives you two complementary tools for writing code that breaks loudly instead of silently:

  1. Type hints — annotations that describe what types a variable, parameter, or return value should hold. They are checked statically by tools like mypy or pyright, not at runtime.
  2. Exceptions — a signal mechanism that interrupts normal execution when something unexpected happens, letting you catch and recover or propagate errors up the call stack.

Together, type hints and exceptions let you communicate intent precisely and handle failures gracefully.

def greet(name: str, count: int = 1) -> str:
return (name + "! ") * count

name: str, count: int, and -> str are type hints. Python ignores them at runtime — they only exist for static checkers and human readers. They do not make greet("Alice") slower or faster.

try:
result = int("abc")
except ValueError as e:
print(f"Caught: {e}")
finally:
print("Always runs")

try wraps code that might fail. except catches a specific exception type. finally runs regardless of whether an exception occurred.

Type hints and exceptions are not mutually exclusive — they serve different phases:

PhaseTool
Static analysis (before running)Type hints + mypy/pyright
Runtime failures (while running)Exceptions

A function signature tells callers what types are expected; exceptions tell callers what can go wrong at runtime. Neither one substitutes for the other: a clean mypy --strict run does not mean the program cannot crash, and a program with careful try/except everywhere can still ship with Any scattered through every signature, providing no static guarantees at all.

Decision table: what to raise, what to catch, what to let through

Section titled “Decision table: what to raise, what to catch, what to let through”

Every lesson in this module answers one piece of this table. Use it as a map while reading, or as a quick reference afterward.

SituationWhat to doWhy
Input from outside your program is invalidraise ValueError/TypeError (or a custom subclass), never assertassert is stripped entirely under python -O
You can meaningfully recover from a specific failureexcept SpecificError:, act, continueBroad except hides bugs that were never the failure you meant to handle
You cannot recover, but must clean uptry/finally, or a context managerCleanup must run whether or not the exception is ultimately handled
A library call fails and you add contextraise NewError(...) from originalPreserves the chain (__cause__) instead of losing the original error
You must log and continue at a top-level boundaryexcept Exception: (never bare except:), logging.exception(...)Exception excludes KeyboardInterrupt/SystemExit; logging.exception keeps the traceback
A parameter should reject one clear shapeA precise type hint (Literal, Protocol, Sequence, …), not AnyAny gives up static checking entirely — see Type Hints
Several unrelated operations can fail independentlyexcept* over an ExceptionGroupPlain except cannot see inside a group
You deliberately want to ignore one specific failurecontextlib.suppress(SpecificError)States exactly what is ignored, unlike except: pass
LessonWhat you will learn
Type HintsAnnotating variables, params, returns; Optional, Union, invariance, Any, Protocol, TypedDict, TypeGuard/TypeIs, and where mypy and pyright disagree
ExceptionsThe exception hierarchy, try/except/else/finally, raise vs raise e, EAFP vs LBYL, except*, reading a traceback
Context Managerswith, __enter__/__exit__, when __exit__ swallows an exception, ExitStack, contextlib.suppress
Custom ErrorsSubclassing Exception, why except X as e: deletes e, implicit vs explicit chaining, add_note()
from typing import Optional
def find_item(items: list[str], target: str) -> Optional[int]:
"""Return the index of target in items, or None if not found."""
for i, item in enumerate(items):
if item == target:
return i
return None
fruits = ["apple", "banana", "cherry"]
# type hints describe intent — no runtime enforcement
index = find_item(fruits, "banana")
print(f"Found at index: {index}")
missing = find_item(fruits, "mango")
print(f"Not found: {missing}")
# exceptions signal runtime failures
try:
value = fruits[10]
except IndexError as e:
print(f"IndexError: {e}")
# the else clause runs only when no exception was raised
try:
value = fruits[1]
except IndexError:
print("Out of range")
else:
print(f"Got: {value}")

Keep two failure modes apart, since the rest of this module treats them very differently. A type error is something a checker can catch before the program ever runs, by reading annotations — it costs nothing at runtime and catches nothing a checker was not run against. A runtime exception is something Python itself detects while the program runs, unwinding the call stack until a handler catches it or the program exits. Decorative type hints with no checker run in CI, and defensive try/except blocks with no thought given to which exception is expected, are the two most common ways both systems end up doing nothing useful at all.

What you getBenefitCost
Type hints + mypy/pyright in CIWhole classes of bugs caught before code runsSomeone has to actually run the checker and fix what it finds
Precise except clausesBugs outside the expected failure still crash loudlyMore clauses to write than one broad except
Custom exception hierarchyCallers catch at the specificity they needMore classes to define and maintain
Context managers for cleanupCleanup guaranteed regardless of how the block exitsSlightly more ceremony than manual open/close
  • “Type hints and exceptions solve the same problem.” Type hints prevent a class of bugs from ever running; exceptions handle failures that only exist because the program is actually running (a missing file, bad user input, a network timeout) — no amount of static typing eliminates those.
  • “If the code has type hints, mypy must already be passing.” Type hints are just annotations; nothing forces anyone to actually run a checker against them, and Any or # type: ignore can make a “fully typed” file enforce nothing at all.
  • “Catching Exception is basically the same as catching everything.” It deliberately excludes KeyboardInterrupt, SystemExit, and GeneratorExit — see the Exceptions lesson for why that boundary exists.
  • “A function with no explicit raise cannot fail.” Nearly every operation (indexing, attribute access, arithmetic, a library call) can raise on its own; the absence of a raise statement says nothing about the absence of failure modes.

This whole module trains one skill: reading a function’s signature and body and asking “what does this actually guarantee, versus what does it merely suggest?” Before trusting generated code in this area:

  • Does a parameter or return type use Any, effectively opting the whole function out of static checking?
  • Is there a bare except: or an except Exception: pass with no logging, silently hiding failures?
  • Does the code use assert for anything that must be validated in production, where -O would remove it?
  • Is a custom exception’s except ... as e: binding read anywhere after its block has ended?
  • Was the code actually run through mypy --strict and pyright, or do the type hints just look plausible?

🐛 Spot the bug

from typing import Any
def get_config(data: dict[str, object], key: str) -> Any:
try:
return data[key]
except:
return None
value = get_config({"timeout": "30"}, "timeout")
print(value + 5)
Show the answer

Two problems compound here. First, -> Any means the checker never verifies what get_config actually returns — callers get zero protection. Second, the bare except: means a missing key silently becomes None, indistinguishable from a real error. The config value "timeout": "30" is a str, not an int — get_config returns it unchanged, and value + 5 raises TypeError: can only concatenate str (not "int") to str far away from where the real mistake (storing a string instead of a number, or never validating the config) actually happened. The fix touches both lessons: type get_config precisely instead of Any, and catch KeyError specifically instead of everything.

What does Python do with type hints at runtime?
Which block in a try statement runs regardless of whether an exception was raised?
When does the else block of a try statement execute?
Which tool checks Python type hints statically?
What actually happens when this runs, and why is the -> Any-style signature part of the problem?
def get_config(data: dict, key: str):
    try:
        return data[key]
    except:
        return None

print(get_config({}, "port") + 1)
A function is fully annotated with precise types, but the team never runs mypy or pyright in CI. What is actually guaranteed by the type hints alone?
What does this print?
def cleanup():
    try:
        raise ValueError("boom")
    finally:
        return "done"

print(cleanup())