Skip to main content

๐Ÿ›‘ Lesson 5: Error Handling in Depth

Go beyond basic try/except โ€” build custom exception hierarchies, master the full error-handling pattern, and replace print() debugging with Python's powerful logging module.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Use the full try/except/else/finally pattern correctly
  • Catch specific exceptions and understand the built-in exception hierarchy
  • Define custom exception classes organized into hierarchies
  • Raise, chain, and re-raise exceptions intentionally
  • Use the logging module for structured, leveled output

Estimated Time: 60 minutes

Project: Build a configuration file loader with robust error handling and logging

In This Lesson

๐Ÿค” Why Error Handling Matters

In the real world, things go wrong constantly: files are missing, network connections drop, users enter garbage data, APIs return unexpected responses. Error handling is how your program responds gracefully instead of crashing.

You've already seen basic try/except blocks. In this lesson, we'll go much deeper โ€” learning to write error-handling code that's precise, informative, and maintainable.

๐Ÿ“– Key Terms

Exception: An object representing an error condition. Python creates one when something goes wrong, and your code can catch, inspect, or create them.

Raise: To create and throw an exception intentionally with the raise keyword.

Catch (handle): To intercept an exception with except and decide what to do about it.

Propagate: When an exception is not caught, it travels up the call stack until something handles it โ€” or the program crashes.

Traceback: The stack of function calls Python prints when an unhandled exception occurs.

graph LR A["โšก Error Occurs"] -->|"raise"| B["๐Ÿšจ Exception Created"] B --> C{"Caught by
except?"} C -->|Yes| D["๐Ÿ› ๏ธ Handled"] C -->|No| E["โฌ†๏ธ Propagates Up"] E --> F{"Caller has
except?"} F -->|Yes| D F -->|No| G["๐Ÿ’ฅ Program Crashes
Traceback printed"] style D fill:#10b981,color:#fff style G fill:#ef4444,color:#fff

๐ŸŒณ Python's Built-in Exception Hierarchy

Python's exceptions form a class hierarchy rooted at BaseException. Understanding this tree is essential for writing precise except clauses โ€” because catching a parent class also catches all its children.

graph TD A["BaseException"] --> B["SystemExit"] A --> C["KeyboardInterrupt"] A --> D["Exception"] D --> E["ValueError"] D --> F["TypeError"] D --> G["KeyError"] D --> H["FileNotFoundError"] D --> I["IOError / OSError"] D --> J["AttributeError"] D --> K["RuntimeError"] D --> L["StopIteration"] D --> M["... 60+ more"] style A fill:#6366f1,color:#fff style D fill:#3b82f6,color:#fff

โš ๏ธ Never Catch BaseException

BaseException includes SystemExit and KeyboardInterrupt, which are not errors โ€” they're signals to stop the program. If you catch them, Ctrl+C won't work and sys.exit() will be swallowed. Always catch Exception or more specific types.

Common Built-in Exceptions

Exception When It's Raised Example
ValueError Right type, wrong value int("abc")
TypeError Wrong type entirely "hello" + 5
KeyError Dictionary key not found d["missing"]
IndexError Sequence index out of range [1, 2][5]
AttributeError Object has no such attribute "hi".append("!")
FileNotFoundError File or directory doesn't exist open("nope.txt")
PermissionError Insufficient permissions open("/etc/shadow")
ZeroDivisionError Division by zero 10 / 0
ImportError Module not found import nonexistent
RuntimeError Generic runtime problem Recursion limit, async errors

Checking the Hierarchy

# You can inspect the hierarchy with __mro__ (Method Resolution Order)
print(FileNotFoundError.__mro__)
# (<class 'FileNotFoundError'>, <class 'OSError'>, <class 'Exception'>,
#  <class 'BaseException'>, <class 'object'>)

# This means: catching OSError also catches FileNotFoundError!
print(issubclass(FileNotFoundError, OSError))   # True
print(issubclass(FileNotFoundError, Exception)) # True

๐Ÿง  Why This Matters

If you write except OSError, you'll catch FileNotFoundError, PermissionError, IsADirectoryError, and many others. Sometimes that's what you want โ€” sometimes you need to be more specific. Always catch the narrowest exception that makes sense.

๐Ÿ”„ The Full try/except/else/finally Pattern

Most developers know try/except, but the full four-part pattern gives you precise control over what runs when:

try:
    # Code that might fail
    result = risky_operation()

except SomeError as e:
    # Runs ONLY if SomeError (or its subclass) was raised
    print(f"Caught: {e}")

else:
    # Runs ONLY if NO exception occurred in try
    print(f"Success: {result}")

finally:
    # ALWAYS runs โ€” exception or not, even if you return/break
    print("Cleanup done.")
try: Code that might fail Error? except: Handle the error Yes else: Success path only No finally: ALWAYS runs โœ… Done

Practical Example

def load_config(filepath):
    """Load a config file with full error handling."""
    try:
        with open(filepath, "r", encoding="utf-8") as f:
            data = f.read()

    except FileNotFoundError:
        print(f"โŒ Config file not found: {filepath}")
        return None

    except PermissionError:
        print(f"๐Ÿ”’ No permission to read: {filepath}")
        return None

    else:
        # Only runs if open + read succeeded
        print(f"โœ… Loaded {len(data)} characters from {filepath}")
        return data

    finally:
        # Always runs โ€” good for logging, metrics, cleanup
        print(f"๐Ÿ“‹ Attempted to load: {filepath}")


# Test it
config = load_config("settings.ini")
config = load_config("nonexistent.ini")

Output:

โœ… Loaded 142 characters from settings.ini
๐Ÿ“‹ Attempted to load: settings.ini
โŒ Config file not found: nonexistent.ini
๐Ÿ“‹ Attempted to load: nonexistent.ini

๐Ÿ“– When to Use else vs. Putting Code in try

The else block is for code that should only run if try succeeded but that you don't want to accidentally catch exceptions from. If you put processing code inside try, an unrelated error in that processing might get caught by your except clause, masking the real bug.

# โŒ Bad: processing code inside try โ€” a bug in process_data()
#    could be caught by except ValueError
try:
    raw = input("Enter a number: ")
    number = int(raw)
    result = process_data(number)  # Bug here? Caught below!
except ValueError:
    print("Not a number!")

# โœ… Good: only the risky part is in try
try:
    raw = input("Enter a number: ")
    number = int(raw)
except ValueError:
    print("Not a number!")
else:
    result = process_data(number)  # Bug here? Propagates normally

๐ŸŽฏ Catching Multiple Exceptions

You have several options for handling different exceptions differently:

Separate except Clauses

try:
    value = int(user_input)
    result = data[value]
except ValueError:
    print("Please enter a valid integer.")
except IndexError:
    print(f"Index out of range. Valid: 0โ€“{len(data) - 1}")
except Exception as e:
    print(f"Unexpected error: {type(e).__name__}: {e}")

โš ๏ธ Order Matters!

Python checks except clauses from top to bottom and runs the first one that matches. If you put a broad exception like Exception first, it catches everything โ€” and the specific handlers below it never run. Always order from most specific to most general.

Catching Multiple Types in One Clause

If you want the same handler for several exceptions, use a tuple:

try:
    result = parse_and_calculate(user_input)
except (ValueError, TypeError, ArithmeticError) as e:
    print(f"Input error: {e}")
    result = None

Accessing Exception Details

The as e syntax binds the exception instance so you can inspect it:

try:
    with open("data.json", "r") as f:
        data = json.load(f)
except json.JSONDecodeError as e:
    print(f"JSON parse error in {e.doc[:20]}...")
    print(f"  Line {e.lineno}, Column {e.colno}")
    print(f"  Message: {e.msg}")
except FileNotFoundError as e:
    print(f"Missing file: {e.filename}")
    print(f"  Error number: {e.errno}")

๐Ÿง  Exception Objects Are Rich

Many built-in exceptions carry useful attributes beyond the message string. OSError has .errno and .filename. JSONDecodeError has .lineno and .colno. UnicodeDecodeError has .encoding and .reason. Check the docs for the exception you're catching.

๐Ÿš€ Raising & Chaining Exceptions

Raising Exceptions

Use raise to signal that something has gone wrong. This is how your functions communicate errors to their callers:

def set_age(age):
    if not isinstance(age, int):
        raise TypeError(f"Age must be an integer, got {type(age).__name__}")
    if age < 0 or age > 150:
        raise ValueError(f"Age must be 0โ€“150, got {age}")
    return age

# These work
set_age(25)   # 25
set_age(0)    # 0

# These raise
set_age("old")   # TypeError: Age must be an integer, got str
set_age(-5)      # ValueError: Age must be 0โ€“150, got -5

Re-raising Exceptions

Sometimes you want to catch an exception, do something (like log it), and then let it continue propagating. Use bare raise:

def process_file(path):
    try:
        with open(path) as f:
            return f.read()
    except FileNotFoundError:
        print(f"โš ๏ธ Logging: {path} not found")
        raise  # Re-raises the SAME exception with original traceback

Exception Chaining with from

When you catch one exception and raise a different one, you can preserve the original cause with raise ... from ...:

class ConfigError(Exception):
    """Error loading application configuration."""
    pass

def load_config(path):
    try:
        with open(path) as f:
            return json.load(f)
    except FileNotFoundError as e:
        raise ConfigError(f"Config file missing: {path}") from e
    except json.JSONDecodeError as e:
        raise ConfigError(f"Invalid JSON in {path}") from e


try:
    config = load_config("bad_config.json")
except ConfigError as e:
    print(f"Config error: {e}")
    print(f"Caused by: {e.__cause__}")

Output:

Config error: Config file missing: bad_config.json
Caused by: [Errno 2] No such file or directory: 'bad_config.json'

๐Ÿ“– raise X from Y vs. raise X

raise X from Y โ€” Explicit chaining. Sets X.__cause__ = Y. The traceback shows both exceptions with "The above exception was the direct cause of..."

raise X inside an except โ€” Implicit chaining. Sets X.__context__ = Y. The traceback shows "During handling of the above exception, another exception occurred..."

raise X from None โ€” Suppresses chaining. Only the new exception is shown in the traceback.

๐Ÿ—๏ธ Custom Exception Classes

Custom exceptions make your error handling semantic โ€” callers can catch InsufficientFundsError instead of guessing which ValueError means what.

Basic Custom Exception

class InsufficientFundsError(Exception):
    """Raised when a withdrawal exceeds the account balance."""
    pass

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance

    def withdraw(self, amount):
        if amount > self.balance:
            raise InsufficientFundsError(
                f"Cannot withdraw ${amount:.2f} โ€” "
                f"balance is ${self.balance:.2f}"
            )
        self.balance -= amount
        return self.balance


account = BankAccount("Alice", 100)

try:
    account.withdraw(150)
except InsufficientFundsError as e:
    print(f"โŒ {e}")

Output:

โŒ Cannot withdraw $150.00 โ€” balance is $100.00

Custom Exceptions with Extra Data

Add attributes to carry structured information about the error:

class ValidationError(Exception):
    """Raised when input validation fails."""

    def __init__(self, field, value, message):
        self.field = field
        self.value = value
        self.message = message
        super().__init__(f"{field}: {message} (got {value!r})")


def validate_email(email):
    if not isinstance(email, str):
        raise ValidationError("email", email, "must be a string")
    if "@" not in email:
        raise ValidationError("email", email, "must contain @")
    if not email.endswith((".com", ".org", ".net", ".edu")):
        raise ValidationError("email", email, "unsupported domain")
    return email


try:
    validate_email("bob_at_gmail")
except ValidationError as e:
    print(f"โŒ Validation failed!")
    print(f"   Field: {e.field}")
    print(f"   Value: {e.value!r}")
    print(f"   Reason: {e.message}")

Output:

โŒ Validation failed!
   Field: email
   Value: 'bob_at_gmail'
   Reason: must contain @

๐ŸŒณ Exception Hierarchies in Practice

For larger projects, organize your custom exceptions into a hierarchy. This lets callers choose their level of granularity โ€” catch a broad category or a specific error.

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

class DatabaseError(AppError):
    """Base for all database-related errors."""
    pass

class ConnectionError(DatabaseError):
    """Failed to connect to the database."""
    pass

class QueryError(DatabaseError):
    """A database query failed."""
    pass

class AuthError(AppError):
    """Base for authentication/authorization errors."""
    pass

class InvalidCredentialsError(AuthError):
    """Wrong username or password."""
    pass

class PermissionDeniedError(AuthError):
    """User lacks required permissions."""
    pass
graph TD A["๐Ÿ  AppError"] --> B["๐Ÿ—„๏ธ DatabaseError"] A --> C["๐Ÿ” AuthError"] B --> D["๐Ÿ”Œ ConnectionError"] B --> E["โ“ QueryError"] C --> F["๐Ÿ”‘ InvalidCredentialsError"] C --> G["๐Ÿšซ PermissionDeniedError"] style A fill:#6366f1,color:#fff style B fill:#3b82f6,color:#fff style C fill:#f59e0b,color:#fff

Now callers can choose their specificity:

# Catch everything from our app
try:
    do_something()
except AppError as e:
    print(f"App error: {e}")

# Catch only database issues
try:
    query_database()
except DatabaseError as e:
    print(f"DB problem: {e}")

# Catch a specific auth issue
try:
    login(username, password)
except InvalidCredentialsError:
    print("Wrong username or password.")
except AuthError:
    print("Authentication problem โ€” please try again.")

โœ… Convention: One Base Exception Per Package

Most well-designed Python libraries define a single base exception (like requests.RequestException or django.core.exceptions.ImproperlyConfigured). This lets users write except MyLibraryError as a catch-all for everything your library raises, without accidentally catching unrelated exceptions.

๐Ÿ“‹ The logging Module

Using print() for debugging and error reporting is a beginner habit that doesn't scale. Python's built-in logging module gives you leveled output, configurable destinations, and structured formatting โ€” all with very little setup.

Why logging Over print()?

Feature print() logging
Severity levels โŒ None โœ… DEBUG, INFO, WARNING, ERROR, CRITICAL
Turn on/off easily โŒ Comment out lines โœ… Change level to filter
Write to files โŒ Manual redirect โœ… Built-in handlers
Timestamps โŒ Manual โœ… Automatic
Source location โŒ No โœ… Module, function, line number
Exception tracebacks โŒ Manual โœ… exc_info=True

Quick Start

import logging

# Configure the root logger
logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s [%(levelname)-8s] %(name)s: %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S"
)

logger = logging.getLogger(__name__)

logger.debug("Starting data processing...")
logger.info("Loaded 1,024 records from database.")
logger.warning("Disk usage at 87% โ€” consider cleanup.")
logger.error("Failed to connect to payment API.")
logger.critical("Database is unreachable โ€” shutting down.")

Output:

2026-04-12 10:30:01 [DEBUG   ] __main__: Starting data processing...
2026-04-12 10:30:01 [INFO    ] __main__: Loaded 1,024 records from database.
2026-04-12 10:30:01 [WARNING ] __main__: Disk usage at 87% โ€” consider cleanup.
2026-04-12 10:30:01 [ERROR   ] __main__: Failed to connect to payment API.
2026-04-12 10:30:01 [CRITICAL] __main__: Database is unreachable โ€” shutting down.
DEBUG Level 10 INFO Level 20 WARNING Level 30 ERROR Level 40 CRITICAL Level 50 โ† Less severe ยท ยท ยท More severe โ†’

Logging to a File

import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[
        logging.FileHandler("app.log", encoding="utf-8"),
        logging.StreamHandler()  # Also print to console
    ]
)

logger = logging.getLogger(__name__)
logger.info("Application started.")
logger.error("Something went wrong!")

Logging Exceptions with Tracebacks

The killer feature of logging is exc_info=True, which automatically includes the full traceback:

import logging

logger = logging.getLogger(__name__)

def divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        logger.error("Division by zero!", exc_info=True)
        return None

result = divide(10, 0)

Output:

2026-04-12 10:30:01 [ERROR   ] __main__: Division by zero!
Traceback (most recent call last):
  File "example.py", line 7, in divide
    return a / b
ZeroDivisionError: division by zero

You can also use logger.exception() as a shortcut โ€” it's the same as logger.error(..., exc_info=True):

try:
    risky_operation()
except SomeError:
    logger.exception("Operation failed")  # Includes traceback automatically

Named Loggers

In larger projects, use named loggers to trace which module generated each message:

# In database.py
logger = logging.getLogger("myapp.database")
logger.info("Connected to PostgreSQL")

# In auth.py
logger = logging.getLogger("myapp.auth")
logger.warning("Failed login attempt for user: admin")

# In main.py โ€” configure once
logging.basicConfig(level=logging.DEBUG)
# Now all "myapp.*" loggers output through the root logger

๐Ÿง  Logger Hierarchy

Logger names use dots as separators, forming a hierarchy: myapp.database is a child of myapp, which is a child of the root logger. Configuration applied to a parent automatically applies to all children unless they override it. This is why logging.getLogger(__name__) is the convention โ€” it automatically matches your module hierarchy.

๐Ÿ‹๏ธ Hands-on Exercises

๐Ÿ‹๏ธ Exercise 1: Robust Number Parser

Objective: Practice try/except/else/finally and multiple exception types.

Requirements:

  1. Write a function parse_number(value) that accepts a string and returns a number
  2. Try to parse as int first, then float if that fails
  3. Raise a ValueError with a clear message if neither works
  4. Write a function safe_divide(a, b) that uses try/except/else/finally
  5. Use logging instead of print() for all output

Starter Code:

import logging

logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s [%(levelname)-8s] %(message)s",
    datefmt="%H:%M:%S"
)
logger = logging.getLogger(__name__)


def parse_number(value):
    """Parse a string into int or float."""
    # TODO: Try int first, then float, raise ValueError if both fail
    pass


def safe_divide(a, b):
    """Divide a by b with full error handling."""
    # TODO: Use try/except/else/finally
    #   - try: perform division
    #   - except: handle ZeroDivisionError and TypeError
    #   - else: log the successful result
    #   - finally: log that division was attempted
    pass


# Test cases
test_values = ["42", "3.14", "0xFF", "hello", "", "  7  "]
for val in test_values:
    try:
        result = parse_number(val)
        logger.info(f"parse_number({val!r}) = {result}")
    except ValueError as e:
        logger.error(f"parse_number({val!r}) failed: {e}")

print()
safe_divide(10, 3)
safe_divide(10, 0)
safe_divide("10", 3)
๐Ÿ’ก Hint

For parse_number, use nested try/except: try int() first, catch ValueError, then try float(). Remember that int("0xFF", 16) handles hex, but you can keep it simple with just int(value.strip()). For safe_divide, put only the division in try and use else for the success message.

โœ… Solution
import logging

logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s [%(levelname)-8s] %(message)s",
    datefmt="%H:%M:%S"
)
logger = logging.getLogger(__name__)


def parse_number(value):
    """Parse a string into int or float."""
    cleaned = value.strip()
    if not cleaned:
        raise ValueError(f"Cannot parse empty string as a number")

    try:
        return int(cleaned)
    except ValueError:
        pass  # Not an int, try float

    try:
        return float(cleaned)
    except ValueError:
        raise ValueError(
            f"Cannot parse {value!r} as int or float"
        )


def safe_divide(a, b):
    """Divide a by b with full error handling."""
    try:
        result = a / b
    except ZeroDivisionError:
        logger.error(f"Cannot divide {a} by zero")
        return None
    except TypeError as e:
        logger.error(f"Type error in division: {e}")
        return None
    else:
        logger.info(f"{a} / {b} = {result:.4f}")
        return result
    finally:
        logger.debug(f"Division attempted: {a!r} / {b!r}")


# Test cases
test_values = ["42", "3.14", "0xFF", "hello", "", "  7  "]
for val in test_values:
    try:
        result = parse_number(val)
        logger.info(f"parse_number({val!r}) = {result}")
    except ValueError as e:
        logger.error(f"parse_number({val!r}) failed: {e}")

print()
safe_divide(10, 3)
safe_divide(10, 0)
safe_divide("10", 3)

๐Ÿ‹๏ธ Exercise 2: Config Loader with Custom Exceptions

Objective: Build a configuration file loader with a custom exception hierarchy and logging.

Requirements:

  1. Define a ConfigError base exception with subclasses: ConfigNotFoundError, ConfigParseError, ConfigValidationError
  2. Write a load_config(path) function that reads a simple key=value config file
  3. Validate that required keys (host, port, debug) are present
  4. Validate that port is a number between 1 and 65535
  5. Use exception chaining (from) when wrapping low-level errors
  6. Log every step using the logging module
๐Ÿ’ก Hint

Parse each line by splitting on = (use line.split("=", 1) to handle values containing =). For validation, check the parsed dictionary for required keys and convert port to int. Wrap FileNotFoundError in ConfigNotFoundError and ValueError in ConfigParseError.

โœ… Solution
import logging
from pathlib import Path

logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s [%(levelname)-8s] %(message)s",
    datefmt="%H:%M:%S"
)
logger = logging.getLogger("config_loader")

# --- Custom Exception Hierarchy ---

class ConfigError(Exception):
    """Base exception for configuration errors."""
    pass

class ConfigNotFoundError(ConfigError):
    """Config file does not exist."""
    pass

class ConfigParseError(ConfigError):
    """Config file has invalid syntax."""
    pass

class ConfigValidationError(ConfigError):
    """Config values fail validation."""
    pass


# --- Config Loader ---

REQUIRED_KEYS = {"host", "port", "debug"}

def load_config(path):
    """Load and validate a key=value config file."""
    path = Path(path)
    logger.info(f"Loading config from: {path}")

    # Step 1: Read the file
    try:
        text = path.read_text(encoding="utf-8")
    except FileNotFoundError as e:
        raise ConfigNotFoundError(f"Config not found: {path}") from e
    except PermissionError as e:
        raise ConfigError(f"Cannot read {path}: permission denied") from e

    # Step 2: Parse key=value pairs
    config = {}
    for line_num, line in enumerate(text.splitlines(), start=1):
        line = line.strip()
        if not line or line.startswith("#"):
            continue  # Skip blanks and comments
        if "=" not in line:
            raise ConfigParseError(
                f"Line {line_num}: expected key=value, got {line!r}"
            )
        key, value = line.split("=", 1)
        config[key.strip()] = value.strip()

    logger.debug(f"Parsed {len(config)} config entries")

    # Step 3: Validate required keys
    missing = REQUIRED_KEYS - config.keys()
    if missing:
        raise ConfigValidationError(
            f"Missing required keys: {', '.join(sorted(missing))}"
        )

    # Step 4: Validate port
    try:
        port = int(config["port"])
    except ValueError as e:
        raise ConfigValidationError(
            f"'port' must be an integer, got {config['port']!r}"
        ) from e

    if not (1 <= port <= 65535):
        raise ConfigValidationError(
            f"'port' must be 1โ€“65535, got {port}"
        )
    config["port"] = port

    # Step 5: Parse debug as boolean
    config["debug"] = config["debug"].lower() in ("true", "1", "yes")

    logger.info(f"Config loaded: host={config['host']}, "
                f"port={config['port']}, debug={config['debug']}")
    return config


# --- Test ---

# Create a sample config file
sample_config = """# App Configuration
host = localhost
port = 8080
debug = true
database = postgres://localhost/mydb
"""

Path("app.cfg").write_text(sample_config, encoding="utf-8")

try:
    config = load_config("app.cfg")
    print(f"Config: {config}")
except ConfigNotFoundError as e:
    logger.error(f"File missing: {e}")
except ConfigParseError as e:
    logger.error(f"Parse error: {e}")
except ConfigValidationError as e:
    logger.error(f"Validation error: {e}")
except ConfigError as e:
    logger.error(f"Config error: {e}")

๐ŸŽฏ Quick Quiz

Question 1: When does the else block in a try/except/else run?

Question 2: What does raise ConfigError("bad") from original_error do?

Question 3: What's the main advantage of logging over print() for error reporting?

๐Ÿ“ Best Practices

โœ… Do's

  • Catch specific exceptions โ€” not bare except: or except Exception: unless you're at the top-level entry point
  • Use else to separate the "risky" code from the "success" code
  • Use finally for cleanup that must happen regardless (closing connections, releasing locks)
  • Chain exceptions with from when wrapping low-level errors in domain-specific ones
  • Use logging instead of print() โ€” it's trivial to set up and infinitely more useful
  • Use logger.exception() inside except blocks to automatically include tracebacks
  • Include context in error messages โ€” "File not found" is bad; "Config file not found: /etc/app/settings.ini" is good

โŒ Don'ts

  • Don't use bare except: โ€” it catches KeyboardInterrupt and SystemExit, making your program impossible to stop
  • Don't silently swallow exceptions โ€” except: pass hides bugs. At minimum, log the error
  • Don't catch exceptions too broadly โ€” except Exception at every call site masks bugs
  • Don't use exceptions for flow control โ€” checking if key in dict is better than catching KeyError in normal flow
  • Don't inherit from BaseException โ€” always inherit from Exception for custom errors

๐Ÿ’ก Pro Tips

  • Use logging.getLogger(__name__) in every module โ€” it automatically creates a logger hierarchy matching your package structure
  • Configure logging once in your main() or entry point โ€” don't call basicConfig() in library code
  • For production apps, use logging.handlers.RotatingFileHandler to prevent log files from growing forever
  • The warnings module is for deprecation notices and non-fatal issues โ€” don't confuse it with logging

๐Ÿ“ Summary

๐ŸŽ‰ Key Takeaways

  • try/except/else/finally โ€” the full pattern gives precise control: except for errors, else for success, finally for always
  • Catch specific exceptions โ€” ordered from most specific to most general
  • raise signals errors; raise ... from ... chains exceptions to preserve context
  • Custom exceptions make errors semantic โ€” callers catch InsufficientFundsError, not a generic ValueError
  • Exception hierarchies let callers choose granularity โ€” catch one specific error or a whole category
  • logging replaces print() โ€” gives levels, timestamps, file output, and traceback capture for free
Pattern When to Use
try / except Basic error handling โ€” catch and respond
try / except / else Separate risky code from success-only code
try / finally Guaranteed cleanup (prefer with when possible)
try / except / else / finally Complex operations needing all four phases
raise X from Y Wrap low-level errors in domain-specific ones
raise (bare) Re-raise the current exception after logging/cleanup
logger.exception() Log an error with its full traceback

๐Ÿ“š Additional Resources

๐Ÿš€ What's Next?

In the next lesson, we'll explore Regular Expressions โ€” Python's re module for powerful pattern matching, text search, and data extraction.

๐ŸŽ‰ Level Up!

Your programs now handle errors gracefully, report problems with structured logging, and use custom exception hierarchies that make debugging a breeze. No more mysterious crashes or silent failures!