Exceptions: try/except/else/finally & Custom Exceptions

0/4 in this phase0/54 across the roadmap

📖 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: or except Exception: unless you re-raise. Bare excepts swallow KeyboardInterrupt and SystemExit.
  • EAFP over LBYL — "Easier to Ask Forgiveness than Permission" is Pythonic. Use try/except rather than checking conditions first (if key in dict vs try: dict[key]).
  • Exception chaining — Python 3 automatically chains exceptions (__cause__ via raise X from Y, __context__ for implicit chaining). This preserves the full error trail.
  • ExceptionGroup (3.11+) — allows raising and catching multiple unrelated exceptions simultaneously using except* 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

codeTap to expand ⛶
1# ============================================================
2# Full try/except/else/finally control flow
3# ============================================================
4def divide(a, b):
5 """Demonstrate every clause of try/except/else/finally."""
6 try:
7 result = a / b
8 except ZeroDivisionError:
9 print("Cannot divide by zero")
10 return None
11 except TypeError as e:
12 print(f"Type error: {e}")
13 return None
14 else:
15 # Runs ONLY when no exception was raised
16 # Put success-path logic here, NOT in the try block
17 print(f"Division successful: {a} / {b} = {result}")
18 return result
19 finally:
20 # ALWAYS runs — even if return/break/continue was hit above
21 # Use for cleanup: closing files, releasing locks, etc.
22 print("Division operation completed (finally block)")
23
24divide(10, 3) # Success path → elsefinally
25divide(10, 0) # ZeroDivisionError → except → finally
26divide("a", 2) # TypeError → except → finally
27
28
29# ============================================================
30# BAD: Common anti-patterns
31# ============================================================
32# BAD — bare except swallows KeyboardInterrupt, SystemExit
33# try:
34# do_something()
35# except: # NEVER do this
36# pass
37
38# BAD — too broad, hides bugs
39# 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 know
44
45# BAD — using exceptions for normal control flow
46# try:
47# value = my_list[999]
48# except IndexError:
49# value = "default" # Use: value = my_list[999] if len(my_list) > 999 else "default"
50
51
52# ============================================================
53# GOOD: Specific, informative exception handling
54# ============================================================
55import json
56from pathlib import Path
57
58def load_config(filepath: str) -> dict:
59 """Load and validate a JSON configuration file."""
60 path = Path(filepath)
61
62 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 )
74
75 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 error
82
83 # Validate required keys
84 required = {"database_url", "secret_key", "debug"}
85 missing = required - config.keys()
86 if missing:
87 raise KeyError(f"Missing required config keys: {missing}")
88
89 return config
90
91
92# ============================================================
93# Custom exception hierarchy — production pattern
94# ============================================================
95class AppError(Exception):
96 """Base exception for the entire application.
97 All custom exceptions inherit from this, so callers can
98 catch AppError to handle any app-specific error."""
99
100 def __init__(self, message: str, code: str = "UNKNOWN", details: dict = None):
101 super().__init__(message)
102 self.code = code
103 self.details = details or {}
104
105 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 }
112
113
114class ValidationError(AppError):
115 """Invalid input data."""
116
117 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 = field
124
125
126class NotFoundError(AppError):
127 """Resource not found."""
128
129 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 )
135
136
137class AuthenticationError(AppError):
138 """Authentication failure."""
139
140 def __init__(self, reason: str = "Invalid credentials"):
141 super().__init__(message=reason, code="AUTH_ERROR")
142
143
144# Usage in an API handler
145def 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)
149
150 users_db = {1: {"name": "Alice"}, 2: {"name": "Bob"}}
151
152 if user_id not in users_db:
153 raise NotFoundError("User", user_id)
154
155 return users_db[user_id]
156
157
158# Caller can catch specific or broad
159try:
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 error
166 print(f"App error [{e.code}]: {e}")
167
168
169# ============================================================
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 = original
176
177def fetch_from_db(query: str):
178 """Wrap low-level DB errors in application-level exceptions."""
179 try:
180 # Simulate a database operation that fails
181 raise ConnectionError("Connection refused: localhost:5432")
182 except ConnectionError as e:
183 # Explicit chaining — preserves the cause in tracebacks
184 raise DatabaseError(
185 f"Failed to execute query: {query}"
186 ) from e # e is stored as __cause__
187
188# raise ... from None — suppresses the chain
189def parse_user_input(raw: str) -> int:
190 try:
191 return int(raw)
192 except ValueError:
193 # Hide the internal ValueError from the user
194 raise ValidationError(
195 "age", "Must be a whole number", raw
196 ) from None # __cause__ = None, __suppress_context__ = True
197
198
199# ============================================================
200# ExceptionGroup (Python 3.11+) — multiple simultaneous errors
201# ============================================================
202def validate_form(data: dict) -> None:
203 """Collect ALL validation errors, not just the first one."""
204 errors = []
205
206 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"))
212
213 if errors:
214 raise ExceptionGroup("Form validation failed", errors)
215
216# 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 instances
221 for exc in eg.exceptions:
222 print(f" - {exc.field}: {exc}")
223 # Unmatched exceptions propagate automatically

🏋️ Practice Exercise

Exercises:

  1. Write a function safe_divide(a, b) that handles ZeroDivisionError and TypeError, uses else for logging success, and finally for cleanup. Return None on failure.

  2. 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 a to_dict() method for API responses.

  3. Write a validate_user_registration(data) function that collects ALL validation errors (name, email, password strength, age) and raises them as an ExceptionGroup (Python 3.11+). Then catch and display each error using except*.

  4. 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. Use raise ... from to chain the original error.

  5. Demonstrate the difference between raise X from Y (explicit chaining), implicit chaining (exception during except block), and raise X from None (suppressed chaining) with three separate examples.

⚠️ Common Mistakes

  • Using bare except: or except Exception: without re-raising. This swallows KeyboardInterrupt and SystemExit, making your program impossible to kill gracefully. Always catch specific exceptions or re-raise with raise.

  • Putting too much code in the try block. Only wrap the specific line(s) that can raise — everything else goes in else (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). Writing except ValueError as e: log(e); raise ValueError(str(e)) creates a new exception and loses the original traceback. Use bare raise to re-raise the original.

  • Ignoring exception chaining — not using raise NewError(...) from original_error. Without from, Python still sets __context__ implicitly, but from makes 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