Exception Handling

Difficulty: Beginner

Question

Explain Python exception handling. How do you create custom exceptions?

Answer

Python uses try/except/else/finally for exception handling: - try: code that might raise an exception - except: handle specific exceptions - else: runs only if no exception occurred - finally: always runs (cleanup code)

Exception hierarchy: BaseException -> Exception -> specific exceptions.

Best practices: - Catch specific exceptions, not bare except - Use else clause for code that should only run on success - Use finally for cleanup (or better: use context managers) - Create custom exceptions by inheriting from Exception

Code examples

try/except/else/finally

def divide(a, b):
    try:
        result = a / b
    except ZeroDivisionError:
        print("Cannot divide by zero")
        return None
    except TypeError as e:
        print(f"Type error: {e}")
        return None
    else:
        # Only runs if NO exception occurred
        print(f"Division successful: {result}")
        return result
    finally:
        # ALWAYS runs, even after return
        print("Division operation complete")

print(divide(10, 3))
print("---")
print(divide(10, 0))
print("---")
print(divide("10", 3))

The else block runs only on success - don't put success code in try (it might catch unrelated errors). finally always runs, even after return or exception.

Custom Exceptions

# Custom exception hierarchy
class AppError(Exception):
    """Base exception for our application."""
    pass

class ValidationError(AppError):
    def __init__(self, field, message):
        self.field = field
        self.message = message
        super().__init__(f"{field}: {message}")

class NotFoundError(AppError):
    def __init__(self, resource, resource_id):
        self.resource = resource
        self.resource_id = resource_id
        super().__init__(f"{resource} with id {resource_id} not found")

class AuthenticationError(AppError):
    pass

# Usage
def get_user(user_id):
    if not isinstance(user_id, int):
        raise ValidationError('user_id', 'Must be an integer')
    if user_id < 0:
        raise ValidationError('user_id', 'Must be positive')
    if user_id > 1000:
        raise NotFoundError('User', user_id)
    return {'id': user_id, 'name': 'Alice'}

try:
    user = get_user(9999)
except ValidationError as e:
    print(f"Validation: {e.field} - {e.message}")
except NotFoundError as e:
    print(f"Not found: {e.resource} #{e.resource_id}")
except AppError as e:
    print(f"App error: {e}")

Custom exceptions should form a hierarchy. Catch specific exceptions first, then broader ones. Include relevant context as attributes.

Exception Chaining and Best Practices

# Exception chaining with 'from'
def parse_config(text):
    try:
        key, value = text.split('=')
        return {key.strip(): int(value.strip())}
    except ValueError as e:
        raise ValueError(f"Invalid config line: '{text}'") from e

try:
    parse_config("invalid line")
except ValueError as e:
    print(f"Error: {e}")
    print(f"Caused by: {e.__cause__}")

# Anti-patterns to avoid
# BAD: bare except catches everything including SystemExit, KeyboardInterrupt
# try:
#     ...
# except:  # NEVER do this
#     pass

# BAD: catching too broad
# try:
#     ...
# except Exception:  # catches everything - usually too broad
#     pass

# GOOD: specific exceptions
try:
    value = int("not a number")
except ValueError:
    value = 0  # sensible default

# GOOD: re-raise with context
try:
    data = process(raw_data)
except ProcessingError:
    logger.exception("Failed to process data")  # logs traceback
    raise  # re-raises the same exception

'raise ... from e' chains exceptions, preserving the original cause. 'raise' alone re-raises the current exception. Never use bare except or catch-all-and-silence.

Key points

Concepts covered

try/except, finally, raise, Custom Exceptions, Exception Hierarchy