Trace, raise, recover, and chain failures precisely while organizing reusable modules and package entry points.
Exceptions make failed operations explicit, while modules make dependencies explicit. A reliable Python program lets successful code stay readable, raises precise failures when a contract cannot be fulfilled, recovers only where it has a real fallback, and organizes related behavior behind small module interfaces.
Learning goals
Read a traceback from the final exception back through its call stack.
Raise precise built-in or custom exceptions when a contract fails.
Use try, except, else, and finally for their distinct responsibilities.
Preserve causal context with raise ... from.
Organize code into modules and packages with explicit imports.
Keep import-time behavior small and protect script execution with a main guard.
Understand how exceptions move through calls
When an operation raises an exception, normal execution stops at that point. Python searches the current call for a matching handler, then unwinds outward through callers. If nothing handles the exception, the interpreter prints a traceback and the program exits with a failure status. This propagation lets a low-level function report failure without deciding how the whole application should respond.
PYTHON · EDITABLE
defparse_quantity(raw:str)->int:returnint(raw)deforder_total(raw_quantity:str, unit_cents:int)->int:
quantity = parse_quantity(raw_quantity)return quantity * unit_cents
try:print(order_total("three",1_995))except ValueError as error:print(type(error).__name__,"propagated through both function calls:", error)
Read the traceback as evidence
Start with the final line: it names the exception class and message. Then read upward through the most recent frames to find your code and the first incorrect value or violated assumption. The final frame is where Python raised, but the cause may be an earlier caller that supplied invalid data.
NameError usually means a name is missing or misspelled.
TypeError means an operation received an incompatible kind of object or call shape.
ValueError means the type is acceptable but the value is invalid for the operation.
KeyError and IndexError expose missing mapping keys and sequence positions.
OSError subclasses describe filesystem and operating-system failures.
Raise exceptions for broken contracts
Use raise when a function cannot return the result promised by its contract. Choose TypeError when the caller supplied the wrong kind of object and ValueError when the kind is acceptable but its value is outside the allowed domain. Use a message that states the failed rule without exposing secrets or irrelevant internals.
PYTHON · EDITABLE
defpercentage(completed:int, total:int)->float:ifnotisinstance(completed,int)ornotisinstance(total,int):raise TypeError("completed and total must be integers")if total <=0:raise ValueError("total must be positive")ifnot0<= completed <= total:raise ValueError("completed must be from 0 through total")return completed / total
Assertions are for conditions the programmer believes must be true inside correctly used code. They can be disabled and should not validate user input, permissions, payments, or other runtime boundaries. Raise an exception for data that can be invalid in normal operation.
Catch only what you can handle
A try block should contain the smallest operation whose expected failure you intend to handle. except names the specific exception. Put recovery or translation there, not a broad continuation with unknown state. Multiple exception types can share a handler when the recovery is genuinely identical.
PYTHON · EDITABLE
defread_quantity(raw:str)->int:try:
quantity =int(raw)except ValueError:return1# documented product fallbackif quantity <1:raise ValueError("quantity must be positive")return quantity
print(read_quantity("invalid"))
Separate success and cleanup with else and finally
else runs only when the try block completes without an exception. It keeps successful follow-up work outside the protected region so its own failures are not caught accidentally. finally runs whether the operation succeeds, raises, or returns, making it suitable for unavoidable cleanup. Prefer context managers for resources that support them.
PYTHON · EDITABLE
defparse_positive_int(raw:str)->int:try:
value =int(raw)except ValueError as error:print("Could not parse input")raiseelse:if value <1:raise ValueError("value must be positive")return value
finally:print("Parse attempt finished")
A bare raise inside an except block re-raises the active exception with its original traceback. Avoid returning from finally because it can suppress a pending exception or override an earlier return value, hiding the real control flow.
Translate failures without losing their cause
A lower-level exception often needs application context. raise NewError(...) from error creates an explicit causal chain so the traceback shows both failures. Use from None only when the original exception is an expected implementation detail and suppressing it genuinely improves the public error—not to conceal useful diagnostics.
PYTHON · EDITABLE
from json import JSONDecodeError, loads
defparse_config(raw:str)->dict:try:
value = loads(raw)except JSONDecodeError as error:raise ValueError("configuration is not valid JSON")from error
ifnotisinstance(value,dict):raise TypeError("configuration must be an object")return value
Create a small application exception hierarchy
Define a custom exception when callers need to distinguish a domain failure from ordinary built-in failures. Inherit from Exception, give the class a focused name ending in Error, and keep the hierarchy shallow. The exception can store structured context, but its message must remain safe to display or log in its intended boundary.
PYTHON · EDITABLE
classConfigurationError(Exception):"""Base class for invalid application configuration."""classMissingSettingError(ConfigurationError):def__init__(self, setting:str)->None:
self.setting = setting
super().__init__(f"missing required setting: {setting}")defrequire_setting(config:dict, name:str):try:return config[name]except KeyError as error:raise MissingSettingError(name)from error
Choose between attempting and pre-checking
Python often favors EAFP: attempt the operation and handle a specific expected failure. This avoids duplicated checks and race windows. LBYL—checking before acting—is useful when the check itself communicates a business rule or avoids an expensive operation. Neither style justifies catching broad exceptions.
PYTHON · EDITABLE
config:dict[str,str]={}# EAFP: one lookup, one precise failure.try:
theme = config["theme"]except KeyError:
theme ="system"# LBYL: membership expresses a distinct state.if"theme"in config:print("The user explicitly configured a theme")else:print("Using fallback theme:", theme)
Use context managers for resource cleanup
Files, locks, transactions, and similar resources need cleanup on every path. A with statement delegates acquisition and release to a context manager, which is clearer than manually coordinating finally. Cleanup errors should not silently replace the original failure without a deliberate policy.
PYTHON · EDITABLE
from pathlib import Path
deffirst_line(path: Path)->str:with path.open(encoding="utf-8")asfile:
line =file.readline()return line.rstrip("\n")
Use modules as explicit namespaces
Every .py file is a module. Importing it creates a module object, executes its top-level statements once per interpreter process, and caches it in sys.modules. Names accessed through the module namespace make ownership visible and reduce collisions.
PYTHON · EDITABLE
# pricing.pydeforder_total(unit_cents:int, quantity:int)->int:return unit_cents * quantity
# report.py would use: import pricing# Then call: pricing.order_total(1_995, 3)# This one-block preview calls the same public function directly.
total = order_total(1_995,3)print(total)
Prefer import module when the namespace improves clarity, or from module import name for a small, stable dependency. Avoid wildcard imports because readers and tools cannot easily see where names originate, and later module changes can create collisions.
Group modules into packages
A package groups related modules under one import namespace. A typical application may separate domain rules, storage adapters, and command-line entry points. __init__.py can mark a regular package and expose a deliberately small convenience interface, but it should not import the entire application or trigger expensive side effects.
Absolute imports name the package from its root and are usually clearest across an application. Explicit relative imports such as from .pricing import order_total describe a relationship inside one package. Run package code through its package entry point rather than changing sys.path inside source files.
PYTHON · EDITABLE
# course_app/progress.py would import a sibling with:# from .pricing import order_totaldefprogress_message(completed:int, total:int)->str:if total <=0:raise ValueError("total must be positive")returnf"{completed / total:.0%} complete"print(progress_message(3,12))
Define a deliberate script entry point
Python sets a directly executed module's __name__ to '__main__'. When another module imports it, __name__ is its import name. A main guard lets definitions remain reusable without running command-line behavior during import. Keep argument parsing and process exit behavior in a small main function.
PYTHON · EDITABLE
defmain()->int:print("Run the course report")return0if __name__ =="__main__":raise SystemExit(main())
Returning an integer from main and passing it to SystemExit makes the process status explicit: zero means success and nonzero indicates failure. Libraries should usually raise exceptions; the outer application boundary decides how those failures become messages and exit codes.
Keep import-time work small
Top-level definitions and constants are normal. Network requests, database connections, file writes, process exits, and expensive calculations at import time make modules difficult to test and reuse. Move operational work into functions and call it from an entry point.
Prevent circular dependencies
A circular import occurs when modules depend on each other before initialization is complete. Moving an import inside a function can defer the symptom, but the durable fix is usually architectural: move shared types or rules into a lower-level module, reverse a dependency through an interface, or combine modules that are not truly independent.
Keep domain modules independent of command-line and web adapters.
Move shared constants or data types to a neutral module.
Import dependencies in one direction through clear layers.
Avoid package __init__ files that eagerly import every submodule.
Treat a circular import as design feedback, not merely an import-order puzzle.
Expose a narrow public interface
A leading underscore marks a name as internal by convention. __all__ can define names exported by wildcard import, but documentation and stable import paths are the real public contract. Callers should depend on a small set of intended functions and classes rather than internal helpers.
PYTHON · EDITABLE
# course_app/pricing.py
__all__ =["order_total"]deforder_total(unit_cents:int, quantity:int)->int:return unit_cents * quantity
def_validate_quantity(quantity:int)->None:if quantity <1:raise ValueError("quantity must be positive")
Common exception and module mistakes
Catching Exception around a large block and continuing after unknown failure.
Using pass in an exception handler and discarding evidence.
Raising a generic exception when a precise built-in or domain error exists.
Translating an exception without preserving its cause.
Returning from finally and suppressing another result or failure.
Executing file, network, or process behavior during import.
Using wildcard imports or modifying sys.path to hide package problems.
Fixing circular imports through timing tricks instead of dependency design.
Build a configuration boundary
Run the notebook below with valid JSON, malformed JSON, a non-object value, and missing required settings. Trace each failure from its low-level cause to ConfigurationError, then explain why the outer run_case function catches the domain exception while parse_config does not print or exit.
Identify the exact statements inside each try block.
Explain what the else block guarantees before validation runs.
Inspect __cause__ on a translated JSON failure.
Add a required positive integer setting and raise a precise message.
Keep the parsing function independent from terminal presentation.
Lesson review
You can now trace exception propagation, raise and recover precisely, separate success from cleanup, preserve causal context, create focused domain errors, structure modules and packages, control import-time work, define a script entry point, and diagnose circular dependencies. Complete the quick checks and scored quiz before continuing to classes and data models.
RUNNABLE PYTHON NOTEBOOK
Build a configuration boundary
Parse valid and invalid JSON, translate low-level errors into a domain exception, inspect the preserved cause, and keep presentation at the outer application boundary.
import json
classConfigurationError(Exception):"""The application configuration cannot be used."""defparse_config(raw:str)->dict:try:
value = json.loads(raw)except json.JSONDecodeError as error:raise ConfigurationError("configuration is not valid JSON")from error
else:ifnotisinstance(value,dict):raise ConfigurationError("configuration must be an object")
required ={"theme","page_size"}
missing = required - value.keys()if missing:
names =", ".join(sorted(missing))raise ConfigurationError(f"missing settings: {names}")if value["theme"]notin{"light","dark","system"}:raise ConfigurationError("theme must be light, dark, or system")ifnotisinstance(value["page_size"],int)or value["page_size"]<1:raise ConfigurationError("page_size must be a positive integer")return value
defrun_case(label:str, raw:str)->None:try:
config = parse_config(raw)except ConfigurationError as error:print(f"{label}: ERROR — {error}")if error.__cause__ isnotNone:print(" caused by:",type(error.__cause__).__name__)else:print(f"{label}: OK — {config}")
run_case("valid",'{"theme": "dark", "page_size": 20}')
run_case("malformed",'{"theme": "dark"')
run_case("wrong shape",'["dark", 20]')
run_case("missing",'{"theme": "system"}')assert parse_config('{"theme": "light", "page_size": 10}')["page_size"]==10
OUTPUT
Press Run code to execute this Python program in your browser.
QUICK CHECK
Test what you learned
Which clause runs only when the try block completes without an exception?
QUICK CHECK
Test what you learned
Which syntax preserves an original exception as the cause of a translated exception?
QUICK CHECK
Test what you learned
Which special variable equals '__main__' when a module is executed directly?
KNOWLEDGE QUIZ
Check your exceptions-and-modules mental model
Choose an answer for every question, then check the explanations. The goal is not to memorize syntax; it is to predict what Python will do and why.