April 8, 2025
Python Beginner Error Handling Exceptions

Error Handling in Python: try, except, else, and finally

Your code will fail. Not might fail, will fail. And I don't just mean when you're learning; I mean every single day when you're writing production code, processing user input, reading files, or making network requests. The real difference between a beginner and a professional isn't that one never encounters errors, it's that one knows how to handle them gracefully.

Think about what happens when real software meets the real world: users type the wrong thing, files get deleted, network connections drop, APIs return unexpected responses. Your job isn't to write code that works under perfect conditions, it's to write code that behaves sensibly when everything falls apart. That mindset shift, from "make it work" to "make it work even when things go wrong," is what transforms a coder into an engineer. Error handling is the mechanism Python gives you to make that shift concrete.

In this article, we're diving deep into Python's error-handling arsenal: the humble try/except statement, the often-overlooked else clause, the guaranteed-to-execute finally block, and the art of custom exceptions. We'll also cover how Python organizes exceptions into a hierarchy, the most common mistakes developers make when handling errors, and how to design exception classes of your own. By the end, you'll know not just how to catch errors, but how to design code that fails intelligently, recovers when possible, and tells you exactly what went wrong. Let's build code that earns trust.

Table of Contents
  1. Understanding Python's Exception Hierarchy
  2. The Exception Hierarchy in Depth
  3. The try/except Block: The Basics
  4. Catching Specific Exceptions: Why It Matters
  5. The Else Clause: Often Missed, Always Useful
  6. The Finally Clause: Guaranteed Execution
  7. The Complete Pattern: try/except/else/finally
  8. Raising Exceptions: Creating Your Own Errors
  9. Custom Exception Classes: Designing Your Own
  10. Common Error Handling Mistakes
  11. Exception Chaining: Preserving Context
  12. Grouping Related Exceptions
  13. Common Anti-Patterns: What NOT to Do
  14. The Bare Except Clause
  15. Silently Swallowing Exceptions
  16. Catching Too Broadly
  17. Exception Handling in Real Code: A Complete Example
  18. The Exception Object: Extracting Information
  19. Context Managers: The Preview
  20. Putting It All Together: A Practical Application
  21. Debugging with print() vs Proper Logging
  22. Testing Error Handling
  23. Performance Considerations: Exception Cost
  24. The Danger of Except Blocks That Are Too Broad
  25. Real-World Example: Reading Configuration Files
  26. Key Takeaways
  27. Next Steps

Understanding Python's Exception Hierarchy

Before we start catching exceptions, you need to understand what we're catching. Python organizes exceptions into a hierarchy, like a family tree of problems. Knowing this tree is what separates someone who guesses at exception types from someone who chooses them deliberately.

text
BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception (this is the important one)
    ├── StopIteration
    ├── ArithmeticError
    │   ├── ZeroDivisionError
    │   ├── OverflowError
    │   └── FloatingPointError
    ├── LookupError
    │   ├── IndexError
    │   └── KeyError
    ├── NameError
    ├── TypeError
    ├── ValueError
    ├── AttributeError
    └── ... (many more)

The key rule: always catch Exception or more specific subclasses, never bare BaseException. That top level includes system-level stuff like KeyboardInterrupt (Ctrl+C) that you really don't want your code swallowing.

Here's what this means in practice. Notice how we catch ValueError instead of the catch-all BaseException, this is intentional and important:

python
# BAD - catches too much
try:
    user_input = input("Enter a number: ")
    number = int(user_input)
except BaseException:
    print("Something went wrong")
 
# GOOD - catches what we expect
try:
    user_input = input("Enter a number: ")
    number = int(user_input)
except ValueError:
    print("That's not a valid number")

If someone presses Ctrl+C while the program is running, the bad version silently swallows their interrupt. The good version lets it through. The hierarchy is also why catching a parent class catches all its children: if you catch ArithmeticError, you catch both ZeroDivisionError and OverflowError. This can be powerful, but it can also obscure which specific problem occurred, so use parent-class catching sparingly and only when you genuinely want to treat all child errors the same way.

The Exception Hierarchy in Depth

Understanding the exception hierarchy gives you a map of failure modes across Python's standard library. Every exception you encounter has a logical home in this tree, and that placement tells you something meaningful about what went wrong.

LookupError groups IndexError (accessed a list index that doesn't exist) and KeyError (accessed a dictionary key that doesn't exist). If you're writing a function that works with both lists and dictionaries and you want to handle missing values consistently, catching LookupError lets you do that cleanly. ArithmeticError groups all the ways math can go wrong: dividing by zero, numeric overflow, floating-point failures. OSError (not shown above but equally important) groups file system and I/O failures, FileNotFoundError, PermissionError, and TimeoutError all live under it.

Why does this matter to you? Because when you write except ValueError, you're being precise, you're saying "I expect this specific category of problem." When a new error type is added to Python or a library, understanding the hierarchy tells you whether your existing handler will catch it or not. For example, if a future Python version adds a new NumericConversionError as a subclass of ValueError, your existing except ValueError handlers would catch it automatically. Hierarchy-aware exception handling makes your code resilient to evolution. The practical takeaway: always ask yourself where in the tree the exceptions you care about live, and catch at the most specific level that still expresses your intent.

The try/except Block: The Basics

Let's start simple. Here's the anatomy of a try/except statement. The structure might look minimal, but every keyword carries meaning:

python
try:
    # Code that might cause an error
    result = 10 / 0
except ZeroDivisionError:
    # Code that runs if that specific error happens
    print("Can't divide by zero!")

Expected output:

Can't divide by zero!

The try block contains the risky code. The except block runs only if that specific exception occurs. If no exception happens, the except block is skipped entirely.

Here's the critical part: when an exception occurs inside the try block, execution stops immediately and jumps to the except block. Any code after the error line (but still in the try block) never runs. This "fail fast" behavior is actually a feature, you don't want your code stumbling forward with corrupted state after something goes wrong.

python
try:
    print("Starting calculation")
    result = 10 / 0
    print("This line never executes")
except ZeroDivisionError:
    print("Caught a zero division error")
 
print("Program continues here")

Expected output:

Starting calculation
Caught a zero division error
Program continues here

Notice that "This line never executes" doesn't print. When the exception happens, we immediately jump to the except block, then continue with the rest of the program. This illustrates the core contract of try/except: the try block is an atomic unit of intent, either it fully succeeds, or control transfers to an appropriate handler. Everything after the try/except structure runs normally, so your program recovers and continues rather than crashing.

Catching Specific Exceptions: Why It Matters

You can have multiple except blocks to handle different errors differently. This is essential for writing robust code. Think of each except block as a different customer service specialist, you route the call to the person who knows how to solve that specific problem.

python
user_input = input("Enter a number: ")
 
try:
    number = int(user_input)
    result = 100 / number
except ValueError:
    print(f"'{user_input}' is not a valid integer")
except ZeroDivisionError:
    print("Can't divide by zero!")

If the user types "abc", they get the ValueError message. If they type "0", they get the ZeroDivisionError message. Each exception is handled appropriately.

The order of except blocks matters. Python checks them top-to-bottom and uses the first matching one. If you had more specific exceptions first and general ones later, you're golden. But if you did it backwards:

python
try:
    # risky code
except Exception:  # This catches EVERYTHING
    print("An error occurred")
except ValueError:  # This line is unreachable
    print("Specifically, a value error")

That ValueError block will never execute because ValueError is a subclass of Exception, so the first block catches it first. Always put more specific exceptions before general ones. A useful mental model: exception handlers are like a series of nets with different mesh sizes, put the fine-mesh nets at the top to catch small, specific exceptions, and the coarser nets below to catch anything that slipped through.

The Else Clause: Often Missed, Always Useful

Here's where most tutorials fail you. They skip right over the else clause, and it's a shame, because it fundamentally changes how you think about error handling. The else clause doesn't just add convenience, it changes the semantic meaning of your code in a way that makes your intentions crystal clear to anyone reading it later.

The else clause runs only if no exception occurred in the try block. It's your way of saying "if everything went smoothly, do this next thing."

python
try:
    age = int(input("Enter your age: "))
except ValueError:
    print("That's not a valid age")
else:
    if age >= 18:
        print("You can vote")
    else:
        print("You'll be able to vote soon")

Let's trace through this:

  1. If the user types "abc": ValueError is caught, the except block runs, the else block is skipped.
  2. If the user types "25": No exception occurs, the except block is skipped, the else block runs.

Without the else clause, you'd have to put all that logic inside the try block. This is the version many beginners write:

python
try:
    age = int(input("Enter your age: "))
    if age >= 18:
        print("You can vote")
    else:
        print("You'll be able to vote soon")
except ValueError:
    print("That's not a valid age")

This works, but it's semantically unclear. The voting logic isn't related to error handling; it only runs if conversion succeeds. The else clause makes this explicit. By keeping the voting check in else, you're communicating clearly: "this block has nothing to do with error recovery, it's the happy path." Future maintainers (including your future self) will thank you for that clarity.

Here's a more practical example with file operations. The else clause shines in exactly these situations, where you want to process data only after confirming a risky operation succeeded:

python
try:
    with open("data.txt", "r") as file:
        data = file.read()
except FileNotFoundError:
    print("The file doesn't exist")
else:
    # Process the data only if the file was successfully opened
    lines = data.split("\n")
    print(f"Read {len(lines)} lines")

The else clause is especially powerful because it separates "error handling" from "happy-path logic." This makes your code easier to understand and maintain. When you review code six months from now and see logic in an else clause, you know immediately: this code only runs when nothing went wrong. That signal is worth more than any comment.

The Finally Clause: Guaranteed Execution

The finally block is your cleanup crew. It runs no matter what, whether an exception occurred or not. Whether you have an else clause or not. Always. This guarantee is the whole point, it exists precisely for situations where you absolutely cannot afford to skip cleanup, no matter how the code above it behaves.

This is essential for cleanup tasks. Here we use it to ensure a file is always closed, even if reading fails:

python
file = None
 
try:
    file = open("important.txt", "r")
    data = file.read()
except FileNotFoundError:
    print("File not found")
finally:
    if file:
        file.close()
    print("Cleanup complete")

Expected output (if file exists):

Cleanup complete

Expected output (if file doesn't exist):

File not found
Cleanup complete

Notice that "Cleanup complete" prints in both cases. Even if the file isn't found and we jump to the except block, finally still runs. This guarantee extends even further: finally runs if you return from inside the try block, if an exception propagates upward uncaught, or if the code raises a new exception inside the except block. The only way to prevent finally from running is a hard process termination like os._exit().

This pattern is so common that Python provides the with statement (context managers) to handle it automatically. In practice, the with statement is the preferred pattern for resource management precisely because it packages the finally guarantee into a clean, readable syntax:

python
try:
    with open("important.txt", "r") as file:
        data = file.read()
except FileNotFoundError:
    print("File not found")
else:
    print(f"Read {len(data)} characters")
finally:
    print("The file is automatically closed by 'with'")

The with statement automatically closes the file in its finally block. We'll explore context managers more deeply in a later article, but the key insight is that finally is for anything that must happen regardless of success or failure. Database connections, network sockets, locks, temporary files, any resource you open, you must close, and finally is your insurance policy that you will.

The Complete Pattern: try/except/else/finally

Here's the full orchestration. This pattern is one worth memorizing, it shows up constantly in professional Python code once you know what to look for:

python
def process_user_data(filename):
    try:
        # Try to open and read the file
        with open(filename, "r") as file:
            data = file.read()
    except FileNotFoundError:
        print(f"Error: {filename} not found")
    except IOError:
        print("Error: Unable to read the file")
    else:
        # Only runs if no exception occurred
        lines = data.strip().split("\n")
        print(f"Successfully processed {len(lines)} lines")
        return lines
    finally:
        # Always runs
        print("File operation complete")
 
    return None
 
result = process_user_data("data.txt")

The execution flow is:

  1. Try to open and read the file
  2. If FileNotFoundError happens, print that message
  3. If IOError happens instead, print a different message
  4. If neither exception occurred, the else block processes the data
  5. Regardless of what happened above, the finally block prints the completion message

This pattern is incredibly common in professional code. Whenever you're doing something that might fail (file I/O, network requests, database queries), you'll use some variant of this. Each clause has a single, clear job: try attempts the operation, except handles specific failures, else processes the success result, finally cleans up. When every clause has a focused responsibility, the code reads like a story with a clear narrative arc.

Raising Exceptions: Creating Your Own Errors

Sometimes you need to deliberately raise an exception. This signals to the caller that something is wrong, and lets them decide how to handle it. Raising your own exceptions is how you enforce contracts in your API, you're saying "this precondition must hold, and if it doesn't, I refuse to proceed."

python
def divide(a, b):
    if b == 0:
        raise ValueError("Denominator cannot be zero")
    return a / b
 
try:
    result = divide(10, 0)
except ValueError as error:
    print(f"Invalid operation: {error}")

Expected output:

Invalid operation: Denominator cannot be zero

The raise statement stops execution and passes control to the nearest except block that catches that exception type. Notice the as error syntax, this captures the exception object, which often contains useful information about what went wrong. The message you provide when raising becomes the string representation of the exception, so make it descriptive and actionable, tell the caller what went wrong and, when possible, what they should do about it.

You can also re-raise an exception. This is a key technique for adding context (like logging) while still letting the error propagate to whoever is best positioned to handle it:

python
try:
    with open("data.txt", "r") as file:
        data = file.read()
except FileNotFoundError as error:
    print("Logging the error to our system...")
    # Do some logging, then re-raise
    raise  # Re-raises the same exception

The bare raise statement (without specifying an exception) re-raises the most recent exception. This is useful when you want to do some cleanup or logging but still let the error propagate up to the caller. It preserves the original traceback entirely, so whoever catches the re-raised exception sees the full picture of where and why it originally occurred.

Custom Exception Classes: Designing Your Own

For complex applications, creating custom exceptions helps you organize error handling logic and communicate intent. A well-designed exception hierarchy is part of your API, it tells callers exactly what category of problem occurred, making their error handling code clean and specific rather than a mess of string comparisons.

python
class InsufficientFundsError(Exception):
    """Raised when an account doesn't have enough money"""
    pass
 
class BankAccount:
    def __init__(self, name, balance):
        self.name = name
        self.balance = balance
 
    def withdraw(self, amount):
        if amount > self.balance:
            raise InsufficientFundsError(
                f"Cannot withdraw ${amount}. Available: ${self.balance}"
            )
        self.balance -= amount
        return self.balance
 
# Using the custom exception
account = BankAccount("Alice", 100)
 
try:
    account.withdraw(150)
except InsufficientFundsError as error:
    print(f"Transaction denied: {error}")
 
print(f"Account balance: ${account.balance}")

Expected output:

Transaction denied: Cannot withdraw $150. Available: $100
Account balance: $100

Custom exceptions inherit from Exception and let you catch specific, domain-related errors. This is much cleaner than trying to distinguish between error types based on string messages. When a caller catches InsufficientFundsError, they know precisely what happened, no string parsing, no guesswork. This is why every major library ships with its own exception hierarchy: requests raises requests.RequestException, Django raises django.core.exceptions.ValidationError. Defining your own exceptions is how you participate in that ecosystem professionally.

You can also add custom attributes to carry structured data about the failure:

python
class ValidationError(Exception):
    def __init__(self, message, field_name):
        super().__init__(message)
        self.field_name = field_name
 
try:
    raise ValidationError("Email format invalid", "email")
except ValidationError as error:
    print(f"Field '{error.field_name}' failed validation: {error}")

Expected output:

Field 'email' failed validation: Email format invalid

Adding attributes to your exceptions turns them into structured data carriers rather than plain error messages. Callers can access error.field_name programmatically, log it, display it in a UI, or route it to the appropriate handler. When you design custom exceptions thoughtfully, your error handling becomes part of your domain model, every exception tells a precise story about what went wrong and where.

Common Error Handling Mistakes

Even experienced developers fall into predictable traps with error handling. Knowing these patterns in advance means you can recognize and avoid them before they become bugs in production.

The most dangerous mistake is the silent swallow: catching an exception and doing nothing, or just printing a vague message. When you write except Exception: pass, you've created a black hole, errors disappear, your program pretends everything is fine, and you have no idea what's going wrong or how often. This kind of code is easy to write when you're in a hurry and hard to debug months later when something subtle breaks. Always at minimum log the error with its traceback before suppressing it.

A close second is the overly broad try block. When your try block spans thirty lines and catches Exception, you've created a situation where any bug anywhere in those thirty lines gets quietly absorbed. Maybe a variable name is misspelled, maybe a method signature changed, your broad handler masks it all. Keep try blocks as small as possible, wrapping only the specific operation that can fail. This forces you to think clearly about which operations are risky and why.

Catching the wrong exception type is subtler but equally dangerous. If you expect ValueError but catch Exception, you'll also catch TypeError, AttributeError, and NameError. Now a programmer error, a genuine bug in your code, gets treated as expected user error and silently handled. You lose the signal that something is broken. Conversely, catching a type that's too narrow means your handler never fires, and the error propagates as an unhandled exception when you expected recovery. The fix is the same in both cases: understand the exception hierarchy, test your handlers explicitly, and match your catch to exactly the failure mode you expect.

Another common mistake is ignoring the exception message and attributes. The exception object often contains precise, actionable information, the invalid value, the missing key, the specific line of malformed JSON. When you catch an exception and print a generic message instead of using str(error) or accessing its attributes, you're throwing away free debugging information. Always use the exception object in your error messages.

Finally, many developers treat finally as optional cleanup. It isn't. Any resource you acquire in a try block, file handles, database connections, network sockets, locks, must be released in a finally block (or via a context manager). Resource leaks are silent, accumulative, and can bring down a long-running server days after the offending code ran. The habit of pairing every resource acquisition with a guaranteed release is one of the most important disciplinary practices in professional development.

Exception Chaining: Preserving Context

Sometimes you catch an exception and want to raise a different one while preserving the original error information. This is called exception chaining.

python
class DataProcessingError(Exception):
    pass
 
def process_user_file(filename):
    try:
        with open(filename, "r") as file:
            data = file.read()
            numbers = [int(x) for x in data.split(",")]
    except FileNotFoundError as error:
        raise DataProcessingError(f"Cannot find {filename}") from error
    except ValueError as error:
        raise DataProcessingError("File contains non-numeric data") from error

When you use from error, the original exception is attached to the new one. This helps with debugging, you can see the full chain of what went wrong. Without exception chaining, converting a low-level FileNotFoundError into a high-level DataProcessingError would hide the original cause. With chaining, you get the best of both worlds: a domain-meaningful exception for callers, and the full low-level detail for debugging.

python
try:
    process_user_file("data.txt")
except DataProcessingError as error:
    print(f"High-level error: {error}")
    print(f"Caused by: {error.__cause__}")

The __cause__ attribute gives you access to the original exception. This is invaluable for understanding complex error scenarios.

Python 3.11+ introduced exception groups (and the except* syntax), which lets you catch multiple exceptions at once. While we're focusing on simpler patterns here, it's worth knowing this exists for more advanced error handling:

python
# Python 3.11+
try:
    # Code that might raise multiple exceptions
    results = []
except* ValueError as errors:
    # Handle all ValueErrors that occurred
    for error in errors:
        print(f"Value error: {error}")
except* TypeError as errors:
    # Handle all TypeErrors that occurred
    for error in errors:
        print(f"Type error: {error}")

This is useful when you have operations that might generate multiple errors and you want to handle them all at once rather than stopping at the first one. For now, stick with the standard try/except patterns, but know this is available as your code grows more complex.

Common Anti-Patterns: What NOT to Do

The Bare Except Clause

python
# DON'T DO THIS
try:
    risky_operation()
except:  # Catches literally everything
    print("Something went wrong")

This catches KeyboardInterrupt, SystemExit, and other system-level exceptions. Your program becomes impossible to stop, and debugging becomes a nightmare. Always specify what you're catching.

Silently Swallowing Exceptions

python
# BAD
try:
    process_data()
except Exception:
    pass  # Silent failure!

You just buried an error. The program continues as if nothing happened, but something is clearly wrong. If you must suppress an exception, at least log it:

python
# BETTER
import logging
 
try:
    process_data()
except Exception as error:
    logging.error(f"Data processing failed: {error}")

Catching Too Broadly

python
# BAD
try:
    number = int(user_input)
    result = 100 / number
    save_to_database(result)
except Exception:
    print("An error occurred")

You've caught ValueError, ZeroDivisionError, and database errors all in one basket. You can't handle them differently, and you don't know which operation actually failed. Be specific. Each distinct operation that can fail deserves its own try block and its own handler:

python
# GOOD
try:
    number = int(user_input)
except ValueError:
    print("Invalid number format")
    return
 
try:
    result = 100 / number
except ZeroDivisionError:
    print("Division by zero")
    return
 
try:
    save_to_database(result)
except DatabaseError:
    print("Failed to save data")

This is more verbose, but your error handling is clear and appropriate.

Exception Handling in Real Code: A Complete Example

Let's tie everything together with a realistic example. This pattern, custom exception, file I/O, validation in the else clause, logging throughout, is the backbone of configuration loading in virtually every serious Python application:

python
import json
 
class ConfigError(Exception):
    """Raised when configuration is invalid"""
    pass
 
def load_config(filename):
    """Load and validate configuration from a JSON file."""
    try:
        with open(filename, "r") as file:
            config = json.load(file)
    except FileNotFoundError:
        raise ConfigError(f"Configuration file '{filename}' not found")
    except json.JSONDecodeError as error:
        raise ConfigError(f"Invalid JSON in {filename}: {error}")
    else:
        # Validation logic
        required_keys = ["database", "port", "debug"]
        missing = [k for k in required_keys if k not in config]
        if missing:
            raise ConfigError(f"Missing required keys: {missing}")
        return config
    finally:
        print("Configuration loading attempt completed")
 
# Using it
try:
    config = load_config("app.json")
except ConfigError as error:
    print(f"Configuration error: {error}")
    exit(1)
 
print(f"Loaded config with port {config['port']}")

This example demonstrates:

  • Specific exception handling: FileNotFoundError and JSONDecodeError are caught separately
  • Custom exceptions: ConfigError lets callers distinguish configuration errors from other issues
  • The else clause: Validation only runs if the file loaded successfully
  • Re-raising exceptions: We convert lower-level errors into domain-specific ones
  • Finally cleanup: We guarantee a completion message

The Exception Object: Extracting Information

When you catch an exception, the object contains valuable debugging information. Getting comfortable with the exception object is what separates developers who write good error messages from those who write "something went wrong":

python
try:
    result = 1 / 0
except ZeroDivisionError as error:
    print(f"Exception type: {type(error).__name__}")
    print(f"Exception message: {str(error)}")
    print(f"Exception args: {error.args}")

Expected output:

Exception type: ZeroDivisionError
Exception message: division by zero
Exception args: ('division by zero',)

You can also access the traceback for debugging. In production code, this is how you get the full picture of what went wrong and how execution got there:

python
import traceback
 
try:
    risky_function()
except Exception as error:
    print("Full traceback:")
    traceback.print_exc()
    print(f"\nJust the message: {error}")

The traceback shows you the exact line where the error occurred and the path of function calls that led there. It's invaluable for debugging.

Context Managers: The Preview

We mentioned that with statements handle cleanup automatically. This is so powerful that it deserves mention here. Understanding the relationship between context managers and finally blocks helps you see how Python's design encourages safe resource handling:

python
# Without with (manual cleanup)
file = open("data.txt", "r")
try:
    data = file.read()
except IOError:
    print("Error reading file")
finally:
    file.close()
 
# With with (automatic cleanup)
try:
    with open("data.txt", "r") as file:
        data = file.read()
except IOError:
    print("Error reading file")

The with statement guarantees that file.close() is called, even if an exception occurs. We'll explore how to write your own context managers in a future article, but for now, know that the with statement is the Pythonic way to handle resource management. Any time you find yourself writing a finally block to close or release something, ask whether a context manager would express the same intent more cleanly.

Putting It All Together: A Practical Application

Here's a function that validates and processes user input with comprehensive error handling. Notice how the error handling logic is separated from the business logic, the except blocks deal with failure modes, while the code outside them deals with success:

python
def get_positive_integer(prompt):
    """Get a positive integer from the user."""
    while True:
        try:
            user_input = input(prompt)
            number = int(user_input)
        except ValueError:
            print(f"'{user_input}' is not a valid integer. Try again.")
            continue
 
        if number <= 0:
            print("Number must be positive. Try again.")
            continue
 
        return number
 
# Usage
try:
    age = get_positive_integer("Enter your age: ")
    print(f"You entered: {age}")
except KeyboardInterrupt:
    print("\nProgram interrupted by user")
except Exception as error:
    print(f"Unexpected error: {error}")

This function demonstrates:

  • Looping on error: Invalid input triggers a retry
  • Specific exception handling: ValueError for non-integers
  • Custom validation: Checking the value is positive
  • User-friendly messages: Each error explains what went wrong

Debugging with print() vs Proper Logging

When an exception occurs, resist the urge to just print messages. Use the logging module instead:

python
import logging
 
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
 
try:
    result = int("not a number")
except ValueError as error:
    logger.error(f"Conversion failed: {error}", exc_info=True)

The exc_info=True parameter automatically includes the full traceback. Unlike print statements, logging messages have timestamps, severity levels, and can be redirected to files. This becomes essential as your code grows.

Here's why logging beats print:

python
# Bad: scattered print statements
try:
    operation()
except Exception:
    print("Error in operation")  # Where? When? Why?
 
# Better: structured logging
try:
    operation()
except Exception:
    logger.exception("Operation failed")  # Includes full traceback automatically

Testing Error Handling

You should test that your code handles errors correctly. This is often forgotten, but it's critical:

python
import pytest
 
def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b
 
def test_divide_by_zero():
    """Test that division by zero raises the right exception"""
    with pytest.raises(ValueError, match="Cannot divide by zero"):
        divide(10, 0)
 
def test_divide_normal():
    """Test normal operation"""
    assert divide(10, 2) == 5

When you write tests, you explicitly verify that errors are raised when they should be. This prevents regressions where someone "fixes" error handling by removing it.

Performance Considerations: Exception Cost

A common misconception: "exceptions are slow, so avoid them." In Python, exceptions are actually optimized for the happy path. An exception that isn't raised has virtually no performance cost. But an exception that is raised is expensive because Python must unwind the stack and create a traceback.

This means:

  • Good: Use exceptions for error conditions (they're rare)
  • Bad: Use exceptions for normal control flow
python
# BAD - using exceptions for control flow
def find_user(username):
    try:
        return users[username]
    except KeyError:
        return None
 
# GOOD - checking before accessing
def find_user(username):
    if username in users:
        return users[username]
    return None

In the bad example, every missing user incurs exception overhead. The good example checks directly, which is cheaper when misses are common.

However, if exceptions are truly rare, the exception approach is fine. The point is: exceptions are for exceptional situations, not normal variations.

The Danger of Except Blocks That Are Too Broad

We've mentioned this before, but it deserves emphasis. When you catch a broad exception, you might be masking bugs:

python
# BAD
try:
    calculate_taxes()
    submit_to_irs()
except Exception:
    print("Tax calculation failed")

If there's a bug in your code that causes a NameError or AttributeError, you'll catch it and pretend everything is fine. The user thinks their taxes were submitted when they weren't. Meanwhile, you have no idea what went wrong.

Instead, only catch what you expect. Separating the two operations into their own try blocks also makes it immediately obvious which step failed when something goes wrong:

python
# GOOD
try:
    calculate_taxes()
except ValueError:
    print("Tax rate data is invalid")
except IOError:
    print("Cannot read tax file")
 
try:
    submit_to_irs()
except ConnectionError:
    print("Cannot connect to IRS servers")
except HTTPError as error:
    print(f"IRS returned error {error.status_code}")

Now each error is handled specifically. If something unexpected happens, it will crash loudly, and you'll see exactly what the problem is.

Real-World Example: Reading Configuration Files

Let's combine everything we've learned into a realistic function. This is the kind of code you'd write on day one of a new project and rely on for years, it needs to be thorough:

python
import json
import logging
from pathlib import Path
 
logger = logging.getLogger(__name__)
 
class ConfigError(Exception):
    """Custom exception for configuration problems"""
    pass
 
def load_config(config_path):
    """
    Load configuration from a JSON file.
 
    Args:
        config_path: Path to the JSON configuration file
 
    Returns:
        dict: Configuration data
 
    Raises:
        ConfigError: If configuration cannot be loaded or is invalid
    """
    config_path = Path(config_path)
 
    try:
        if not config_path.exists():
            raise ConfigError(f"Config file not found: {config_path}")
 
        with open(config_path, "r") as f:
            config = json.load(f)
    except json.JSONDecodeError as e:
        raise ConfigError(f"Invalid JSON in {config_path}: {e}") from e
    except IOError as e:
        raise ConfigError(f"Cannot read {config_path}: {e}") from e
    else:
        logger.info(f"Successfully loaded config from {config_path}")
 
        # Validate required fields
        required = ["database", "api_key"]
        missing = [k for k in required if k not in config]
        if missing:
            raise ConfigError(f"Missing required fields: {missing}")
 
        return config
    finally:
        logger.debug("Config loading attempt completed")
 
# Usage with comprehensive error handling
if __name__ == "__main__":
    logging.basicConfig(
        level=logging.INFO,
        format="%(levelname)s: %(message)s"
    )
 
    try:
        config = load_config("config.json")
        print(f"Database: {config['database']}")
    except ConfigError as e:
        logger.error(f"Configuration error: {e}")
        exit(1)
    except Exception as e:
        logger.exception("Unexpected error")
        exit(1)

This example shows:

  • Custom exception for domain-specific errors
  • Exception chaining with from e to preserve context
  • The else clause for successful operations
  • Finally for cleanup (logging completion)
  • Specific exception types for different failures
  • Proper logging instead of print
  • Clear error messages for users

Key Takeaways

Error handling isn't about making your code look sophisticated. It's about:

  1. Anticipating failure: Identify where things can go wrong
  2. Being specific: Catch the exact exceptions you expect
  3. Handling appropriately: Each error deserves a thoughtful response
  4. Cleaning up properly: Use finally and context managers
  5. Informing the user: Error messages should help, not confuse
  6. Designing for failure: Write code that fails gracefully

The difference between brittle code and robust code isn't complexity, it's thinking. When you write a try block, you're asking "what can go wrong here?" and "what should happen if it does?" That's the mark of a professional programmer.

Next Steps

In the next article, we'll explore Python Modules and Imports: Organizing Your Code. You'll learn how to organize your exception classes, utility functions, and growing codebase into reusable modules. You'll discover how to structure projects so they're maintainable and shareable, and how to navigate Python's rich ecosystem of third-party packages.

Until then, practice writing code that handles errors thoughtfully. Test edge cases. Think about what your functions should do when they receive unexpected input. These habits, more than any technical knowledge, separate beginners from professionals. Start with the code you're already writing, go back to a recent script and ask: what happens if this file doesn't exist? What if the user types a letter instead of a number? What if the network is down? Adding those handlers, even to existing code, will build the muscle memory that makes robust error handling feel natural.

Remember: the best error handling is invisible to the user. When something goes wrong, they see a clear, helpful message and a graceful recovery. The internals, the hierarchy awareness, the specific except clauses, the finally blocks, the custom exception classes, all of that is invisible to them. That invisibility is the goal. When your error handling disappears into the background and just quietly does the right thing, you've done your job. That's what we're aiming for.

Need help implementing this?

We build automation systems like this for clients every day.

Discuss Your Project