Python Lesson 20 – Exception Handling| Dataplexa

Exception Handling

Every program you write will eventually run into something unexpected — a user types letters where you expected a number, a file doesn't exist, a network connection drops. Without a plan for those moments, your program crashes with an ugly error message and stops completely. Exception handling is Python's built-in system for catching those problems, responding to them gracefully, and keeping your program running.

This lesson builds from the ground up: what exceptions are, why they exist, how to catch and raise them, and how to write production-quality error-handling code used by professional developers every day.


What Is an Exception?

An exception is an event that disrupts the normal flow of a program. When Python encounters an operation it cannot complete — dividing by zero, opening a missing file, converting invalid input — it raises an exception object that carries information about what went wrong.

If nothing catches that exception, the program terminates and Python prints a traceback — a chain of messages showing exactly where the failure happened and what type of error it was.

Common built-in exception types you will see constantly:

  • ValueError — right type, wrong value (e.g., int("hello"))
  • TypeError — wrong type entirely (e.g., "5" + 5)
  • ZeroDivisionError — dividing any number by zero
  • FileNotFoundError — opening a file that does not exist
  • IndexError — accessing a list index that is out of range
  • KeyError — accessing a dictionary key that does not exist
  • AttributeError — calling a method that doesn't exist on an object
  • NameError — using a variable that was never defined

The try / except Block

The core tool is the try / except block. Code that might fail goes inside try. If an exception is raised, Python jumps immediately to the matching except block instead of crashing.

Why it exists: programs in the real world interact with data they cannot fully control — user input, files, APIs, databases. Exception handling lets you define what happens when things go wrong instead of leaving it to chance.

Real-world use: an online store checkout form catches a ValueError when a user accidentally types letters into the quantity field and shows a friendly message rather than a server error page.

# Basic try/except — catching a ValueError
# Ask the user for a number and safely convert it

raw = input("Enter a number: ")   # get raw text from user

try:
    n = int(raw)                  # attempt conversion to integer
    print("You entered:", n)      # runs only if conversion succeeds
except ValueError:
    print("That wasn't a valid number.")   # runs if int() fails
If user types "42":
You entered: 42

If user types "hello":
That wasn't a valid number.
  • Code inside try runs normally until an exception occurs
  • When an exception matches the except type, that block runs instead
  • If no exception occurs, the except block is skipped entirely
  • The program continues after the try/except block in both cases

Catching Multiple Exception Types

A single try block can raise different kinds of errors. You can handle each one separately with multiple except clauses, or group them together inside a tuple.

Why it exists: different errors often require different responses. A missing file needs a different message than bad user input.

Real-world use: a data import tool catches FileNotFoundError to tell users the file path is wrong, and catches ValueError to tell them the file's contents are in the wrong format — two separate, helpful messages.

# Handling multiple exception types separately

def divide(a, b):
    try:
        result = a / b             # could raise ZeroDivisionError
        return result
    except ZeroDivisionError:
        print("Cannot divide by zero.")
        return None
    except TypeError:              # catches e.g. divide("x", 2)
        print("Both inputs must be numbers.")
        return None

print(divide(10, 2))    # normal case
print(divide(10, 0))    # ZeroDivisionError
print(divide("x", 2))   # TypeError
5.0
Cannot divide by zero.
None
Both inputs must be numbers.
None
  • Python checks except clauses top to bottom and runs the first match
  • Only one except block runs per exception — not all of them
  • You can group types: except (ValueError, TypeError): handles both the same way
  • Always list specific exceptions before broad ones

Accessing the Exception Object

Using as e in your except clause binds the exception object to a variable. That object holds the error message and type, which you can log or display.

Why it exists: the exception object contains specific details about what went wrong — essential for debugging and for writing informative error messages in applications.

Real-world use: a payment processing service logs the exact exception message to a file so developers can diagnose failures without exposing technical details to customers.

# Using "as e" to capture the exception message

items = [10, 20, 30]    # a list with three items

try:
    val = items[9]       # index 9 does not exist — IndexError
except IndexError as e:
    print("Error caught:", e)           # print the exception message
    print("Type:", type(e).__name__)    # print the exception class name
Error caught: list index out of range
Type: IndexError
  • e is just a conventional variable name — you can use any name
  • str(e) or print(e) shows the human-readable error message
  • type(e).__name__ gives you the exception class name as a string
  • This is extremely useful for logging systems in production code

The else and finally Clauses

Python's try block has two optional extra clauses that give you precise control over what happens when things succeed and what always runs regardless.

  • else — runs only if the try block completed without raising any exception
  • finally — runs always, whether an exception occurred or not — perfect for cleanup

Why it exists: separating "success" logic into else keeps try blocks minimal and focused. finally guarantees cleanup — closing files, releasing locks, disconnecting from databases — even when errors occur.

Real-world use: a database query function uses finally to close the database connection whether the query succeeded or failed, preventing connection leaks.

# Full try / except / else / finally structure

def read_score(val):
    try:
        score = int(val)           # attempt conversion
    except ValueError:
        print("Invalid input — not a number.")
    else:
        # only runs if try succeeded (no exception)
        print("Score accepted:", score)
    finally:
        # always runs — success or failure
        print("--- validation complete ---")

read_score("85")     # succeeds
print()
read_score("abc")    # fails
Score accepted: 85
--- validation complete ---

Invalid input — not a number.
--- validation complete ---
  • else only runs when try completes with zero exceptions
  • finally runs in every case — including when an unhandled exception propagates upward
  • Even if a return statement is inside try, finally still executes before returning
  • The order is always: tryexcept (if error) → else (if no error) → finally

Raising Exceptions

You are not limited to catching exceptions Python raises — you can raise your own using the raise keyword. This lets you enforce rules and signal errors from inside your own functions.

Why it exists: sometimes Python won't raise an error even though the input is logically wrong for your application. Raising exceptions lets you define and communicate your own rules clearly.

Real-world use: an age verification function raises a ValueError if the submitted age is negative or above 120 — values that are technically valid integers but logically impossible.

# Raising exceptions to enforce business rules

def set_age(age):
    if not isinstance(age, int):        # must be an integer
        raise TypeError("Age must be an integer.")
    if age < 0 or age > 120:            # must be a realistic range
        raise ValueError(f"Age {age} is out of valid range (0-120).")
    return age

try:
    print(set_age(25))     # valid
    print(set_age(-5))     # triggers ValueError
except ValueError as e:
    print("ValueError:", e)
except TypeError as e:
    print("TypeError:", e)
25
ValueError: Age -5 is out of valid range (0-120).
  • raise ExceptionType("message") is the standard syntax
  • You can raise any built-in exception type with a custom message
  • Raised exceptions propagate up the call stack until something catches them
  • If nothing catches a raised exception, the program terminates with a traceback

Custom Exception Classes

For larger programs, you can define your own exception types by creating a class that inherits from Exception. This lets callers catch your specific error type without confusing it with built-in exceptions.

Why it exists: built-in exceptions are generic. Custom exceptions carry meaning specific to your application, making error handling self-documenting and easier to maintain.

Real-world use: a payment library raises InsufficientFundsError instead of a generic ValueError so the calling code can catch that exact condition and show the appropriate checkout message.

# Defining and raising a custom exception class

class InsufficientFundsError(Exception):
    """Raised when a withdrawal exceeds the account balance."""
    pass   # inherits everything from Exception — no extra code needed

def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFundsError(
            f"Cannot withdraw ${amount:.2f} — balance is only ${balance:.2f}."
        )
    return balance - amount     # return new balance if successful

try:
    new_bal = withdraw(50.00, 80.00)   # attempt to overdraw
    print("New balance:", new_bal)
except InsufficientFundsError as e:
    print("Transaction failed:", e)
Transaction failed: Cannot withdraw $80.00 — balance is only $50.00.
  • Custom exceptions inherit from Exception (or another exception class)
  • pass is enough for a basic custom exception — the message comes from raise
  • You can add attributes (like error codes) by overriding __init__
  • Name custom exceptions with the Error suffix by Python convention

Re-raising Exceptions

Sometimes you want to catch an exception, do something with it (like log it), and then let it continue propagating up the call stack. Use a bare raise inside an except block to re-raise the current exception.

# Re-raising an exception after logging it

def load_config(path):
    try:
        f = open(path)          # try to open a config file
        data = f.read()
        f.close()
        return data
    except FileNotFoundError as e:
        print(f"[LOG] Config file missing: {e}")   # log the problem
        raise                                        # re-raise same exception

try:
    load_config("settings.cfg")
except FileNotFoundError:
    print("Application cannot start without a config file.")
[LOG] Config file missing: [Errno 2] No such file or directory: 'settings.cfg'
Application cannot start without a config file.
  • Bare raise (no argument) re-raises the currently active exception unchanged
  • This preserves the original traceback — essential for debugging
  • Useful in middleware, logging layers, and decorator functions
  • Contrast with raise NewError(...) from e which chains exceptions together

Exception Handling Best Practices

Professional developers follow a set of rules that keep exception handling clean, readable, and safe. Understanding these will immediately elevate your code quality.

  • Never use bare except: — it silently catches everything including KeyboardInterrupt and SystemExit, masking serious problems. Always name the exception type.
  • Keep try blocks small — only wrap the specific line(s) that can fail, not entire functions. Smaller scope means clearer intent and fewer masked bugs.
  • Don't suppress exceptions silently — an empty except block that does nothing hides real problems. At minimum, log the error.
  • Use finally for resource cleanup — or better yet, use with statements (context managers, covered in Lesson 37) which handle cleanup automatically.
  • Raise the most specific exception typeValueError is more useful than Exception. Custom exceptions are more useful still.
# Best practice: small try block, specific exception, logging

import logging

def parse_price(text):
    try:
        price = float(text)            # ONLY this line can fail
    except ValueError:
        logging.warning("Invalid price input: %s", text)   # log it
        return 0.0                     # return safe default
    return price

print(parse_price("19.99"))   # valid price string
print(parse_price("free"))    # invalid — returns 0.0 safely
19.99
0.0

Summary Table

Keyword / Concept Purpose When It Runs
try Wraps code that might raise an exception Always — first
except Catches a specific exception type and handles it Only when matching exception is raised
else Runs success logic separate from try Only when try completes with no exception
finally Cleanup code — always executes Always — even if exception is unhandled
raise Triggers an exception manually When you call it explicitly
as e Binds exception object to a variable Inside except clause
Custom Exception App-specific error type via class inheritance When raised explicitly in your code

Practice Questions

Practice 1. What keyword do you use to wrap code that might raise an exception?



Practice 2. What exception type is raised when you call int("hello")?



Practice 3. Which clause in a try block runs only when no exception is raised?



Practice 4. What syntax do you use inside an except clause to bind the exception object to the variable e?



Practice 5. To create a custom exception class, what built-in class must it inherit from?




Quiz

Quiz 1. What happens if an exception is raised inside a try block and no matching except clause exists?






Quiz 2. Which clause is guaranteed to run regardless of whether an exception occurred?






Quiz 3. Which of the following is the correct way to raise a ValueError with a custom message?






Quiz 4. What does a bare raise statement (with no argument) do inside an except block?






Quiz 5. Which of the following is considered best practice when writing exception handling code?