Python • Error & Exception Mastery

Exception Handling from Basics to Production

Understand every built‑in exception, try/except/else/finally, raise, custom exceptions, assertions, and production‑grade patterns — with solved and unsolved practice problems.

📌 What you'll learn: Exception handling is not just about try and except. It’s a mindset that separates fragile scripts from production‑ready software. This page covers everything: syntax errors vs exceptions, the full hierarchy, best practices, and how to design your own error strategy.

🎯 Goal: Write Python code that fails gracefully, logs meaningfully, and recovers intelligently — whether you’re a beginner or an experienced developer moving to production systems.

Why Exception Handling? The Big Picture

What is Exception Handling?

Exception handling is a programming construct that allows you to catch and respond to runtime errors (exceptions) without crashing the program. It separates the normal flow of code from the error‑recovery logic, making programs more robust and maintainable.

Why is it required?

Without exception handling, any unexpected situation – a missing file, a network timeout, invalid user input – would immediately terminate the program. In production, that means lost data, unhappy users, and system downtime. Exception handling gives you control over these scenarios.

Why is it important?
  • User experience: Show friendly messages instead of raw tracebacks.
  • Data integrity: Roll back transactions or save progress before exiting.
  • Debugging: Log detailed error information for post‑mortem analysis.
  • Resilience: Retry operations, fall back to defaults, or degrade gracefully.
What problem does it solve?

It solves the fragility problem: a single unhandled error should not bring down an entire system. It turns unpredictable failures into manageable events, allowing software to self‑heal or at least shut down cleanly.

Role in Production Environments

In production, exception handling is the backbone of observability and reliability. It enables:

  • Centralized error logging and alerting (Sentry, ELK, CloudWatch).
  • Automatic retry with backoff (protects against transient failures).
  • Circuit breakers that prevent cascading failures.
  • Graceful shutdowns that finish in‑flight tasks before termination.
💡 Production example: A web server catches all exceptions at the top level, returns a 500 response, logs the traceback, and keeps running for the next request. Without this, one bug could crash the entire server.
01

Errors vs Exceptions

syntaxruntime
02

Built‑in Exceptions

hierarchyexamples
03

try/except

catchingmultiple
04

else / finally

cleanupsuccess
05

raise & Custom

raiseinheritance
06

Assertions

assertdebug
07

Production Patterns

loggingretry

1 Errors vs Exceptions – The Foundation

Python distinguishes between syntax errors (parsing problems, your code never runs) and exceptions (runtime errors that occur during execution and can be caught).

syntax_vs_runtime.py
# Syntax error – code won't execute
if True
    print("Missing colon")

# Runtime exception – ZeroDivisionError
print(10 / 0)
🔍 Why it matters: Syntax errors must be fixed before the program runs. Runtime exceptions must be anticipated and handled, because they often depend on external factors (user input, file availability, network).

2 Built‑in Exception Hierarchy & Examples

All built‑in exceptions inherit from BaseException. The main branch for everyday errors is Exception. Here are the most common ones every developer must know.

Common Built‑in Exceptions Hierarchy
ExceptionCauseExample Trigger
TypeErrorWrong typelen(42)
ValueErrorRight type, inappropriate valueint("hello")
IndexErrorList index out of rangelst[10]
KeyErrorMissing dict keyd["missing"]
ZeroDivisionErrorDivision by zero10/0
FileNotFoundErrorFile doesn't existopen("nofile.txt")
ImportErrorModule not foundimport nonexistent
AttributeErrorObject has no such attributeNone.upper()
💡 Pro tip: Catching Exception is broad; prefer specific exceptions. Use except (TypeError, ValueError) as e: for multiple types.

3 try / except – Catching & Handling

The core mechanism. You can catch one, many, or all exceptions. Always capture the exception object for logging.

try_except_examples.py
# Basic try/except
try:
    result = 10 / int(input("Enter a number: "))
except ZeroDivisionError:
    print("Cannot divide by zero!")
except ValueError:
    print("That's not a valid number!")
except Exception as e:
    print(f"Unexpected error: {e}")

# Multiple exceptions in one line
try:
    data = {"a":1}
    print(data["b"])
except (KeyError, IndexError) as e:
    print(f"Lookup failed: {e}")
🔍 Why it's crucial: Unhandled exceptions crash your program. A well‑placed try/except allows your application to continue running, maybe retry or show a friendly message.

4 else & finally – Success Path & Cleanup

else runs only if no exception occurred. finally runs no matter what — perfect for releasing resources.

else_finally.py
def read_config(filename):
    file = None
    try:
        file = open(filename, 'r')
        data = file.read()
    except FileNotFoundError:
        print(f"Config file {filename} not found, using defaults")
        data = {}
    else:
        print("Config loaded successfully")
    finally:
        if file:
            file.close()
        print("Cleanup done")
    return data
💡 Best practice: Use else for code that should only run on success (keeps the try block minimal). Use finally for closing files, releasing locks, or restoring state — it's guaranteed.

5 raise & Custom Exceptions

You can raise exceptions yourself to signal errors, and define your own exception classes for better readability and control.

custom_exceptions.py
class InsufficientFundsError(Exception):
    """Custom exception for banking domain."""
    def __init__(self, balance, amount):
        super().__init__(f"Balance {balance} is insufficient for withdrawal of {amount}")
        self.balance = balance
        self.amount = amount

def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFundsError(balance, amount)
    return balance - amount

# Usage
try:
    new_balance = withdraw(100, 200)
except InsufficientFundsError as e:
    print(e)  # Balance 100 is insufficient for withdrawal of 200
🔍 Why custom exceptions? They make error handling domain‑specific and self‑documenting. Instead of a generic ValueError, InsufficientFundsError tells you exactly what went wrong.

6 Assertions – Debugging Aid

assert condition, message checks invariants. If the condition is false, it raises AssertionError. Disable assertions in production with -O flag.

assertions.py
def apply_discount(price, discount):
    assert 0 <= discount <= 1, "Discount must be between 0 and 1"
    return price * (1 - discount)

# This will fail during development
# apply_discount(100, 1.2)  # AssertionError
⚠️ Warning: Never use assertions for data validation in production (they can be disabled). Use explicit if + raise ValueError for user‑facing checks.

7 Production‑Grade Exception Handling

In production, you need logging, retries, graceful degradation, and monitoring. Here are essential patterns.

production_patterns.py
import logging
import time
from functools import wraps

logging.basicConfig(level=logging.ERROR)

def retry(max_attempts=3, delay=1, backoff=2):
    """Decorator to retry a function on exception."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            attempt = 0
            while attempt < max_attempts:
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    attempt += 1
                    logging.error(f"Attempt {attempt} failed: {e}")
                    if attempt == max_attempts:
                        raise
                    time.sleep(delay * (backoff ** (attempt-1)))
        return wrapper
    return decorator

@retry(max_attempts=3, delay=1)
def unstable_network_call():
    # simulate transient failure
    raise ConnectionError("Network timeout")
    
💡 Production essentials: Always log full tracebacks; never silence exceptions without logging. Use context managers (with) for resource safety. Implement circuit breakers for external calls.

Solved Practice Problems

Work through these solved examples to solidify your understanding. Each solution includes a step‑by‑step explanation.

🔰 Easy (3 problems)

1. Safe Division Function
solution.py
def safe_divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        print("Error: Cannot divide by zero.")
        return None

# Test
print(safe_divide(10, 2))   # 5.0
print(safe_divide(10, 0))   # Error: Cannot divide by zero. → None
2. List Element Access with Fallback
solution.py
def get_element(lst, index):
    try:
        return lst[index]
    except IndexError:
        print(f"Index {index} is out of range. Returning None.")
        return None

numbers = [10, 20, 30]
print(get_element(numbers, 1))  # 20
print(get_element(numbers, 5))  # Error message + None
3. Dictionary Lookup with Default
solution.py
def get_config(key, config_dict):
    try:
        return config_dict[key]
    except KeyError:
        print(f"Key '{key}' not found. Using default.")
        return "default_value"

config = {"host": "localhost", "port": 8080}
print(get_config("host", config))   # localhost
print(get_config("timeout", config)) # Key 'timeout' not found. Using default. → default_value

⚡ Intermediate (3 problems)

4. Multi‑Exception JSON Parser
solution.py
import json

def parse_and_access(json_string, key):
    try:
        data = json.loads(json_string)
        return data[key]
    except json.JSONDecodeError:
        print("Invalid JSON format.")
    except KeyError:
        print(f"Key '{key}' missing in JSON.")
    return None

# Example
good_json = '{"name": "Alice", "age": 30}'
bad_json = 'invalid'
print(parse_and_access(good_json, "name"))   # Alice
print(parse_and_access(good_json, "city"))   # Key missing
print(parse_and_access(bad_json, "name"))    # Invalid JSON
5. Custom Exception for Banking
solution.py
class InsufficientFundsError(Exception):
    def __init__(self, balance, amount):
        super().__init__(f"Balance {balance} is insufficient for withdrawal of {amount}")
        self.balance = balance
        self.amount = amount

def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFundsError(balance, amount)
    return balance - amount

try:
    new_balance = withdraw(100, 200)
except InsufficientFundsError as e:
    print(e)  # Balance 100 is insufficient for withdrawal of 200
6. Retry Decorator for Transient Errors
solution.py
import time
from functools import wraps

def retry(max_attempts=3, delay=1):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts - 1:
                        raise
                    print(f"Attempt {attempt+1} failed: {e}. Retrying...")
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(max_attempts=2, delay=0.5)
def unstable_call():
    # simulate failure
    raise ConnectionError("Network issue")

try:
    unstable_call()
except ConnectionError:
    print("All retries exhausted")

🚀 Advanced (3 problems)

7. Context Manager for Transaction
solution.py
class Transaction:
    def __init__(self):
        self.committed = False

    def __enter__(self):
        print("BEGIN TRANSACTION")
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type is None:
            self.committed = True
            print("COMMIT")
        else:
            print(f"ROLLBACK due to {exc_type.__name__}: {exc_val}")
        return False  # don't suppress exception

def process_order():
    with Transaction() as t:
        # raise ValueError("Invalid order")  # uncomment to test rollback
        print("Order processed")

process_order()
8. Chained Exceptions in Data Pipeline
solution.py
class DataPipelineError(Exception):
    pass

def parse_int(value):
    try:
        return int(value)
    except ValueError as original:
        raise DataPipelineError("Failed to convert to integer") from original

try:
    parse_int("abc")
except DataPipelineError as e:
    print(f"Pipeline error: {e}")
    if e.__cause__:
        print(f"Caused by: {e.__cause__}")
9. Global Unhandled Exception Hook
solution.py
import sys
import traceback
from datetime import datetime

def global_exception_handler(exc_type, exc_value, exc_tb):
    """Log unhandled exceptions to a file."""
    error_msg = ''.join(traceback.format_exception(exc_type, exc_value, exc_tb))
    with open('crash.log', 'a') as f:
        f.write(f"[{datetime.now()}] UNHANDLED EXCEPTION\n{error_msg}\n")
    print("A critical error occurred. Check crash.log", file=sys.stderr)

sys.excepthook = global_exception_handler

# Test – uncomment to see it in action (will crash but log)
# raise RuntimeError("Something went very wrong")

Unsolved Practice Problems

Test your understanding with these carefully graded exercises. No peeking — try to solve them first!

🔰 Easy (5 problems)

1. Safe Division

Write a function that takes two numbers and returns their division. If the denominator is zero, catch ZeroDivisionError and return None instead of crashing.

def safe_divide(a, b):
    # your code here
    pass
2. List Element Access

Given a list and an index, return the element. If the index is out of range, catch IndexError and print "Index out of bounds".

3. Dictionary Lookup with Default

Write a function get_config(key, config_dict) that returns the value for the key. Catch KeyError and return "default_value" instead.

4. Integer Conversion

Ask the user for input and convert it to an integer. Catch ValueError and print "Not a valid integer".

5. File Reader

Try to open a file and read its content. Catch FileNotFoundError and print a friendly message.

⚡ Intermediate (5 problems)

6. Multiple Exception Handling

Implement a function that parses a JSON string and accesses a specific key. Catch both json.JSONDecodeError and KeyError, printing different messages for each.

7. Finally for Resource Cleanup

Write a context manager class (or use a function with try/finally) that opens a file, processes it, and ensures the file is closed even if an exception occurs.

8. Custom Exception Hierarchy

Create a base exception AppError and two subclasses DatabaseError and ValidationError. Write a function that raises the appropriate error based on a condition.

9. Retry with Backoff

Write a decorator @retry_on_failure(max_retries=3) that retries a function if it raises any Exception, with a 1‑second delay between retries.

10. Assertion vs Exception

Write a function that calculates the square root of a number. Use assert to check non‑negative input, and catch it only if assertions are enabled. Then write a production‑ready version that raises ValueError instead.

🚀 Advanced (5 problems)

11. Chained Exceptions

In a data pipeline, catch a ValueError while converting a string to int. Raise a new DataPipelineError with the original exception as __cause__ (using raise ... from err).

12. Context Manager with Error Handling

Create a context manager Transaction that simulates a database transaction. If an exception occurs inside the with block, roll back; otherwise commit. Use __exit__ to decide.

13. Global Exception Hook

Override sys.excepthook to log all unhandled exceptions to a file with timestamp and traceback, then exit gracefully.

14. Circuit Breaker Pattern

Implement a simple circuit breaker class that, after 3 consecutive failures, stops calling a function for a timeout period.

15. Nested try/except with Resource Cleanup

Write a function that opens two files (source and destination) and copies content. If opening the destination fails, ensure the source file is closed in an outer finally block. Use nested try structures.