Follow AiTechWorlds on LinkedIn for professional AI content!Follow Now →
16 minLesson 16 of 34
Advanced Python

Error Handling & Custom Exceptions

Error Handling & Custom Exceptions: Writing Resilient Python Code

Good error handling is what separates scripts from production software. When something goes wrong — network failure, invalid input, missing file — your code should fail gracefully with clear messages, not with cryptic tracebacks.

The Exception Hierarchy

BaseException
├── SystemExit          — sys.exit() was called
├── KeyboardInterrupt   — Ctrl+C
├── GeneratorExit       — generator/coroutine closed
└── Exception           ← Most errors you'll catch
    ├── ValueError      — wrong value type/format
    ├── TypeError       — wrong type
    ├── KeyError        — dict key not found
    ├── IndexError      — sequence index out of range
    ├── AttributeError  — object has no attribute
    ├── NameError       — name not defined
    ├── OSError         — system errors (FileNotFoundError, PermissionError)
    ├── RuntimeError    — misc runtime errors
    ├── StopIteration   — iterator exhausted
    └── ArithmeticError
        ├── ZeroDivisionError
        └── OverflowError

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"Wrong types: {e}")
        return None
    else:
        # Runs only if NO exception occurred
        print(f"Success: {result}")
        return result
    finally:
        # ALWAYS runs — even if exception was raised
        print("divide() finished")

divide(10, 2)    # Success: 5.0 / divide() finished
divide(10, 0)    # Cannot divide by zero / divide() finished
divide(10, "x")  # Wrong types / divide() finished

Catching Multiple Exceptions

def parse_config(data):
    try:
        config = json.loads(data)          # JSONDecodeError if invalid JSON
        port = int(config['port'])         # KeyError if 'port' missing; ValueError if not int
        assert 1 <= port <= 65535          # AssertionError if out of range
        return config
    
    except json.JSONDecodeError as e:
        raise ValueError(f"Invalid JSON: {e}") from e
    
    except KeyError as e:
        raise ValueError(f"Missing required key: {e}") from e
    
    except (ValueError, AssertionError) as e:
        raise ValueError(f"Invalid config: {e}") from e

# Catch any exception (usually too broad — use sparingly)
try:
    risky_operation()
except Exception as e:
    print(f"Something went wrong: {type(e).__name__}: {e}")
    raise  # Re-raise the same exception after logging

Re-raising and Chaining Exceptions

import logging
logger = logging.getLogger(__name__)

def process_payment(amount, card_number):
    try:
        return charge_card(card_number, amount)
    except ConnectionError as e:
        logger.error(f"Payment gateway unreachable: {e}")
        # Raise a business-level exception that hides implementation details
        raise PaymentError("Payment service temporarily unavailable") from e
    except AuthError as e:
        raise PaymentError("Card declined — please try another card") from e

# 'from e' sets __cause__ — Python shows both exceptions in traceback
# Use 'from None' to suppress the original exception in the traceback
try:
    result = some_function()
except SpecificError:
    raise PublicError("Something went wrong") from None  # Hides implementation detail

Custom Exceptions: Building a Clear Error Hierarchy

# Define a hierarchy of custom exceptions
class AppError(Exception):
    """Base class for all application errors."""
    
    def __init__(self, message, code=None, details=None):
        super().__init__(message)
        self.message = message
        self.code = code
        self.details = details or {}
    
    def __str__(self):
        parts = [self.message]
        if self.code:
            parts.append(f"(code: {self.code})")
        return " ".join(parts)
    
    def to_dict(self):
        return {
            "error": type(self).__name__,
            "message": self.message,
            "code": self.code,
            "details": self.details
        }

class ValidationError(AppError):
    """Input validation failed."""
    pass

class AuthenticationError(AppError):
    """Authentication failed."""
    pass

class AuthorizationError(AppError):
    """Insufficient permissions."""
    pass

class NotFoundError(AppError):
    """Resource not found."""
    pass

class ExternalServiceError(AppError):
    """External API or service failed."""
    pass

# Usage
def get_user(user_id):
    if not isinstance(user_id, int) or user_id <= 0:
        raise ValidationError(
            f"Invalid user ID: {user_id}",
            code="INVALID_ID",
            details={"user_id": user_id, "expected": "positive integer"}
        )
    
    user = database.find(user_id)
    if user is None:
        raise NotFoundError(
            f"User {user_id} not found",
            code="USER_NOT_FOUND",
            details={"user_id": user_id}
        )
    
    return user

# API error handler
def handle_request(request):
    try:
        result = process(request)
        return {"status": "success", "data": result}
    
    except ValidationError as e:
        return {"status": "error", "http_status": 400, **e.to_dict()}
    
    except AuthenticationError as e:
        return {"status": "error", "http_status": 401, **e.to_dict()}
    
    except NotFoundError as e:
        return {"status": "error", "http_status": 404, **e.to_dict()}
    
    except AppError as e:
        return {"status": "error", "http_status": 500, **e.to_dict()}

Context Managers for Cleanup

# Using contextlib.suppress
from contextlib import suppress

# Ignore specific exceptions
with suppress(FileNotFoundError, PermissionError):
    import os
    os.remove("temp_file.txt")

# Equivalent to:
try:
    os.remove("temp_file.txt")
except (FileNotFoundError, PermissionError):
    pass

Exception Best Practices

Don't Catch What You Can't Handle

# BAD: swallowing exceptions silently
try:
    result = complex_operation()
except Exception:
    pass  # Silent failure — bugs become mysteries

# BAD: catching too broadly
try:
    user = get_user(user_id)
except Exception as e:
    return "Error"  # Which error? What happened?

# GOOD: catch specific exceptions, handle them specifically
try:
    user = get_user(user_id)
except NotFoundError:
    return None  # User doesn't exist — caller handles this
except ValidationError as e:
    raise  # Programming error — let it propagate

# GOOD: log and re-raise if you can't handle it
try:
    result = risky_operation()
except SomeSpecificError as e:
    logger.error(f"Operation failed: {e}", exc_info=True)
    raise  # Don't swallow it

Use EAFP Over LBYL in Python

# LBYL (Look Before You Leap) — more common in other languages
if 'key' in my_dict:
    value = my_dict['key']
else:
    value = default

# EAFP (Easier to Ask Forgiveness than Permission) — more Pythonic
try:
    value = my_dict['key']
except KeyError:
    value = default

# Even better — use .get()
value = my_dict.get('key', default)

Logging vs Printing Errors

import logging

# Configure logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s %(name)s %(levelname)s %(message)s',
    handlers=[
        logging.FileHandler('app.log'),
        logging.StreamHandler()  # Also print to console
    ]
)

logger = logging.getLogger(__name__)

def process_order(order):
    try:
        validate_order(order)
        payment = charge_payment(order)
        logger.info(f"Order {order['id']} processed: ${order['amount']:.2f}")
        return payment
    
    except ValidationError as e:
        logger.warning(f"Invalid order {order.get('id')}: {e}")
        raise
    
    except PaymentError as e:
        logger.error(f"Payment failed for order {order.get('id')}: {e}")
        raise
    
    except Exception as e:
        logger.critical(f"Unexpected error processing order: {e}", exc_info=True)
        raise

# exc_info=True adds the full traceback to the log

The traceback Module

import traceback

def debug_error():
    try:
        risky()
    except Exception:
        # Get full traceback as a string
        error_text = traceback.format_exc()
        print(error_text)
        
        # Or get the traceback info structured
        tb_info = traceback.extract_tb(sys.exc_info()[2])
        for frame in tb_info:
            print(f"  File: {frame.filename}, Line: {frame.lineno}, "
                  f"Function: {frame.name}")

Well-structured error handling makes debugging fast, makes APIs clear about what went wrong, and prevents cryptic failures from reaching users.

Next lesson: Modules, Packages & pip — organizing code and using the Python ecosystem.

📱

Get this course's notes on Telegram!

Free cheat sheets, summaries & practice exercises

Get Notes Free →
!