Exceptions: try/except/else/finally & Custom Exceptions
📖 Concept
Python uses an exception-based error handling model rather than error codes. Every exception is an instance of a class that inherits from BaseException, with most user-facing exceptions inheriting from Exception. Understanding the full try/except/else/finally control flow and knowing how to write custom exception hierarchies is critical for building robust, maintainable systems.
The full try block anatomy:
try:
result = risky_operation() # Code that might raise
except SpecificError as e: # Catch specific exception
handle_error(e)
except (TypeError, ValueError): # Catch multiple types
handle_multiple()
else: # Runs ONLY if no exception was raised
process(result)
finally: # ALWAYS runs — cleanup code
release_resources()
Key principles:
- Catch specific exceptions — never use bare
except:orexcept Exception:unless you re-raise. Bare excepts swallowKeyboardInterruptandSystemExit. - EAFP over LBYL — "Easier to Ask Forgiveness than Permission" is Pythonic. Use
try/exceptrather than checking conditions first (if key in dictvstry: dict[key]). - Exception chaining — Python 3 automatically chains exceptions (
__cause__viaraise X from Y,__context__for implicit chaining). This preserves the full error trail. ExceptionGroup(3.11+) — allows raising and catching multiple unrelated exceptions simultaneously usingexcept*syntax, essential for concurrent/async error handling.
| Pattern | Use Case |
|---|---|
raise ValueError("msg") |
Signal invalid input |
raise from original |
Explicit chaining — set __cause__ |
raise from None |
Suppress chaining — hide original traceback |
except* TypeError |
Catch one type from an ExceptionGroup |
Custom exceptions should form a hierarchy rooted at a module-level base exception, letting callers catch broad or narrow categories as needed.
💻 Code Example
1# ============================================================2# Full try/except/else/finally control flow3# ============================================================4def divide(a, b):5 """Demonstrate every clause of try/except/else/finally."""6 try:7 result = a / b8 except ZeroDivisionError:9 print("Cannot divide by zero")10 return None11 except TypeError as e:12 print(f"Type error: {e}")13 return None14 else:15 # Runs ONLY when no exception was raised16 # Put success-path logic here, NOT in the try block17 print(f"Division successful: {a} / {b} = {result}")18 return result19 finally:20 # ALWAYS runs — even if return/break/continue was hit above21 # Use for cleanup: closing files, releasing locks, etc.22 print("Division operation completed (finally block)")2324divide(10, 3) # Success path → else → finally25divide(10, 0) # ZeroDivisionError → except → finally26divide("a", 2) # TypeError → except → finally272829# ============================================================30# BAD: Common anti-patterns31# ============================================================32# BAD — bare except swallows KeyboardInterrupt, SystemExit33# try:34# do_something()35# except: # NEVER do this36# pass3738# BAD — too broad, hides bugs39# try:40# user = get_user(user_id)41# send_email(user.email)42# except Exception:43# print("Something went wrong") # Which line failed? We'll never know4445# BAD — using exceptions for normal control flow46# try:47# value = my_list[999]48# except IndexError:49# value = "default" # Use: value = my_list[999] if len(my_list) > 999 else "default"505152# ============================================================53# GOOD: Specific, informative exception handling54# ============================================================55import json56from pathlib import Path5758def load_config(filepath: str) -> dict:59 """Load and validate a JSON configuration file."""60 path = Path(filepath)6162 try:63 raw_text = path.read_text(encoding="utf-8")64 except FileNotFoundError:65 raise FileNotFoundError(66 f"Config file not found: {path.resolve()}. "67 f"Create it from config.example.json"68 )69 except PermissionError:70 raise PermissionError(71 f"Cannot read config file: {path.resolve()}. "72 f"Check file permissions (current: {oct(path.stat().st_mode)})"73 )7475 try:76 config = json.loads(raw_text)77 except json.JSONDecodeError as e:78 raise ValueError(79 f"Invalid JSON in {filepath} at line {e.lineno}, "80 f"column {e.colno}: {e.msg}"81 ) from e # Chain to preserve original error8283 # Validate required keys84 required = {"database_url", "secret_key", "debug"}85 missing = required - config.keys()86 if missing:87 raise KeyError(f"Missing required config keys: {missing}")8889 return config909192# ============================================================93# Custom exception hierarchy — production pattern94# ============================================================95class AppError(Exception):96 """Base exception for the entire application.97 All custom exceptions inherit from this, so callers can98 catch AppError to handle any app-specific error."""99100 def __init__(self, message: str, code: str = "UNKNOWN", details: dict = None):101 super().__init__(message)102 self.code = code103 self.details = details or {}104105 def to_dict(self) -> dict:106 """Serialize for API error responses."""107 return {108 "error": self.code,109 "message": str(self),110 "details": self.details,111 }112113114class ValidationError(AppError):115 """Invalid input data."""116117 def __init__(self, field: str, message: str, value=None):118 super().__init__(119 message=f"Validation failed for '{field}': {message}",120 code="VALIDATION_ERROR",121 details={"field": field, "rejected_value": repr(value)},122 )123 self.field = field124125126class NotFoundError(AppError):127 """Resource not found."""128129 def __init__(self, resource: str, identifier):130 super().__init__(131 message=f"{resource} with id '{identifier}' not found",132 code="NOT_FOUND",133 details={"resource": resource, "id": identifier},134 )135136137class AuthenticationError(AppError):138 """Authentication failure."""139140 def __init__(self, reason: str = "Invalid credentials"):141 super().__init__(message=reason, code="AUTH_ERROR")142143144# Usage in an API handler145def get_user(user_id: int) -> dict:146 """Simulate fetching a user with proper error handling."""147 if not isinstance(user_id, int) or user_id < 1:148 raise ValidationError("user_id", "Must be a positive integer", user_id)149150 users_db = {1: {"name": "Alice"}, 2: {"name": "Bob"}}151152 if user_id not in users_db:153 raise NotFoundError("User", user_id)154155 return users_db[user_id]156157158# Caller can catch specific or broad159try:160 user = get_user(999)161except NotFoundError as e:162 print(e.to_dict())163 # {'error': 'NOT_FOUND', 'message': "User with id '999' not found", ...}164except AppError as e:165 # Catches ANY app-level error166 print(f"App error [{e.code}]: {e}")167168169# ============================================================170# Exception chaining: raise ... from ...171# ============================================================172class DatabaseError(AppError):173 def __init__(self, message, original=None):174 super().__init__(message, code="DB_ERROR")175 self.original = original176177def fetch_from_db(query: str):178 """Wrap low-level DB errors in application-level exceptions."""179 try:180 # Simulate a database operation that fails181 raise ConnectionError("Connection refused: localhost:5432")182 except ConnectionError as e:183 # Explicit chaining — preserves the cause in tracebacks184 raise DatabaseError(185 f"Failed to execute query: {query}"186 ) from e # e is stored as __cause__187188# raise ... from None — suppresses the chain189def parse_user_input(raw: str) -> int:190 try:191 return int(raw)192 except ValueError:193 # Hide the internal ValueError from the user194 raise ValidationError(195 "age", "Must be a whole number", raw196 ) from None # __cause__ = None, __suppress_context__ = True197198199# ============================================================200# ExceptionGroup (Python 3.11+) — multiple simultaneous errors201# ============================================================202def validate_form(data: dict) -> None:203 """Collect ALL validation errors, not just the first one."""204 errors = []205206 if not data.get("name"):207 errors.append(ValidationError("name", "Required"))208 if not data.get("email") or "@" not in data.get("email", ""):209 errors.append(ValidationError("email", "Invalid email format"))210 if not isinstance(data.get("age"), int) or data["age"] < 0:211 errors.append(ValidationError("age", "Must be non-negative integer"))212213 if errors:214 raise ExceptionGroup("Form validation failed", errors)215216# Catching with except* (Python 3.11+)217try:218 validate_form({"name": "", "email": "bad", "age": -5})219except* ValidationError as eg:220 # eg is an ExceptionGroup containing only ValidationError instances221 for exc in eg.exceptions:222 print(f" - {exc.field}: {exc}")223 # Unmatched exceptions propagate automatically
🏋️ Practice Exercise
Exercises:
Write a function
safe_divide(a, b)that handlesZeroDivisionErrorandTypeError, useselsefor logging success, andfinallyfor cleanup. ReturnNoneon failure.Create a custom exception hierarchy for an e-commerce system:
ShopError(base) →PaymentError,InventoryError,ShippingError. Each should carry structured data (order_id, item_sku, etc.) and have ato_dict()method for API responses.Write a
validate_user_registration(data)function that collects ALL validation errors (name, email, password strength, age) and raises them as anExceptionGroup(Python 3.11+). Then catch and display each error usingexcept*.Implement a retry decorator
@retry(max_attempts=3, backoff=1.0, exceptions=(ConnectionError,))that catches specified exceptions, waits with exponential backoff, and raises the last exception after all attempts fail. Useraise ... fromto chain the original error.Demonstrate the difference between
raise X from Y(explicit chaining), implicit chaining (exception during except block), andraise X from None(suppressed chaining) with three separate examples.
⚠️ Common Mistakes
Using bare
except:orexcept Exception:without re-raising. This swallowsKeyboardInterruptandSystemExit, making your program impossible to kill gracefully. Always catch specific exceptions or re-raise withraise.Putting too much code in the
tryblock. Only wrap the specific line(s) that can raise — everything else goes inelse(success path) or after the try/except. Large try blocks hide which operation actually failed.Catching an exception just to log and re-raise without
raise(no arguments). Writingexcept ValueError as e: log(e); raise ValueError(str(e))creates a new exception and loses the original traceback. Use bareraiseto re-raise the original.Ignoring exception chaining — not using
raise NewError(...) from original_error. Withoutfrom, Python still sets__context__implicitly, butfrommakes the relationship explicit and clearer in tracebacks.Creating flat custom exceptions instead of a hierarchy. Without a base
AppError, callers cannot catch broad categories of errors. Always define a module-level base exception that all custom exceptions inherit from.
💼 Interview Questions
🎤 Mock Interview
Practice a live interview for Exceptions: try/except/else/finally & Custom Exceptions