Programs fail: files go missing, users type letters where numbers belong, networks drop. Exception handling is how Python lets you respond to these errors gracefully instead of crashing. Master it and your code becomes robust, predictable, and far easier to debug.

This guide covers the full toolkit end-to-end: try/except, catching specific vs multiple exceptions, the else and finally clauses, raising your own errors with raise, building custom exception classes, exception chaining, and the patterns professionals actually use. Every example is runnable with its output shown.

Errors vs Exceptions

A syntax error stops your code from running at all. An exception happens while the program runs — it is a recoverable runtime event you can catch and handle.

print(10 / 0)      # this line runs, then raises an exception

Output:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero

The try / except Block

Wrap risky code in try; handle failures in except. The program keeps running instead of crashing.

try:
    result = 10 / 0
except ZeroDivisionError:
    print("You can't divide by zero!")

print("Program continues...")

Output:

You can't divide by zero!
Program continues...

Catch Specific Exceptions (and the Error Object)

Always catch the specific exception you expect. Use as e to access the error message.

try:
    age = int("not a number")
except ValueError as e:
    print(f"Invalid input: {e}")

Output:

Invalid input: invalid literal for int() with base 10: 'not a number'
Avoid a bare except: or except Exception: that swallows everything — it hides bugs and even catches KeyboardInterrupt. Catch what you can actually handle.

Handling Multiple Exceptions

Use several except clauses, or group related ones in a tuple.

def safe_divide(a, b):
    try:
        return int(a) / int(b)
    except ValueError:
        return "Please enter numbers"
    except ZeroDivisionError:
        return "Cannot divide by zero"

print(safe_divide(10, 2))
print(safe_divide(10, 0))
print(safe_divide("x", 2))

Output:

5.0
Cannot divide by zero
Please enter numbers

Grouping with a tuple when the response is the same:

try:
    risky()
except (ValueError, TypeError) as e:
    print(f"Bad data: {e}")

The else and finally Clauses

else runs only if no exception occurred. finally runs always — success or failure — making it perfect for cleanup like closing files or connections.

def read_value(data, key):
    try:
        value = data[key]
    except KeyError:
        print("Key not found")
        return None
    else:
        print("Lookup succeeded")
        return value
    finally:
        print("Done with lookup")

print(read_value({"a": 1}, "a"))
print("---")
print(read_value({"a": 1}, "z"))

Output:

Lookup succeeded
Done with lookup
1
---
Key not found
Done with lookup
None

Raising Exceptions with raise

Use raise to signal an error yourself when an input or state is invalid — this is better than returning a magic value the caller might ignore.

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

try:
    set_age(-5)
except ValueError as e:
    print(f"Rejected: {e}")

Output:

Rejected: Age cannot be negative

Custom Exception Classes

For domain-specific errors, define your own exception by subclassing Exception. This lets callers catch exactly your error and makes intent obvious.

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

def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFundsError(f"Tried {amount}, only {balance} available")
    return balance - amount

try:
    withdraw(100, 250)
except InsufficientFundsError as e:
    print(f"Declined: {e}")

Output:

Declined: Tried 250, only 100 available

Exception Chaining with raise ... from

When you catch one error and raise a more meaningful one, use raise NewError(...) from e to preserve the original cause — invaluable when debugging.

class ConfigError(Exception):
    pass

def load_port(config):
    try:
        return int(config["port"])
    except KeyError as e:
        raise ConfigError("Missing 'port' in config") from e

try:
    load_port({})
except ConfigError as e:
    print(f"{e} (caused by {e.__cause__!r})")

Output:

Missing 'port' in config (caused by KeyError('port'))

Real-World Example: A Robust Input Loop

A classic use case: keep asking the user until they give valid input, handling both bad values and Ctrl-C cleanly.

def get_positive_int(prompt):
    while True:
        try:
            value = int(input(prompt))
            if value <= 0:
                raise ValueError("must be positive")
            return value
        except ValueError as e:
            print(f"Invalid: {e}. Try again.")
        except (KeyboardInterrupt, EOFError):
            print("\nCancelled.")
            return None

# age = get_positive_int("Enter your age: ")

This pattern — validate, raise on bad data, catch, re-prompt — is the foundation of reliable, user-facing programs.

Common Mistakes to Avoid

  • Bare except: — catches everything, hides real bugs. Be specific.
  • Swallowing errors silently with except: pass — at least log them.
  • Putting too much in try — wrap only the line that can fail, so you don't mask unrelated errors.
  • Using exceptions for normal flow control — prefer an if check when failure is expected and cheap to test.
  • Losing the original error — use raise ... from e when re-raising.
  • Returning instead of cleaning up — use finally (or a with block) to release resources.

Summary Table

KeywordRuns whenTypical use
tryAlways (the risky code)Wrap code that may fail
exceptAn exception occursHandle a specific error
elseNo exception occurredCode that needs the try to succeed
finallyAlwaysCleanup (close files, connections)
raiseYou call itSignal an error yourself
raise ... fromRe-raisingPreserve the original cause

Conclusion

Good exception handling is about being specific and intentional: catch the errors you expect, raise clear errors for invalid states, clean up with finally, and preserve context when re-raising. Done well, it turns crashes into controlled, debuggable behaviour.

Audit one script today: find the line most likely to fail (a file open, an API call, an int() conversion) and wrap just that line in a precise try/except. Small, specific handlers beat one giant catch-all every time.