7. Chapter 7: Python Exception Handling

7.1. Python Exceptions

7.1.1. 1. What is an Exception

An exception is an error that occurs during program execution and disrupts normal program flow:

print(10 / 0)  # Raises ZeroDivisionError

Without handling, the program terminates immediately.

7.1.2. 2. Basic try…except Block

The try block contains risky code; except handles the error gracefully:

try:
    result = 10 / 0
except ZeroDivisionError:
    print("Cannot divide by zero")

7.1.3. 3. Handling Multiple Exceptions

Different exception types can be handled separately:

try:
    value = int("abc")
except ValueError:
    print("Invalid conversion")
except ZeroDivisionError:
    print("Division error")

7.1.4. 4. Using else with try…except

The else block executes only if no exception is raised:

try:
    num = int("10")
except ValueError:
    print("Conversion failed")
else:
    print("Conversion successful:", num)

7.1.5. 5. Using finally Block

finally always executes, whether an exception occurs or not:

try:
    file = open("data.txt", "r")
except FileNotFoundError:
    print("File not found")
finally:
    print("Execution completed")

7.1.6. 6. Catching All Exceptions (Generic Exception)

Catches any exception; useful for logging but should be used cautiously:

try:
    risky_operation()
except Exception as e:
    print("Error occurred:", e)

7.1.7. 7. Raising Custom Exceptions

The raise keyword triggers an exception manually:

def validate_age(age):
    if age < 0:
        raise ValueError("Age cannot be negative")

validate_age(-5)

7.1.8. 8. Creating Custom Exception Classes

Custom exceptions improve clarity in domain-specific logic:

class NegativeNumberError(Exception):
    pass

def check_number(n):
    if n < 0:
        raise NegativeNumberError("Negative numbers are not allowed")

check_number(-3)

7.1.9. 9. Using Assertions (assert)

Assertions are used for debugging and validation during development:

age = 15
assert age >= 18, "User must be at least 18 years old"

7.1.10. 10. Best Practice: Structured Exception Handling

Encapsulating exception logic improves reliability and user experience:

def divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        return "Division by zero error"
    except TypeError:
        return "Invalid input type"

print(divide(10, 0))

7.2. Python Exception Handling

7.2.1. 1. Purpose of Exception Handling

Exception handling prevents abrupt program termination and allows controlled recovery from runtime errors:

try:
    result = 10 / 0
except ZeroDivisionError:
    print("Handled division by zero safely")

Ensures stable application behavior.

7.2.2. 2. Basic try…except Structure

Separates risky code from error-handling logic:

try:
    number = int("abc")
except ValueError:
    print("Invalid number format")

7.2.3. 3. Handling Multiple Exceptions

Different exception types can be managed independently:

try:
    value = int("10")
    result = value / 0
except ValueError:
    print("Conversion error")
except ZeroDivisionError:
    print("Division error")

7.2.4. 4. Using try…except…else

else executes only if no exception occurs:

try:
    num = int("50")
except ValueError:
    print("Conversion failed")
else:
    print("Conversion successful:", num)

7.2.5. 5. Using try…except…finally

finally always runs, making it ideal for resource cleanup:

try:
    file = open("data.txt", "r")
except FileNotFoundError:
    print("File not found")
finally:
    print("Cleanup operations executed")

7.2.6. 6. Nested Exception Handling

Allows granular control over layered risky operations:

try:
    try:
        x = int("abc")
    except ValueError:
        print("Inner exception handled")
except Exception:
    print("Outer exception handler")

7.2.7. 7. Catching Generic Exceptions

Catches all exceptions; useful for logging but should be used carefully:

try:
    risky_operation()
except Exception as e:
    print("Error:", e)

7.2.8. 8. Re-raising Exceptions

Preserves the original traceback while adding contextual handling:

try:
    value = int("xyz")
except ValueError:
    print("Logging error before re-raising")
    raise

7.2.9. 9. Custom Exception Handling Strategy

Allows domain-specific error control:

class InvalidAgeError(Exception):
    pass

def validate_age(age):
    if age < 18:
        raise InvalidAgeError("Age must be 18 or above")

try:
    validate_age(16)
except InvalidAgeError as e:
    print(e)

7.2.10. 10. Production-Grade Exception Handling Pattern

Combines graceful handling, error reporting, and guaranteed execution logic:

def safe_divide(a, b):
    try:
        return a / b
    except ZeroDivisionError as e:
        return f"Error: {e}"
    except Exception as e:
        return f"Unexpected error: {e}"
    finally:
        print("Operation attempted")

print(safe_divide(10, 0))

7.3. Python Custom Exceptions

7.3.1. 1. Why Use Custom Exceptions

Custom exceptions improve clarity, maintainability, and semantic accuracy by representing domain-specific error conditions:

class InvalidInputError(Exception):
    pass

Defines a new exception type tailored to your application logic.

7.3.2. 2. Basic Custom Exception Example

Raises a user-defined exception with a meaningful message:

class NegativeValueError(Exception):
    pass

def process_value(value):
    if value < 0:
        raise NegativeValueError("Negative values are not allowed")

process_value(-10)

7.3.3. 3. Extending Exception with Custom Message

Adds dynamic context to error messages:

class AgeLimitError(Exception):
    def __init__(self, age):
        super().__init__(f"Age {age} is below allowed limit")

def validate_age(age):
    if age < 18:
        raise AgeLimitError(age)

validate_age(15)

7.3.4. 4. Catching Custom Exceptions

Handled like any built-in exception:

class AuthenticationError(Exception):
    pass

try:
    raise AuthenticationError("Invalid credentials")
except AuthenticationError as e:
    print("Login failed:", e)

7.3.5. 5. Creating Hierarchy of Custom Exceptions

Facilitates organized error taxonomy and layered handling:

class ApplicationError(Exception):
    pass

class DatabaseError(ApplicationError):
    pass

class NetworkError(ApplicationError):
    pass

7.3.6. 6. Custom Exception with Additional Attributes

Carries structured metadata for better diagnostics:

class TransactionError(Exception):
    def __init__(self, code, message):
        self.code = code
        self.message = message
        super().__init__(f"[{code}] {message}")

raise TransactionError(403, "Unauthorized action")

7.3.7. 7. Using Custom Exceptions in Functions

Enforces logical validation constraints:

class FileMissingError(Exception):
    pass

def load_config(filename):
    if not filename.endswith(".json"):
        raise FileMissingError("Only JSON configuration files supported")

load_config("config.txt")

7.3.8. 8. Custom Exception with Logging

Useful for application-level logging and audit trails:

class DataValidationError(Exception):
    pass

try:
    raise DataValidationError("Invalid CSV column format")
except DataValidationError as e:
    print("Validation Error Logged:", e)

7.3.9. 9. Chaining Custom Exceptions

Preserves original error context using exception chaining:

class InitialError(Exception):
    pass

class DerivedError(Exception):
    pass

try:
    try:
        raise InitialError("Initial failure")
    except InitialError as e:
        raise DerivedError("Follow-up failure") from e
except DerivedError as final_error:
    print(final_error)

7.3.10. 10. Best Practice: Naming and Design Pattern

Follow naming conventions:

  • Use meaningful names

  • Inherit from Exception

  • Include descriptive docstrings

class InvalidOrderStateError(Exception):
    """Raised when an order is in an invalid processing state"""
    pass

def process_order(status):
    if status != "confirmed":
        raise InvalidOrderStateError("Order must be confirmed before processing")

process_order("draft")