Skip to main content Brad's PyNotes

Tutorial: Errors and Exceptions

TL;DR

Python separates syntax errors (caught before running) from exceptions (caught during execution). The try/except/finally system lets you catch errors, recover gracefully, and guarantee cleanup code runs.

Interesting!

Python automatically chains exceptions raised during exception handling, but the implicit chain just says “During handling of the above exception, another exception occurred” without explaining why. Using raise ... from exc creates an explicit chain with clear causation, while raise ... from None suppresses chaining entirely to hide implementation details from API users.

Syntax Errors vs. Exceptions

Syntax errors occur during parsing, before your code runs. The parser identifies the problematic line and points to where it detected the issue:

python code snippet start

>>> while True print('Hello')
  File "<stdin>", line 1
    while True print('Hello')
               ^^^^^
SyntaxError: invalid syntax

python code snippet end

Exceptions occur during execution. Common built-in exceptions include ZeroDivisionError, NameError, TypeError, and ValueError. Unlike syntax errors, exceptions can be caught and handled.

Handling Exceptions with Try/Except

The try statement allows you to catch and handle specific exceptions:

python code snippet start

try:
    x = int(input("Enter a number: "))
    result = 10 / x
except ValueError:
    print("That's not a valid number")
except ZeroDivisionError:
    print("Cannot divide by zero")

python code snippet end

You can catch multiple exception types in one clause:

python code snippet start

except (RuntimeError, TypeError, NameError):
    print("One of several errors occurred")

python code snippet end

The Else Clause

An else clause after all except clauses runs only if no exception was raised:

python code snippet start

try:
    f = open('file.txt', 'r')
except FileNotFoundError:
    print("File not found")
else:
    print(f.read())
    f.close()

python code snippet end

This prevents accidentally catching exceptions from code you didn’t intend to protect.

The Finally Clause

The finally clause executes regardless of what happens in the try block:

python code snippet start

try:
    result = risky_operation()
except Exception as e:
    print(f"Error: {e}")
finally:
    cleanup_resources()  # Always runs

python code snippet end

This guarantees cleanup code runs even if you return early or an unhandled exception propagates.

Raising Exceptions

You can raise exceptions explicitly with the raise statement:

python code snippet start

if age < 0:
    raise ValueError("Age cannot be negative")

# Re-raise the current exception
try:
    dangerous_operation()
except Exception:
    log_error()
    raise  # Re-raises the caught exception

python code snippet end

Exception Chaining

When an exception occurs while handling another exception, Python automatically chains them. The implicit chain shows both exceptions but just says “During handling of the above exception, another exception occurred” without explaining the relationship.

Use raise ... from to create an explicit chain that clarifies the cause:

python code snippet start

try:
    open_database()
except ConnectionError as e:
    raise RuntimeError("Failed to initialize") from e

python code snippet end

The traceback now says “The above exception was the direct cause of the following exception”, making the relationship clear. The original exception is preserved as the __cause__ attribute.

For library code where implementation details should stay hidden, use raise ... from None to suppress chaining:

python code snippet start

try:
    internal_operation()
except InternalError:
    raise APIError("Operation failed") from None

python code snippet end

This prevents exposing internal implementation exceptions to API users.

User-Defined Exceptions

Built-in exceptions cover common cases, but domain-specific errors make your code more readable. A ValidationError is clearer than a generic ValueError when reading application code:

python code snippet start

class InvalidFruitError(Exception):
    """Raised when fruit is not in the approved list"""
    pass

def validate_fruit(fruit):
    valid_fruits = ['apple', 'banana', 'orange', 'grape']
    if fruit.lower() not in valid_fruits:
        raise InvalidFruitError(f"Unknown fruit: {fruit}")

python code snippet end

By convention, exception names end with “Error”.

Best Practices

Catch specific exceptions, not everything. A bare except: clause will silence KeyboardInterrupt and SystemExit, making your program hard to stop and masking bugs you didn’t anticipate. Specify exactly what you expect to go wrong:

python code snippet start

# Bad: catches everything, including bugs
try:
    process_data()
except:
    pass

# Good: catches only expected failures
try:
    process_data()
except (ValueError, KeyError):
    handle_invalid_data()

python code snippet end

Use context managers for cleanup. Files, database connections, and locks need to be released whether your code succeeds or fails. The with statement guarantees cleanup without verbose try/finally blocks:

python code snippet start

with open('file.txt') as f:
    data = f.read()
# File automatically closed, even if an error occurs

python code snippet end

Preserve error context when wrapping exceptions. When catching an exception to raise a more meaningful one, use raise ... from to keep the original traceback. Without it, you lose the root cause and make debugging harder.

Python’s exception handling provides the tools to write robust programs that fail gracefully and provide useful error information when things go wrong. For more on preserving error context, see PEP 3134: Exception Chaining .

Reference: Python Tutorial - Errors and Exceptions