Python Exceptions & Error Handling
In Python, defects in code fall into two distinct categories:
- Syntax Errors (Compile-Time): Occur during the parsing stage before the program begins executing. If your code contains a missing colon (
if x == 5) or mismatched parenthesis, Python immediately halts with aSyntaxErrororIndentationError. Syntax errors cannot be caught with try-except. - Exceptions (Runtime): Occur while a syntactically valid program is actively executing. For example, dividing by zero (
ZeroDivisionError), accessing a missing dictionary key (KeyError), or opening a non-existent file (FileNotFoundError).
The Python Exception Class Hierarchy: All built-in exceptions in Python form an inheritance tree rooted at BaseException:
Always inherit your custom exceptions from
Exception (not BaseException). Catching BaseException will unintentionally block user keyboard interrupts (Ctrl+C / KeyboardInterrupt) and graceful system shutdowns.
# Inspecting the Exception Class Hierarchy at runtime:
print("Is ZeroDivisionError an ArithmeticError?", issubclass(ZeroDivisionError, ArithmeticError))
print("Is KeyError a LookupError?", issubclass(KeyError, LookupError))
print("Is FileNotFoundError an OSError?", issubclass(FileNotFoundError, OSError))
print("Is ArithmeticError an Exception?", issubclass(ArithmeticError, Exception))
Because KeyError inherits from LookupError, catching except LookupError: will catch both KeyError and IndexError with a single handler!
A complete, professional error handling block in Python can utilize up to four distinct clauses:
try: Encloses the risky code that might raise an exception.except ExceptionType as err: Executes ONLY if a matching exception occurs inside the try block.else: Executes ONLY if the try block completed successfully with ZERO exceptions. (Keeps try blocks minimal!).finally: Executes unconditionally 100% of the time, regardless of whether an exception occurred, was caught, or if areturnstatement was encountered. Used for critical cleanup (closing database connections, releasing file locks).
def safe_divide_engine(a, b):
print(f"\n--- Attempting: {a} / {b} ---")
try:
# Step 1: Risky calculation
result = a / b
except ZeroDivisionError as err:
# Step 2: Handles zero division error
print(f"β Handled Error: Cannot divide by zero! ({err})")
return None
except TypeError as err:
# Step 2: Handles invalid data types
print(f"β Handled Error: Both operands must be numbers! ({err})")
return None
else:
# Step 3: Runs ONLY if division was successful
print(f"β
Calculation Successful! Result = {result}")
return result
finally:
# Step 4: Runs ALWAYS for resource cleanup / logging
print("π [Cleanup] safe_divide_engine execution cycle finished.")
# Test all branches:
safe_divide_engine(100, 4) # Success (try -> else -> finally)
safe_divide_engine(50, 0) # Zero Division (try -> except -> finally)
safe_divide_engine(10, "2") # Type Error (try -> except -> finally)
- In
safe_divide_engine(100, 4):trysucceeds,elseprints the result, andfinallyexecutes cleanup. - In
safe_divide_engine(50, 0):ZeroDivisionErrortriggers,excepthandles it,elseis skipped, andfinallyexecutes before returningNone.
Never use a bare except: or generic except Exception: unless logging at the top-level boundary. Catching specific exceptions prevents masking unrelated programming bugs.
You can define multiple dedicated except blocks, or group related exceptions into a single tuple except (ValueError, TypeError) as err::
def process_user_record(data_dict, key, divisor):
try:
raw_val = data_dict[key] # May raise KeyError
parsed_num = float(raw_val) # May raise ValueError
computed = 1000 / parsed_num # May raise ZeroDivisionError
return computed
except KeyError:
print(f"β Error: Required key '{key}' is missing from user record!")
except (ValueError, TypeError) as err:
print(f"β Data Parsing Error: Invalid numeric input ({err})")
except ZeroDivisionError:
print("β Math Error: Value cannot be zero!")
# Testing different failure scenarios:
user_data = {"score": "50", "bonus": "zero", "zero_val": "0"}
print("1. Valid score: ", process_user_record(user_data, "score", 2))
print("2. Missing key: ", process_user_record(user_data, "missing_key", 2))
print("3. Bad number string:", process_user_record(user_data, "bonus", 2))
print("4. Division by zero:", process_user_record(user_data, "zero_val", 2))
Specific exception blocks allow you to provide targeted recovery actions (e.g. asking for missing keys vs asking for re-entered numbers) rather than failing generically.
Use the raise statement to throw an exception when business rule invariants are violated. Python 3 also supports Exception Chaining via raise NewException from original_error, preserving the original root cause traceback for debugging:
def validate_account_age(age):
if not isinstance(age, int):
raise TypeError(f"Age must be an integer, received {type(age).__name__}")
if age < 0:
raise ValueError(f"Age cannot be negative! Received: {age}")
if age < 18:
raise PermissionError(f"User is {age} years old. Minimum required age is 18.")
return "β
Age verified successfully!"
# Test exception raising:
try:
print(validate_account_age(22)) # Valid
validate_account_age(-5) # Triggers ValueError
except (TypeError, ValueError, PermissionError) as err:
print(f"π« Validation Failed: {err}")
Raising exceptions immediately when invalid inputs arrive prevents corrupted state from propagating deeper into your application and database.
In large production applications (FastAPI backends, payment gateways, microservices), you should define custom domain-specific exception classes by inheriting from Python's standard Exception class:
# Define Custom Application Exceptions
class BankingAppError(Exception):
"""Base exception for all banking domain errors."""
pass
class InsufficientFundsError(BankingAppError):
"""Raised when a withdrawal exceeds available balance."""
def __init__(self, balance, amount_requested):
self.balance = balance
self.amount_requested = amount_requested
self.shortage = amount_requested - balance
super().__init__(
f"Withdrawal of Rs.{amount_requested:.2f} failed! "
f"Current Balance: Rs.{balance:.2f} (Short by Rs.{self.shortage:.2f})"
)
class AccountLockedError(BankingAppError):
"""Raised when operating on a frozen account."""
pass
def withdraw_money(account_balance, amount, is_locked=False):
if is_locked:
raise AccountLockedError("Account is temporarily locked. Please contact support.")
if amount > account_balance:
raise InsufficientFundsError(account_balance, amount)
return account_balance - amount
# Test Custom Exception handling:
try:
new_bal = withdraw_money(500.0, 750.0)
except InsufficientFundsError as err:
print("π³ Transaction Blocked:", err)
print(f"π Customer needs to deposit at least Rs.{err.shortage:.2f} more.")
except BankingAppError as err:
print("π¦ General Banking Error:", err)
Custom exceptions carry rich structured metadata (like err.shortage) allowing API layers to return structured HTTP 400/403 JSON responses to frontend clients automatically.
Using a bare "except:" or catching Exception and doing "pass" silently suppresses all errors including typos (NameError), keyboard interrupts, and out-of-memory errors, making code completely impossible to debug. Always catch specific exceptions and log them!
Write a function parse_integer_input(prompt_text) that repeatedly prompts the user in a while loop until they provide a valid integer, handling ValueError gracefully.
def safe_int_converter(raw_value):
try:
return int(raw_value), "Success"
except ValueError:
return None, f"'{raw_value}' is not a valid integer!"
# Test inputs:
for test_val in ["42", "hello", "100.5", "-99"]:
val, status = safe_int_converter(test_val)
print(f"Input: {test_val:8} -> Parsed: {val} ({status})")
Q What is the difference between else and finally in try-except?
The else block runs ONLY if the try block succeeds with zero exceptions. The finally block runs unconditionally 100% of the time, even if an unhandled exception occurred or a return statement was executed.
Q Why should custom exceptions inherit from Exception and not BaseException?
BaseException is the root of system-level exits like KeyboardInterrupt and SystemExit. Inheriting from Exception ensures your errors represent application issues without interfering with process termination.
Q How do I re-raise the currently active exception?
Inside an except block, simply write "raise" with no arguments. Python will re-raise the active exception up the call stack with its full original traceback intact.