§9  Exception Handling
Python Programming Series  ·  Article 9

Exception Handling in Python: A Complete Guide

Picture a tightrope walker crossing between two skyscrapers. Without a safety net, one slip means disaster. With a net, they can still fall, but they're caught, they recover, and they try again.

Full exception hierarchy
try / except / else / finally
Re-raising & chaining
Logging & advanced tools
Uncaught error
Caught / handled
Recovered / cleaned up

Exception handling is your code's safety net.


1. The Three Types of Errors

Before handling errors, you need to know what can actually go wrong. Programming errors fall into three broad categories.

Type 1: Syntax Errors: "You Broke the Grammar Rules"

A syntax error happens when Python literally can't understand what you wrote. It's like handing someone a sentence with the words scrambled; they can't even begin to read it.

These are caught before the program runs. Python reads your whole file first, and if the grammar is broken, it refuses to start. You will never see any output from a file containing a syntax error.

Common causes: - Missing colons after if, for, def, or class - Mismatched brackets ( ), [ ], { } - Misspelled keywords - Bad indentation (this specific case is called an IndentationError, a subtype of syntax error)

python
# Missing colon after 'if'
if True
    print("Hello")

# Mismatched brackets
result = (1 + 2

# Bad indentation
def greet():
print("Hi")     # not indented

Type 2: Logical Errors: "You Said the Wrong Thing"

A logical error is the trickiest kind. The code runs perfectly with no crash, but it produces the wrong answer. Python has no way to detect these, because as far as Python is concerned, everything worked. Only you (or your tests) can catch them.

python
def calculate_average(numbers):
    total = 0
    for n in numbers:
        total += n
    return total / 10   # BUG: hardcoded 10 instead of len(numbers)!

scores = [80, 90, 75, 95, 85]
print(calculate_average(scores))          # Wrong answer, but no crash
print(sum(scores) / len(scores))          # The correct average

The program never complains; it just quietly gives you a wrong result. These are the bugs that keep experienced engineers up at night.

Type 3: Runtime Errors / Exceptions: "Something Went Wrong While Running"

A runtime error (also called an exception) happens while the program is executing. The syntax is correct and the logic is sound, but something unexpected occurs during the run. This is the category exception handling is all about.

Common causes: - Dividing by zero - Accessing a list index that doesn't exist - Opening a file that isn't there - Calling a method on the wrong type of data

python
examples = [
    ("ZeroDivisionError", lambda: 1 / 0),
    ("IndexError",        lambda: [1, 2, 3][10]),
    ("KeyError",          lambda: {"a": 1}["b"]),
    ("TypeError",         lambda: "5" + 5),
    ("ValueError",        lambda: int("hello")),
    ("AttributeError",    lambda: "text".non_existent_method()),
    ("NameError",         lambda: undefined_variable),
    ("FileNotFoundError", lambda: open("ghost.txt")),
]

for name, trigger in examples:
    try:
        trigger()
    except Exception as e:
        print(f"  {name:<22}: {e}")
  ZeroDivisionError     : division by zero
  IndexError            : list index out of range
  KeyError              : 'b'
  TypeError             : can only concatenate str (not "int") to str
  ValueError            : invalid literal for int() with base 10: 'hello'
  AttributeError        : 'str' object has no attribute 'non_existent_method'
  NameError             : name 'undefined_variable' is not defined
  FileNotFoundError     : [Errno 2] No such file or directory: 'ghost.txt'

Each of these would normally crash the program, unless you handle it.


2. Errors vs. Exceptions: A Subtle Difference

People often use these two words interchangeably, but in Python they're slightly different:

Errors Exceptions
When detected Before execution (at parse time) During execution (runtime)
Examples SyntaxError, IndentationError ValueError, ZeroDivisionError, FileNotFoundError
Can you handle it? Usually not, the program can't even start Yes, this is what try/except is for
Python's reaction Stops before running anything Creates an exception object and "raises" it

What Is an Exception Object?

When a runtime exception occurs, Python automatically creates an exception object that contains three things: - The type of exception (e.g., ValueError) - A message describing what went wrong - A traceback showing exactly where in the code it happened

If you don't handle that object, Python prints the traceback and stops the program. If you do handle it, you get to decide what happens next.


3. Python's Exception Hierarchy: The Family Tree

Every exception in Python is a class, and these classes are arranged in a family tree where some exceptions are "children" of others. Understanding this tree helps you catch exactly the right errors.

BaseException
├── SystemExit              ← raised by sys.exit()
├── KeyboardInterrupt       ← raised by Ctrl+C
├── GeneratorExit           ← raised when a generator is closed
└── Exception               ← parent of ALL regular exceptions
    ├── ArithmeticError
    │   ├── ZeroDivisionError
    │   ├── OverflowError
    │   └── FloatingPointError
    ├── LookupError
    │   ├── IndexError
    │   └── KeyError
    ├── OSError
    │   ├── FileNotFoundError
    │   ├── PermissionError
    │   ├── FileExistsError
    │   └── TimeoutError
    ├── ValueError
    ├── TypeError
    ├── NameError
    │   └── UnboundLocalError
    ├── AttributeError
    ├── ImportError
    │   └── ModuleNotFoundError
    ├── RuntimeError
    │   └── RecursionError
    ├── StopIteration
    ├── MemoryError
    └── AssertionError

The key insight: if you catch a parent exception, you automatically catch all of its children too. Catching OSError also catches FileNotFoundError, PermissionError, and every other OS-related error beneath it. Think of it like a company org chart: telling security to "let anyone from the Engineering department through" automatically covers everyone on every team that reports up into Engineering, without needing to list each team by name.

python
# ZeroDivisionError is a child of ArithmeticError
try:
    result = 1 / 0
except ArithmeticError as e:
    print(f"Caught as ArithmeticError: {type(e).__name__}")   # ZeroDivisionError

# IndexError is a child of LookupError
try:
    x = [1, 2, 3][99]
except LookupError as e:
    print(f"Caught as LookupError: {type(e).__name__}")       # IndexError

# FileNotFoundError is a child of OSError
try:
    open("missing.txt")
except OSError as e:
    print(f"Caught as OSError: {type(e).__name__}")           # FileNotFoundError
Caught as ArithmeticError: ZeroDivisionError
Caught as LookupError: IndexError
Caught as OSError: FileNotFoundError

You can verify these relationships with isinstance():

python
e = ValueError("test")
print(isinstance(e, Exception))      # True: ValueError IS an Exception
print(isinstance(e, BaseException))  # True: and a BaseException
print(isinstance(e, TypeError))      # False: but NOT a TypeError
True
True
False

4. The Most Common Exceptions You'll Meet

Here are the exceptions you'll run into most often, what causes each, and how to fix them:

Exception Triggered by How to fix
TypeError Mixing incompatible types: "age: " + 25 Convert types first: "age: " + str(25)
ValueError Right type, wrong value: int("hello") Validate input before converting
IndexError List index out of range: [10,20,30][5] Check len() first
KeyError Dictionary key missing: user["email"] Use dict.get("email", default)
AttributeError Method on wrong type: (42).upper() Check the type before calling
ZeroDivisionError Dividing by zero: 100 / 0 Check the denominator isn't 0
NameError Using an undefined variable Check spelling; assign it first
RecursionError A function calling itself endlessly Add a base case to stop recursion

Seeing them triggered one at a time, with the fix spelled out, makes them easier to recognize later:

python
try:
    result = "age: " + 25
except TypeError as e:
    print(f"[TypeError] {e}")
    print("  Fix: convert types, 'age: ' + str(25), or use an f-string")

try:
    items = [10, 20, 30]
    print(items[5])
except IndexError as e:
    print(f"[IndexError] {e}")
    print("  Fix: check len(list) first")

try:
    user = {"name": "Alice", "age": 28}
    print(user["email"])
except KeyError as e:
    print(f"[KeyError] {e}")
    print("  Fix: use dict.get('email', default) instead")

try:
    x = 42
    x.upper()
except AttributeError as e:
    print(f"[AttributeError] {e}")
    print("  Fix: check the type before calling the method")
[TypeError] can only concatenate str (not "int") to str
  Fix: convert types, 'age: ' + str(25), or use an f-string
[IndexError] list index out of range
  Fix: check len(list) first
[KeyError] 'email'
  Fix: use dict.get('email', default) instead
[AttributeError] 'int' object has no attribute 'upper'
  Fix: check the type before calling the method

5. The try / except Block: The Foundation

This is the core of all exception handling. You put risky code inside try, and you put the recovery plan inside except.

python
try:
    # Code that might fail
except SomeException:
    # What to do if it fails

How it works, step by step: 1. Python runs the try block line by line. 2. If an exception occurs, Python immediately jumps to the matching except block. 3. Any remaining lines in the try block are skipped. 4. After the except block finishes, the program continues normally.

python
try:
    print("Line 1: About to divide...")
    result = 10 / 0                     # Exception happens HERE
    print("Line 2: This is skipped!")   # Never runs
except ZeroDivisionError:
    print("Caught: ZeroDivisionError!")   # Execution jumps here

print("Program continues here...")
Line 1: About to divide...
Caught: ZeroDivisionError!
Program continues here...

Notice "Line 2" never printed; once the error hit, Python jumped straight to except.

The Golden Rule: Be Specific

Always catch the most specific exception you can. Catching a broad Exception for everything hides bugs. Catching ZeroDivisionError specifically tells you exactly what you're guarding against.

Capturing the Exception Object with as

The as keyword lets you grab the exception object so you can inspect its details:

python
try:
    data = {"price": "19.99", "currency": "USD"}
    quantity = int(data["quantity"])    # KeyError: 'quantity' not in dict
except KeyError as e:
    print(f"Exception type    : {type(e).__name__}")   # KeyError
    print(f"Exception message : {e}")                  # 'quantity'
    print(f"Exception args    : {e.args}")              # ('quantity',)
Exception type    : KeyError
Exception message : 'quantity'
Exception args    : ('quantity',)

Here, e is the exception object. You can read its message with str(e), get the type with type(e).__name__, and access the raw details with e.args. You can even make decisions based on the message:

python
try:
    age = int("twenty five")
except ValueError as e:
    if "invalid literal" in str(e):
        print("Hint: The input contains letters, not digits")
Hint: The input contains letters, not digits

Real-World Example: Stripe-Style Payment Processing

A payment can fail for many different reasons: an invalid card, an expired card, insufficient funds, a network timeout. Each one needs a different response. Multiple except blocks handle exactly this:

python
class InvalidCardError(Exception): pass
class CardExpiredError(Exception): pass
class InsufficientFundsError(Exception): pass
class NetworkTimeoutError(Exception): pass

def process_payment(card_number, amount, card_balance):
    if not card_number.isdigit() or len(card_number) != 16:
        raise InvalidCardError(f"Card '{card_number}' is not a valid 16-digit number")
    if card_number.startswith("0000"):
        raise CardExpiredError("Card has expired")
    if card_balance < amount:
        raise InsufficientFundsError(f"Balance ${card_balance:.2f} is less than charge ${amount:.2f}")
    return {"status": "success", "remaining": card_balance - amount}

def checkout(card_number, amount, card_balance):
    try:
        result = process_payment(card_number, amount, card_balance)
        print(f"Payment approved! Remaining: ${result['remaining']:.2f}")
    except InvalidCardError as e:
        print(f"Declined: Invalid card details. ({e})")
        print("Action: Ask user to re-enter card number")
    except CardExpiredError:
        print("Declined: Your card has expired.")
        print("Action: Ask user to update their payment method")
    except InsufficientFundsError:
        print("Declined: Insufficient funds.")
        print("Action: Suggest a smaller order")
    except NetworkTimeoutError:
        print("Error: Network timeout. Please try again.")
        print("Action: Retry automatically up to 3 times")

checkout("4532015112830366", 49.99, 200.00)   # Success
checkout("abc123", 49.99, 200.00)              # Invalid card
checkout("0000111122223333", 49.99, 200.00)    # Expired
checkout("4532015112830366", 49.99, 30.00)     # Insufficient funds
Payment approved! Remaining: $150.01
Declined: Invalid card details. (Card 'abc123' is not a valid 16-digit number)
Action: Ask user to re-enter card number
Declined: Your card has expired.
Action: Ask user to update their payment method
Declined: Insufficient funds.
Action: Suggest a smaller order

Each failure type leads to its own user-friendly message and recovery action, exactly how a real payment page behaves.


6. The else Clause: "Only If Everything Went Fine"

The else block runs only if no exception occurred in the try block. Think of it as the success path.

python
try:
    risky_operation()
except SomeError:
    handle_error()
else:
    # Runs ONLY if try block succeeded with no exceptions
    do_success_work()

Why bother with else instead of just putting the success code inside try?

Because the try block should contain only the risky line. If you put your success code inside try too, and that code happens to raise an error, your except block would catch it by accident, masking a completely different bug.

python
# Risky style: success code is inside try
def divide_v1(a, b):
    try:
        result = a / b
        formatted = f"Result: {result:.4f}"   # If THIS failed, except would catch it
        print(formatted)
    except ZeroDivisionError:
        print("Cannot divide by zero")

# Better style: only the risky part is in try
def divide_v2(a, b):
    try:
        result = a / b                  # ONLY the risky part
    except ZeroDivisionError:
        print("Cannot divide by zero")
    else:
        formatted = f"Result: {result:.4f}"   # Success code, safely separated
        print(formatted)

divide_v2(10, 3)
divide_v2(10, 0)
Result: 3.3333
Cannot divide by zero

else keeps the risk zone as tight as possible, which makes bugs easier to find.


7. The finally Clause: "Always Run This, No Matter What"

The finally block always runs, whether the try succeeded, whether an exception was caught, whether an exception was not caught, even if there's a return statement in the middle.

python
try:
    risky_operation()
except SomeError:
    handle_error()
finally:
    # ALWAYS runs, no matter what happened above
    cleanup()

What is finally used for? Cleanup that must happen regardless of success or failure: - Closing files - Closing database connections - Releasing network sockets - Releasing locks so other parts of the program aren't blocked

Analogy: a surgeon must close up the patient (stitch the incision) whether the operation succeeded or not. finally is that closing-up step; it's not optional.

python
print("Scenario: Exception caught")
try:
    print("try: doing work")
    result = 10 / 0
except ZeroDivisionError:
    print("except: caught ZeroDivisionError")
finally:
    print("finally: always runs!")
Scenario: Exception caught
try: doing work
except: caught ZeroDivisionError
finally: always runs!

Real-World Example: Database Connections

Every database operation in production must close its connection, even if an error occurs. Leaving connections open wastes server resources and can crash an app under heavy load. finally is the industry standard for guaranteeing cleanup:

python
class DatabaseConnection:
    def __init__(self, db_name):
        self.db_name = db_name
        self.is_open = False

    def connect(self):
        self.is_open = True
        print(f"[DB] Connected to '{self.db_name}'")

    def execute(self, query):
        if not self.is_open:
            raise RuntimeError("Cannot execute: connection is closed!")
        if "DROP" in query.upper():
            raise PermissionError("DROP operations require admin privileges!")
        print(f"[DB] Executed: {query}")
        return [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]

    def close(self):
        self.is_open = False
        print(f"[DB] Connection to '{self.db_name}' closed")


def run_query(query):
    db = DatabaseConnection("production_db")
    try:
        db.connect()
        results = db.execute(query)
        print(f"[DB] Got {len(results)} rows")
        return results
    except PermissionError as e:
        print(f"[ERROR] Permission denied: {e}")
        return []
    except Exception as e:
        print(f"[ERROR] Unexpected error: {e}")
        return []
    finally:
        db.close()   # ALWAYS close the connection, success or failure
        print("[DB] Resources freed\n")

run_query("SELECT * FROM users WHERE active=1")
run_query("DROP TABLE users")
[DB] Connected to 'production_db'
[DB] Executed: SELECT * FROM users WHERE active=1
[DB] Got 2 rows
[DB] Connection to 'production_db' closed
[DB] Resources freed

[DB] Connected to 'production_db'
[ERROR] Permission denied: DROP operations require admin privileges!
[DB] Connection to 'production_db' closed
[DB] Resources freed

No matter what happens inside try (success, a permission error, or any other failure), the connection is guaranteed to close.


8. The Complete Structure: try / except / else / finally

All four clauses together form the complete toolkit. Here's exactly when each runs:

python
try:
    [risky code]
except SomeError:
    [runs only if 'try' raises SomeError]
except AnotherError:
    [runs only if 'try' raises AnotherError]
else:
    [runs only if 'try' had NO exceptions]
finally:
    [ALWAYS runs: success or failure, caught or uncaught]

A simple way to remember it, read it as a story: - try: "Let me try this." - except: "If it goes wrong like THIS, I'll do THAT." - else: "If it went perfectly, here's what I'll do next." - finally: "Either way, I'll always clean up."

A first example tying it all together:

python
def safe_divide(a, b):
    try:
        result = a / b                        # risky operation
    except ZeroDivisionError:
        print("Error: Cannot divide by zero.")
    except TypeError as e:
        print(f"TypeError: {e}")
    else:
        print(f"Result: {result}")            # runs only if no exception
    finally:
        print("Execution complete.\n")        # always runs

safe_divide(10, 2)     # Success → else + finally run
safe_divide(10, 0)     # ZeroDivisionError → except + finally run
safe_divide(10, "x")   # TypeError → except + finally run
Result: 5.0
Execution complete.

Error: Cannot divide by zero.
Execution complete.

TypeError: unsupported operand type(s) for /: 'int' and 'str'
Execution complete.

A second example makes the pattern concrete with real file handling, which is where you'll use this structure constantly in practice:

python
def read_data(filepath):
    file = None
    try:
        file = open(filepath, "r")
        data = file.read()
    except FileNotFoundError:
        print(f"File '{filepath}' not found.")
    except PermissionError:
        print("Permission denied to read the file.")
    except Exception as e:
        print(f"Unexpected error: {e}")
    else:
        print("File read successfully!")
        print(data)
    finally:
        if file:
            file.close()           # always close the file, if it was ever opened
            print("File closed.")

read_data("missing.csv")   # FileNotFoundError → except runs, finally sees file=None
File 'missing.csv' not found.

Notice file = None is set before the try even starts. If open() itself is what fails, file never gets reassigned, so the finally block's if file: check correctly skips calling .close() on something that was never opened.


9. Raising Your Own Exceptions with raise

You don't have to wait for Python to detect a problem. You can raise an exception yourself using the raise keyword. This is how you enforce your own rules and signal that something is wrong.

python
raise ExceptionType("Your descriptive error message here")

When should you raise an exception? - Input validation: the input doesn't meet your requirements - Business rule violations: an operation isn't allowed - Impossible states: something that should never happen, did - API contracts: telling other developers they used your function wrong

Think of raise as throwing a red flag: "I've detected a problem I can't solve here, the caller needs to deal with it."

python
def set_age(age):
    if not isinstance(age, int):
        raise TypeError(f"Age must be an integer, got {type(age).__name__}")
    if age < 0:
        raise ValueError(f"Age cannot be negative, got {age}")
    if age > 150:
        raise ValueError(f"Age {age} is unrealistically high")
    print(f"Age set to {age}")

set_age(25)    # Works fine

for bad_input in [-5, 200, "twenty"]:
    try:
        set_age(bad_input)
    except (ValueError, TypeError) as e:
        print(f"Error for {repr(bad_input)}: {e}")
Age set to 25
Error for -5: Age cannot be negative, got -5
Error for 200: Age 200 is unrealistically high
Error for 'twenty': Age must be an integer, got str

Notice the line except (ValueError, TypeError) as e: you can catch multiple exception types in a single except by listing them in a tuple.


10. Re-Raising and Exception Chaining

Sometimes you want to react to an exception (log it, clean something up) without actually handling it, because the caller still needs to know something went wrong. This is where re-raising comes in.

Plain Re-Raise: A Bare raise

Writing raise on its own, with nothing after it, inside an except block re-raises the exact same exception that was just caught, unchanged:

python
def load_config(path):
    try:
        with open(path) as f:
            return f.read()
    except FileNotFoundError:
        print(f"Log: could not find config at {path}, notifying and re-raising")
        raise   # re-raises the SAME exception, unchanged

try:
    load_config("nonexistent_config.ini")
except FileNotFoundError as e:
    print(f"Caught at the top level: {e}")
Log: could not find config at nonexistent_config.ini, notifying and re-raising
Caught at the top level: [Errno 2] No such file or directory: 'nonexistent_config.ini'

This is the pattern for "I want to log or react to this locally, but I'm not the right place to actually decide what happens next."

Exception Chaining: raise ... from e

Sometimes the more useful move isn't re-raising the same exception, but raising a different, more meaningful one while keeping a record of what originally caused it. raise NewException(...) from original_exception does exactly that:

python
class ConfigError(Exception):
    pass

def load_settings(path):
    try:
        with open(path) as f:
            return f.read()
    except FileNotFoundError as e:
        raise ConfigError(f"Could not load settings from {path}") from e

try:
    load_settings("settings.ini")
except ConfigError as e:
    print(f"ConfigError: {e}")
    print(f"Original cause: {e.__cause__!r}")
ConfigError: Could not load settings from settings.ini
Original cause: FileNotFoundError(2, 'No such file or directory')

Why not just raise ConfigError on its own, without from e? Because Python still remembers the original exception even then, automatically, through a separate attribute called __context__ rather than __cause__. The difference is about intent, not information:

python
def load_settings_v2(path):
    try:
        with open(path) as f:
            return f.read()
    except FileNotFoundError:
        raise ConfigError(f"Could not load settings from {path}")   # no 'from e'

try:
    load_settings_v2("settings.ini")
except ConfigError as e:
    print(f"__cause__ (explicit, only set by 'from'): {e.__cause__}")
    print(f"__context__ (automatic, always set): {e.__context__!r}")
__cause__ (explicit, only set by 'from'): None
__context__ (automatic, always set): FileNotFoundError(2, 'No such file or directory')

Analogy: a doctor treating a patient's fever could just write "fever" on the chart. But writing "fever, caused by a bacterial infection" tells the next doctor exactly what to actually treat. raise ... from e is the deliberate, on-the-record version of "here's the deeper cause," while __context__ is Python quietly keeping notes in the background either way. Using from e explicitly is almost always the clearer choice when you're intentionally translating one exception into another.


11. The assert Statement

assert checks that a condition is true, and if it isn't, immediately raises an AssertionError. It's a sanity check you place in your own code to catch impossible situations early.

python
assert condition, "Optional message if the condition is False"
python
def set_temperature(celsius):
    assert celsius >= -273.15, f"Temperature {celsius}C is below absolute zero"
    print(f"Temperature set to {celsius}C")

set_temperature(20)

try:
    set_temperature(-300)
except AssertionError as e:
    print(f"AssertionError: {e}")
Temperature set to 20C
AssertionError: Temperature -300C is below absolute zero

Analogy: think of assert as a tripwire, not a security guard. A security guard (proper if/raise validation) is meant to stand at the door permanently, checking every single visitor, including ones you expect to occasionally be turned away. A tripwire is there to catch something that should genuinely never happen; if it trips, that means an assumption you were relying on elsewhere in the code turned out to be false.

Two things worth knowing before reaching for assert:

  1. Assertions can be stripped out entirely. Running Python with the -O (optimize) flag disables every assert statement in the program, as if they were never written. This means assert should never be used to validate untrusted input, like something a user typed or data from an API, because in an optimized build, that check would silently vanish. Use raise ValueError(...) (as in section 9) for anything that must always be checked.
  2. An assert with no message still raises AssertionError, just with an empty message, which can be confusing to debug:
python
try:
    assert 1 == 2
except AssertionError as e:
    print(f"Message: {str(e)!r}")
Message: ''

assert is best reserved for catching your own programming mistakes during development, like verifying a function's internal assumptions, not for handling real-world unpredictable input.


12. Custom Exceptions: Building Your Own Error Types

Python lets you create your own exception classes. This is one of the most professional and powerful features of exception handling.

Why create custom exceptions? - Clarity: InsufficientFundsError is far more descriptive than a generic ValueError - Control: callers can catch your specific error without accidentally catching everything else - Context: you can attach extra information (error codes, user IDs) to the exception - Separation: keep your application's errors distinct from Python's built-in ones

How to create one: at minimum, just inherit from Exception:

python
class MyError(Exception):
    pass

That's all it takes. Here's a practical example with two custom exceptions:

python
class NegativeValueError(Exception):
    """Raised when a value that must be positive is negative."""
    pass

class EmptyInputError(Exception):
    """Raised when required input is empty or None."""
    pass

def calculate_square_root(number):
    if number is None or number == "":
        raise EmptyInputError("Input cannot be None or empty")
    if number < 0:
        raise NegativeValueError(f"Cannot take square root of negative number: {number}")
    return number ** 0.5

for val in [16, 0, -9, None]:
    try:
        print(f"sqrt({val}) = {calculate_square_root(val)}")
    except EmptyInputError as e:
        print(f"EmptyInputError: {e}")
    except NegativeValueError as e:
        print(f"NegativeValueError: {e}")
sqrt(16) = 4.0
sqrt(0) = 0.0
NegativeValueError: Cannot take square root of negative number: -9
EmptyInputError: Input cannot be None or empty

Best practices for custom exceptions: - Always inherit from Exception (not BaseException) - End the class name with Error (e.g., PaymentFailedError) - Group related custom exceptions under a shared base class

You can also carry extra data by adding an __init__:

python
class DetailedError(Exception):
    def __init__(self, message, code=None):
        super().__init__(message)
        self.code = code   # Attach an extra error code

13. Logging Exceptions Like a Professional

print() is fine for a learning script, but real production systems use the built-in logging module instead. A crashed server at 3am doesn't have anyone watching a terminal; it has log files that someone will read later. Logging is how that later investigation becomes possible at all.

Analogy: think of logging as a plane's flight recorder. Nobody watches it during a normal flight, but if something goes wrong, it's the only reason anyone can reconstruct what actually happened.

python
import logging

logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
logger = logging.getLogger("payments")

def charge_card(amount):
    if amount <= 0:
        raise ValueError(f"Charge amount must be positive, got {amount}")
    return f"Charged ${amount}"

for amount in [49.99, -10]:
    try:
        result = charge_card(amount)
        logger.info(result)
    except ValueError:
        logger.exception("Failed to charge card")
INFO: Charged $49.99
ERROR: Failed to charge card
Traceback (most recent call last):
  File "...", line ..., in <module>
  File "...", line ..., in charge_card
ValueError: Charge amount must be positive, got -10

logger.exception(...) is worth calling out specifically: called from inside an except block, it automatically attaches the full traceback to the log message, at ERROR level, without you having to build that traceback string yourself. That single line gives whoever reads the logs later the exact same information they'd have seen if the program had crashed in front of them, except the program didn't crash, it kept running.


14. Two More Advanced Tools

contextlib.suppress: Ignoring an Exception on Purpose

Sometimes an exception genuinely doesn't matter, and writing a full try/except: pass for it feels like more ceremony than the situation deserves. contextlib.suppress shrinks that pattern down to one line:

python
from contextlib import suppress
import os

# Without suppress, you'd need:
try:
    os.remove("a_file_that_does_not_exist.txt")
except FileNotFoundError:
    pass

# With suppress, the same intent in one line:
with suppress(FileNotFoundError):
    os.remove("a_file_that_does_not_exist.txt")

print("Program continues normally either way")
Program continues normally either way

It only suppresses exactly the exception types you list, nothing else:

python
try:
    with suppress(FileNotFoundError):
        raise ValueError("this should NOT be suppressed")
except ValueError as e:
    print(f"Correctly NOT suppressed: {e}")
Correctly NOT suppressed: this should NOT be suppressed

Reach for suppress when "this specific failure is completely fine to ignore" is a real, deliberate decision, not a way to quietly hide bugs you haven't gotten around to fixing.

sys.exc_info(): Inspecting the Current Exception

sys.exc_info() returns a tuple of three things about the exception currently being handled: (type, value, traceback). It's mostly used in frameworks and low-level tools that need to inspect errors generically. (When no exception is active, it returns (None, None, None).)

python
import sys

def generic_error_reporter(func, *args):
    try:
        return func(*args)
    except Exception:
        exc_type, exc_value, exc_tb = sys.exc_info()
        print(f"Exception type    : {exc_type.__name__}")
        print(f"Exception message : {exc_value}")
        print(f"At line           : {exc_tb.tb_lineno}")
        return None

def risky_operation(value):
    if value == 0:
        raise ZeroDivisionError("Cannot process zero")
    return 100 / value

generic_error_reporter(risky_operation, 0)
Exception type    : ZeroDivisionError
Exception message : Cannot process zero
At line           : ...

For most everyday code, the cleaner approach is simply except Exception as e and inspecting e directly. Reach for sys.exc_info() only when you're building generic tooling like decorators or error-logging frameworks.


Your Exception Handling Cheat Sheet

The Three Types of Errors

Type When it happens Handleable?
Syntax Error Before running No
Logical Error During running (wrong output) Only by you / tests
Runtime Error (Exception) During running (crash) Yes, with try/except

The Complete Structure

python
try:
    result = risky_operation()      # Only the risky code here
except SpecificError as e:
    handle_specific(e)              # Runs ONLY for SpecificError
except (ErrorA, ErrorB) as e:
    handle_multiple(e)              # Runs for ErrorA OR ErrorB
else:
    use_result(result)              # Runs ONLY if try had no exception
finally:
    cleanup()                       # ALWAYS runs, no matter what

Raising, Re-Raising, and Creating Exceptions

python
# Raise a built-in exception
raise ValueError("Something is wrong")

# Re-raise the exact same exception from inside an except block
raise

# Raise a new exception, but keep a record of what caused it
raise ConfigError("Could not load settings") from original_error

# A sanity check for conditions that should never happen (not for user input)
assert some_condition, "This should never be False"

# Create a custom exception
class MyError(Exception):
    pass

# Custom exception with extra data
class DetailedError(Exception):
    def __init__(self, message, code=None):
        super().__init__(message)
        self.code = code

Key Hierarchy Relationships

Exception
├── ValueError, TypeError, NameError, AttributeError
├── ArithmeticError → ZeroDivisionError, OverflowError
├── LookupError    → IndexError, KeyError
├── OSError        → FileNotFoundError, PermissionError, TimeoutError
├── RuntimeError   → RecursionError
└── AssertionError (raised by 'assert')

Exception handling is what separates fragile scripts from robust, production-ready software. A beginner's program crashes the moment anything unexpected happens. A professional's program anticipates failure, catches it, logs it, cleans up after itself, and keeps running. Master try, except, else, and finally, learn when to raise and re-raise your own errors, and you'll write code that's ready for the messy, unpredictable real world.

A beginner's program crashes the moment anything unexpected happens. A professional's program anticipates failure, catches it, logs it, cleans up after itself, and keeps running. That shift, from hoping nothing goes wrong to planning for what happens when it does, is what this whole guide has been building toward.