Python: Zero to Hero
Home/Python Essentials
Share

Chapter 13: Error Handling — try, except, finally

Every program you've written so far assumes the best. The file exists. The user types a number when you ask for one. The network is up. The data is clean.

Reality is messier. Files get deleted. Users type letters when you expect numbers. Servers go down. Data arrives corrupted. In the real world, things go wrong constantly — and the programs that survive are the ones written to handle it.

Python gives you a clean, readable way to deal with errors: the try / except block. Instead of letting your program crash, you catch the error, deal with it, and keep going — or fail gracefully with a useful message.

This is one of the most important chapters in the book. Error handling is what separates code that works in a demo from code that works in production.

Why Programs Crash

When Python encounters a problem it can't resolve, it raises an exception — an error object that describes what went wrong. If nothing catches it, the program stops and prints a traceback.

You've already seen several:

print(10 / 0)
# ZeroDivisionError: division by zero

int("hello")
# ValueError: invalid literal for int() with base 10: 'hello'

name = "Alice"
print(name[100])
# IndexError: string index out of range

d = {"a": 1}
print(d["b"])
# KeyError: 'b'

open("missing_file.txt")
# FileNotFoundError: [Errno 2] No such file or directory: 'missing_file.txt'

Each error has a type (the class name before the colon) and a message (what comes after). Both give you information about what went wrong and where.

The key insight: exceptions are not disasters. They're Python's way of saying "I hit a situation I can't handle on my own. Can you?" With try / except, you can.

try and except — The Basic Pattern

Wrap the risky code in a try block. If an exception occurs, execution jumps immediately to the matching except block. If no exception occurs, the except block is skipped.

try:
    number = int(input("Enter a number: "))
    print(f"You entered: {number}")
except ValueError:
    print("That's not a valid number. Please try again.")

print("Program continues.")

Sample run 1 — user types 42:

Enter a number: 42
You entered: 42
Program continues.

Sample run 2 — user types hello:

Enter a number: hello
That's not a valid number. Please try again.
Program continues.

Without the try / except, the second run would crash with a traceback and never reach the last line. With it, the program handles the problem and keeps going.

The flow:

  1. Python tries the try block line by line.
  2. If an exception occurs, it stops there and jumps to the matching except.
  3. If no exception occurs, it skips all except blocks.
  4. Either way, code after the try / except continues running.

Common Exception Types

Python has dozens of built-in exception types. Here are the ones you'll encounter most:

Exception When it occurs
ValueError Wrong type of value (e.g. int("hello"))
TypeError Wrong type entirely (e.g. "hi" + 5)
ZeroDivisionError Division or modulo by zero
IndexError List/string index out of range
KeyError Dictionary key doesn't exist
FileNotFoundError File or directory doesn't exist
PermissionError No permission to read/write a file
AttributeError Object doesn't have that attribute/method
NameError Variable or function name not defined
RecursionError Too many nested function calls
StopIteration Iterator is exhausted
OverflowError Number too large for a float
MemoryError Not enough memory
OSError General operating system error (parent of FileNotFoundError, PermissionError, etc.)

Catching Specific Exceptions

Catch specific exception types so you can respond appropriately to each situation.

def safe_divide(a, b):
    try:
        result = a / b
        return result
    except ZeroDivisionError:
        print("Error: Cannot divide by zero.")
        return None
    except TypeError:
        print(f"Error: Cannot divide {type(a).__name__} by {type(b).__name__}.")
        return None

print(safe_divide(10, 2))    # 5.0
print(safe_divide(10, 0))    # Error: Cannot divide by zero. -> None
print(safe_divide(10, "x"))  # Error: Cannot divide int by str. -> None

Catching multiple exceptions in one block

try:
    value = int(input("Enter a number: "))
    result = 100 / value
    print(f"100 / {value} = {result}")
except (ValueError, ZeroDivisionError):
    print("Please enter a non-zero integer.")

Use a tuple of exception types when the same response is appropriate for multiple errors.

Catching all exceptions with Exception

try:
    risky_operation()
except Exception as e:
    print(f"Something went wrong: {e}")

Exception is the base class for almost all built-in exceptions. Catching it handles anything unexpected. The as e gives you access to the exception object so you can print its message.

Important: Be specific when you can. Catching Exception too broadly can hide bugs — you catch an error you didn't expect and your program silently does the wrong thing.

# Too broad — catches everything, even bugs you didn't anticipate
try:
    process_data(data)
except Exception:
    pass   # <- never do this silently

# Better — catch what you expect, let unexpected errors propagate
try:
    process_data(data)
except ValueError as e:
    print(f"Bad data: {e}")
except FileNotFoundError:
    print("Data file missing.")

The as Keyword — Accessing the Exception Object

Add as e (or any name) after the exception type to capture the exception object itself.

try:
    with open("data.txt", "r") as file:
        content = file.read()
except FileNotFoundError as e:
    print(f"File error: {e}")
    print(f"Error type: {type(e).__name__}")

Output:

File error: [Errno 2] No such file or directory: 'data.txt'
Error type: FileNotFoundError

The exception object's string representation is its message. type(e).__name__ gives you the class name as a string.

else — Runs Only When No Exception Occurred

The else block runs if the try block completed without raising any exception.

try:
    number = int(input("Enter a number: "))
except ValueError:
    print("That's not a number.")
else:
    # Only runs if no ValueError was raised
    print(f"Success! You entered {number}.")
    squared = number ** 2
    print(f"Its square is {squared}.")

Sample run — user types 7:

Enter a number: 7
Success! You entered 7.
Its square is 49.

Sample run — user types abc:

Enter a number: abc
That's not a number.

Why use else instead of just putting the code at the end of try? Because any exception inside else won't be caught by the except blocks above it — which is usually what you want. The try block should only contain the risky code, not the code that depends on success.

finally — Always Runs

The finally block runs no matter what — whether an exception occurred or not, whether it was caught or not. It's for cleanup code that must always execute.

def read_file(filename):
    file = None
    try:
        file = open(filename, "r")
        content = file.read()
        return content
    except FileNotFoundError:
        print(f"File '{filename}' not found.")
        return None
    finally:
        if file:
            file.close()
            print("File closed.")   # always runs

With with open(...), you usually don't need finally for files — the context manager handles it. But finally is essential for:

  • Closing database connections
  • Releasing locks
  • Cleaning up temporary files
  • Logging that an operation ended (regardless of success)
import time

def timed_operation():
    start = time.time()
    try:
        print("Starting operation...")
        result = some_risky_function()
        return result
    except Exception as e:
        print(f"Operation failed: {e}")
        return None
    finally:
        elapsed = time.time() - start
        print(f"Operation took {elapsed:.3f} seconds.")   # always prints

The full structure:

try:
    # risky code
except SomeError:
    # handle specific error
except AnotherError as e:
    # handle another error
else:
    # runs only if no exception
finally:
    # always runs

You don't need all four every time. Use what the situation calls for.

raise — Raising Your Own Exceptions

You can raise exceptions yourself using the raise keyword. This is how you signal that something has gone wrong in your own code.

def set_age(age):
    if not isinstance(age, int):
        raise TypeError(f"Age must be an integer, got {type(age).__name__}.")
    if age < 0 or age > 150:
        raise ValueError(f"Age {age} is out of the valid range (0--150).")
    return age

try:
    set_age(25)    # fine
    set_age(-5)    # raises ValueError
except ValueError as e:
    print(f"Invalid age: {e}")

Output:

Invalid age: Age -5 is out of the valid range (0--150).

Raising exceptions from your own functions is how you enforce rules. Instead of returning None and hoping the caller checks, you raise an exception that forces the caller to deal with the problem.

Re-raising an exception

Sometimes you want to catch an exception, do something (like log it), and then let it propagate as if you never caught it.

import logging

def process_payment(amount):
    try:
        result = charge_card(amount)
        return result
    except Exception as e:
        logging.error(f"Payment failed for amount {amount}: {e}")
        raise   # re-raise the same exception

raise with no arguments inside an except block re-raises the current exception. The caller will still see the exception — you just got a chance to log it first.

Creating Custom Exceptions

For larger programs, you'll want your own exception classes. This lets callers catch your specific errors separately from generic Python errors.

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

    def __init__(self, balance, amount):
        self.balance = balance
        self.amount = amount
        super().__init__(
            f"Cannot withdraw ${amount:.2f}. Balance is only ${balance:.2f}."
        )


class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit amount must be positive.")
        self.balance += amount

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("Withdrawal amount must be positive.")
        if amount > self.balance:
            raise InsufficientFundsError(self.balance, amount)
        self.balance -= amount
        return amount


account = BankAccount("Alice", 100.0)

try:
    account.withdraw(150.0)
except InsufficientFundsError as e:
    print(f"Transaction declined: {e}")
    print(f"  Balance: ${e.balance:.2f}")
    print(f"  Requested: ${e.amount:.2f}")
except ValueError as e:
    print(f"Invalid amount: {e}")

Output:

Transaction declined: Cannot withdraw $150.00. Balance is only $100.00.
  Balance: $100.00
  Requested: $150.00

Custom exceptions carry extra information (here, balance and amount) that callers can use to respond intelligently. You'll see this pattern in every serious Python library.

Exception Hierarchy

Python exceptions form a hierarchy. Every exception ultimately inherits from BaseException. Most inherit from Exception.

BaseException
├── SystemExit            <- sys.exit() raises this
├── KeyboardInterrupt     <- Ctrl+C raises this
└── Exception             <- catch this for normal errors
    ├── ValueError
    ├── TypeError
    ├── OSError
    │   ├── FileNotFoundError
    │   └── PermissionError
    ├── IndexError
    ├── KeyError
    ├── ZeroDivisionError
    ├── AttributeError
    ├── NameError
    └── ... (many more)

When you catch a parent class, you catch all its children too:

try:
    open("missing.txt")
except OSError:
    print("An OS error occurred.")   # catches FileNotFoundError too

This matters when you want to handle a family of errors together without listing every possible subtype.

Practical Patterns

Pattern 1: Retry loop

Keep trying until the user gives valid input:

def get_integer(prompt, min_val=None, max_val=None):
    """Keep asking until the user enters a valid integer in the given range."""
    while True:
        try:
            value = int(input(prompt))
        except ValueError:
            print("Please enter a whole number.")
            continue

        if min_val is not None and value < min_val:
            print(f"Please enter a number >= {min_val}.")
        elif max_val is not None and value > max_val:
            print(f"Please enter a number <= {max_val}.")
        else:
            return value

age = get_integer("Enter your age: ", min_val=0, max_val=120)
print(f"Your age is {age}.")

Sample run:

Enter your age: abc
Please enter a whole number.
Enter your age: -5
Please enter a number >= 0.
Enter your age: 200
Please enter a number <= 120.
Enter your age: 28
Your age is 28.

Pattern 2: Try multiple approaches

def load_config(primary="config.json", fallback="config_default.json"):
    """Try the primary config, fall back to the default if missing."""
    for path in (primary, fallback):
        try:
            with open(path) as f:
                print(f"Loaded config from: {path}")
                return f.read()
        except FileNotFoundError:
            print(f"Not found: {path}")

    raise FileNotFoundError("No config file found.")

Pattern 3: Context manager for cleanup

from contextlib import suppress

# suppress is a context manager that silently ignores specific exceptions
with suppress(FileNotFoundError):
    import os
    os.remove("temp_file.txt")   # no crash if file doesn't exist

contextlib.suppress is a clean alternative to a full try / except / pass when you genuinely want to ignore a specific error.

Best Practices

1. Be specific — catch only what you expect.

# Bad
try:
    result = process(data)
except Exception:
    pass

# Good
try:
    result = process(data)
except ValueError as e:
    log_error(e)
    result = default_value

2. Never silently swallow exceptions.

# Bad — hides bugs
except Exception:
    pass

# Good — at minimum, log it
except Exception as e:
    logger.error(f"Unexpected error: {e}")
    raise

3. Keep try blocks short. Only wrap the line(s) that might fail, not the entire function.

# Bad — too broad
try:
    data = load_file(path)
    processed = transform(data)
    save_result(processed)
except Exception:
    print("Something failed.")

# Good — specific
try:
    data = load_file(path)
except FileNotFoundError:
    print(f"File not found: {path}")
    return

processed = transform(data)
save_result(processed)

4. Use finally for cleanup, not just error handling.

5. Create custom exceptions for your domain logic. Generic ValueError is fine for basic validation. For complex business rules, a named exception makes your code self-documenting.

Putting It All Together: A Robust CSV Loader

import csv
from pathlib import Path


class CSVError(Exception):
    """Raised when a CSV file cannot be loaded or parsed."""


def load_students(filepath):
    """
    Load student data from a CSV file.

    Returns a list of dicts with 'name', 'grade', 'city' keys.
    Raises CSVError if the file can't be read or has bad data.
    """
    path = Path(filepath)

    if not path.exists():
        raise CSVError(f"File not found: {filepath}")

    if path.suffix.lower() != ".csv":
        raise CSVError(f"Expected a .csv file, got: {path.suffix}")

    students = []
    try:
        with open(path, "r", encoding="utf-8") as file:
            reader = csv.DictReader(file)

            required_fields = {"name", "grade", "city"}
            if not required_fields.issubset(reader.fieldnames or set()):
                raise CSVError(
                    f"CSV missing required columns. "
                    f"Expected: {required_fields}, "
                    f"Got: {set(reader.fieldnames or [])}"
                )

            for line_num, row in enumerate(reader, start=2):
                try:
                    grade = float(row["grade"])
                except ValueError:
                    raise CSVError(
                        f"Line {line_num}: invalid grade '{row['grade']}' "
                        f"for student '{row['name']}'."
                    )
                students.append({
                    "name": row["name"].strip(),
                    "grade": grade,
                    "city": row["city"].strip(),
                })

    except PermissionError:
        raise CSVError(f"Permission denied: cannot read {filepath}")

    return students


def print_report(students):
    """Print a formatted student report."""
    print(f"{'Name':<15} {'Grade':>6} {'City':<12}")
    print("-" * 35)
    for s in students:
        print(f"{s['name']:<15} {s['grade']:>6.1f} {s['city']:<12}")
    grades = [s["grade"] for s in students]
    print("-" * 35)
    print(f"{'Average':<15} {sum(grades)/len(grades):>6.1f}")
    print(f"{'Highest':<15} {max(grades):>6.1f}")
    print(f"{'Lowest':<15} {min(grades):>6.1f}")


# Main program
try:
    students = load_students("students.csv")
    print_report(students)
except CSVError as e:
    print(f"Could not load student data:\n  {e}")
except Exception as e:
    print(f"Unexpected error: {e}")
    raise

A real-world pattern: a custom exception, specific error messages at each failure point, short try blocks, and a clean separation between error handling and program logic.

What You Learned in This Chapter

  • Exceptions are Python's way of signaling problems — they're not failures, they're information.
  • try / except catches exceptions and lets your program respond instead of crash.
  • Catch specific exception types (ValueError, FileNotFoundError, etc.) rather than bare Exception.
  • as e captures the exception object — use str(e) or type(e).__name__ for details.
  • else runs only when no exception occurred — for code that depends on success.
  • finally always runs — for cleanup that must happen regardless of outcome.
  • raise ExceptionType("message") signals a problem from your own code.
  • Bare raise inside an except block re-raises the current exception.
  • Create custom exceptions by subclassing Exception — carry domain-specific information.
  • Never silently swallow exceptions with pass — at minimum, log them.
  • Keep try blocks short — only wrap the risky line(s).

What's Next?

You can now write programs that handle failure gracefully. The next chapter takes a different direction — not error handling, but elegance.

In Chapter 14 you'll master comprehensions: list comprehensions, dictionary comprehensions, set comprehensions, and generator expressions. You've seen list comprehensions in Chapter 6 and dictionary comprehensions in Chapter 12. Now you'll learn all the forms, understand when to use them vs. loops, and see how they can make your code dramatically shorter and clearer.

Your turn: Write a function called safe_open_json(filepath) that tries to open and parse a JSON file (import json, then json.load(file)). Handle three specific errors: FileNotFoundError (file doesn't exist), json.JSONDecodeError (file isn't valid JSON), and PermissionError (no read access). Return the parsed data on success or None on failure, printing a specific message for each error type.

© 2026 Abhilash Sahoo. Python: Zero to Hero.