My Notes

Python Exception Handling - The Complete Breakdown

Updated 6/29/202612 min readDownload PDFEdit

Python Exception Handling — The Complete Breakdown

So what even is an exception?

Your code is running fine. Then something goes wrong — a user types text where you expected a number, a file doesn't exist, you divide by zero, a network connection drops. Python hits that line and has no idea what to do with it.

So it raises an exception. That's Python's way of saying "something broke and I'm stopping here." If you don't handle it, the entire program crashes with a red error message and a traceback. Every line after the crash never runs.

Exception handling is how you take back control. Instead of letting the program die, you say "if something goes wrong here, do this instead." The program keeps running. The user gets a real message. You stay in charge.


The basic structure — try / except

try:
    result = 10 / 0
except ZeroDivisionError:
    print("Can't divide by zero.")

Output:

Can't divide by zero.

Without the try/except, this crashes:

ZeroDivisionError: division by zero

With it, Python tries the code in try. The moment it hits an error, it jumps straight to except and runs that block instead. The crash never happens.


finally — the block that always runs

finally runs no matter what — whether the try succeeded, whether an exception was caught, whether you're about to crash. It's for cleanup code that must happen regardless.

try:
    number = int("hello")
except ValueError:
    print("That's not a number.")
finally:
    print("This always runs.")

Output:

That's not a number.
This always runs.

Even if no error occurs:

try:
    number = int("42")
    print(f"Got: {number}")
except ValueError:
    print("That's not a number.")
finally:
    print("This always runs.")

Output:

Got: 42
This always runs.

finally is where you close files, disconnect from databases, release resources. Things that must happen even if everything explodes.


else — runs only if no exception occurred

else in a try block is the opposite of except. It runs only when the try block succeeded with no errors.

try:
    number = int("42")
except ValueError:
    print("Invalid input.")
else:
    print(f"Success! Got {number}.")
finally:
    print("Done.")

Output:

Success! Got 42.
Done.

With bad input:

try:
    number = int("hello")
except ValueError:
    print("Invalid input.")
else:
    print(f"Success! Got {number}.")
finally:
    print("Done.")

Output:

Invalid input.
Done.

The else block was skipped because an exception occurred. The full structure: tryexcept (if error) → else (if no error) → finally (always).


Catching specific exceptions

Always catch the most specific exception you can. Catching everything blindly hides real bugs.

try:
    value = int(input("Enter a number: "))
    result = 100 / value
except ValueError:
    print("That wasn't a number.")
except ZeroDivisionError:
    print("Can't divide by zero.")

If the user types "hello"ValueError gets caught. If the user types 0ZeroDivisionError gets caught. If the user types 5 → no exception, runs clean.

Python checks except blocks in order — top to bottom — and stops at the first match.


Catching multiple exceptions in one line

try:
    value = int(input("Enter a number: "))
    result = 100 / value
except (ValueError, ZeroDivisionError):
    print("Invalid input — enter a non-zero number.")

Both exceptions handled by one block. Use this when the response is the same regardless of which error occurred.


Catching any exception — Exception

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

Exception is the base class for almost all built-in errors. Catching it catches nearly everything. The as e part stores the actual error object so you can inspect it.

try:
    result = 10 / 0
except Exception as e:
    print(f"Error type: {type(e).__name__}")
    print(f"Error message: {e}")

Output:

Error type: ZeroDivisionError
Error message: division by zero

Use this sparingly. Broad catches hide bugs. Be as specific as you can — catch Exception only as a last resort or for logging.


The exception hierarchy — how Python organises errors

Python's exceptions are a tree. Every exception inherits from BaseException. Most inherit from Exception. Catching a parent class catches all its children.

BaseException
├── SystemExit
├── KeyboardInterrupt
└── Exception
    ├── ValueError
    ├── TypeError
    ├── ZeroDivisionError
    ├── IndexError
    ├── KeyError
    ├── FileNotFoundError
    ├── AttributeError
    ├── ImportError
    ├── OSError
    └── ... (dozens more)

Never catch BaseException — it swallows KeyboardInterrupt (Ctrl+C) and SystemExit, which means your program won't even respond to being killed.


The most common built-in exceptions

ExceptionWhen it happensExample
ValueErrorRight type, wrong valueint("hello")
TypeErrorWrong type entirely"5" + 5
ZeroDivisionErrorDivide by zero10 / 0
IndexErrorList index out of range[1,2,3][10]
KeyErrorDict key doesn't existd["missing"]
FileNotFoundErrorFile doesn't existopen("nope.txt")
AttributeErrorMethod/attribute doesn't exist"hello".push("x")
ImportErrorModule not foundimport nonexistent
NameErrorVariable not definedprint(undefined_var)
RecursionErrorInfinite recursionFunction calling itself forever
PermissionErrorNo access rightsReading a protected file
TimeoutErrorOperation took too longNetwork request timeout
OverflowErrorNumber too largeFloat math overflow
MemoryErrorOut of memoryAllocating too much data

Raising exceptions — throwing your own errors

You can raise exceptions yourself with raise. This is how you enforce rules in your own code.

def set_age(age):
    if age < 0:
        raise ValueError("Age can't be negative.")
    return age

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

Output:

Error: Age can't be negative.

You're not just catching exceptions — you're creating them when your own logic detects something wrong. This is how well-written libraries communicate bad inputs to whoever calls them.

Re-raising an exception

Sometimes you want to catch an error, do something (log it, clean up), then let it keep bubbling up.

try:
    result = 10 / 0
except ZeroDivisionError as e:
    print(f"Logging error: {e}")
    raise

raise with no arguments re-raises the current exception. The error still crashes the program — you just got to do something before it did.


Custom exceptions — building your own error types

For any serious project, you'll want your own exception types so callers can catch your specific errors separately from everything else.

class InsufficientFundsError(Exception):
    def __init__(self, amount, balance):
        self.amount = amount
        self.balance = balance
        super().__init__(f"Tried to withdraw {amount} but balance is {balance}.")

def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFundsError(amount, balance)
    return balance - amount

try:
    new_balance = withdraw(100, 250)
except InsufficientFundsError as e:
    print(f"Transaction failed: {e}")
    print(f"You tried: ${e.amount}, Available: ${e.balance}")

Output:

Transaction failed: Tried to withdraw 250 but balance is 100.
You tried: $250, Available: $100

Custom exceptions make your code communicate clearly. Instead of a generic ValueError, callers know exactly what went wrong and can handle it precisely.


Context managers — with statement (the clean way to handle resources)

Opening files, database connections, and network sockets need to be closed even if something crashes. finally works, but Python has a cleaner tool: with.

The manual way:

try:
    f = open("data.txt", "r")
    content = f.read()
    print(content)
finally:
    f.close()

The clean way:

with open("data.txt", "r") as f:
    content = f.read()
    print(content)

with automatically closes the file when the block ends — even if an exception occurs inside. No finally needed. This is the standard way to work with files in Python.

With exception handling on top:

try:
    with open("data.txt", "r") as f:
        content = f.read()
        print(content)
except FileNotFoundError:
    print("File doesn't exist.")

assert — the quick sanity check

assert checks that something is true. If it's not, it raises an AssertionError. Used for catching logic bugs during development.

def divide(a, b):
    assert b != 0, "Divisor can't be zero"
    return a / b

divide(10, 0)

Output:

AssertionError: Divisor can't be zero

Assertions are for developer errors — things that should never happen if the code is correct. They're not for user input validation (use raise ValueError for that). Also note: assertions can be disabled globally with the -O flag, so never rely on them for security or critical logic.


Alternatives to exception handling

Option 1: LBYL — "Look Before You Leap"

Check the condition before doing the operation. Avoid the exception entirely.

# LBYL
def safe_divide(a, b):
    if b == 0:
        return None
    return a / b

Option 2: EAFP — "Easier to Ask Forgiveness than Permission"

Just try it and catch the error if it happens. This is the Python-preferred style.

# EAFP
def safe_divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        return None

Python culture generally prefers EAFP. It's often faster (no double-checking) and handles race conditions better (the file might exist when you check but disappear before you open it). But LBYL is fine for simple cases — pick whichever is clearer.

Option 3: Return error codes

Some languages return error codes instead of raising exceptions. In Python, this is rare and usually a bad idea — it's easy to ignore a return value, hard to ignore a crash.

# not pythonic — avoid this
def safe_divide(a, b):
    if b == 0:
        return None, "division by zero"
    return a / b, None

result, error = safe_divide(10, 0)
if error:
    print(f"Error: {error}")

This works but clutters every function call with error checking. Exceptions are cleaner.

Option 4: contextlib.suppress — silently ignore specific errors

For cases where you genuinely don't care if something fails:

from contextlib import suppress

with suppress(FileNotFoundError):
    os.remove("temp_file.txt")

If the file doesn't exist, the error is silently ignored. Use sparingly — suppressing errors can hide real problems.


All the scenarios — when to use what

User input that might be invalid → try/except ValueError

def get_age():
    try:
        age = int(input("Enter your age: "))
        if age < 0:
            raise ValueError("Age can't be negative.")
        return age
    except ValueError as e:
        print(f"Invalid age: {e}")
        return None

Opening files that might not exist → try/except FileNotFoundError

def read_config(path):
    try:
        with open(path, "r") as f:
            return f.read()
    except FileNotFoundError:
        print(f"Config file not found: {path}")
        return {}

Network requests that might fail → try/except with timeout handling

import requests

def fetch_data(url):
    try:
        response = requests.get(url, timeout=5)
        response.raise_for_status()
        return response.json()
    except requests.exceptions.Timeout:
        print("Request timed out.")
    except requests.exceptions.HTTPError as e:
        print(f"HTTP error: {e}")
    except requests.exceptions.ConnectionError:
        print("Connection failed.")
    return None

Database operations → try/except/finally to always close connection

def query_db(sql):
    conn = None
    try:
        conn = connect_to_db()
        result = conn.execute(sql)
        return result
    except DatabaseError as e:
        print(f"Query failed: {e}")
        return None
    finally:
        if conn:
            conn.close()

Enforcing your own rules → raise

def set_password(password):
    if len(password) < 8:
        raise ValueError("Password must be at least 8 characters.")
    if not any(c.isdigit() for c in password):
        raise ValueError("Password must contain at least one number.")
    return password

Real project — a safe file parser with full exception handling

Here's a complete mini-project that puts everything together. A program that reads a file of scores, parses them, calculates stats, and handles every possible failure gracefully.

class ScoreParseError(Exception):
    """Raised when a score line can't be parsed."""
    pass

def parse_score_line(line):
    """Parse a line like 'Alice:95' into (name, score)."""
    line = line.strip()

    if not line or line.startswith("#"):
        return None

    if ":" not in line:
        raise ScoreParseError(f"Invalid format (missing ':'): '{line}'")

    parts = line.split(":")
    if len(parts) != 2:
        raise ScoreParseError(f"Invalid format (too many ':'): '{line}'")

    name = parts[0].strip()
    score_str = parts[1].strip()

    if not name:
        raise ScoreParseError(f"Empty name in line: '{line}'")

    try:
        score = int(score_str)
    except ValueError:
        raise ScoreParseError(f"Score is not a number: '{score_str}'")

    if not 0 <= score <= 100:
        raise ScoreParseError(f"Score out of range (0-100): {score}")

    return name, score


def load_scores(filepath):
    """Load all scores from a file. Returns list of (name, score) tuples."""
    scores = []
    errors = []

    try:
        with open(filepath, "r") as f:
            lines = f.readlines()
    except FileNotFoundError:
        print(f"File not found: {filepath}")
        return [], []
    except PermissionError:
        print(f"Permission denied: {filepath}")
        return [], []

    for i, line in enumerate(lines, start=1):
        try:
            result = parse_score_line(line)
            if result is not None:
                scores.append(result)
        except ScoreParseError as e:
            errors.append(f"Line {i}: {e}")

    return scores, errors


def calculate_stats(scores):
    """Calculate average, highest, and lowest score."""
    if not scores:
        raise ValueError("No scores to calculate stats for.")

    values = [score for _, score in scores]

    return {
        "count":   len(values),
        "average": sum(values) / len(values),
        "highest": max(values),
        "lowest":  min(values),
    }


def run_report(filepath):
    """Full pipeline — load, parse, calculate, report."""
    print(f"Loading scores from: {filepath}\n")

    scores, errors = load_scores(filepath)

    if errors:
        print("Parse errors encountered:")
        for error in errors:
            print(f"  - {error}")
        print()

    if not scores:
        print("No valid scores found. Exiting.")
        return

    print(f"Valid scores loaded: {len(scores)}")
    for name, score in scores:
        print(f"  {name}: {score}")
    print()

    try:
        stats = calculate_stats(scores)
        print(f"Count:   {stats['count']}")
        print(f"Average: {stats['average']:.1f}")
        print(f"Highest: {stats['highest']}")
        print(f"Lowest:  {stats['lowest']}")
    except ValueError as e:
        print(f"Stats error: {e}")
    finally:
        print("\nReport complete.")


run_report("scores.txt")

If scores.txt contains:

# Class scores
Alice:95
Bob:87
Charlie:hello
Dave:110
Eve:72
:55
Frank:88

Output:

Loading scores from: scores.txt

Parse errors encountered:
  - Line 4: Score is not a number: 'hello'
  - Line 5: Score out of range (0-100): 110
  - Line 6: Empty name in line: ':55'

Valid scores loaded: 4
  Alice: 95
  Bob: 87
  Eve: 72
  Frank: 88

Count:   4
Average: 85.5
Highest: 95
Lowest:  72

Report complete.

The program never crashed. Every bad line was caught individually. The good data still got processed. The user got a clear report of what went wrong and what succeeded. That's exception handling doing its job.


The decision table

SituationUse
Risky operation that might failtry/except
Cleanup that must always happenfinally
Code that runs only on successelse
Multiple different errors, different responsesMultiple except blocks
Multiple errors, same responseexcept (Error1, Error2)
Catch anything (last resort)except Exception as e
File/connection resourceswith statement
Enforce your own rulesraise ValueError(...)
Your own error categoryCustom exception class
Silently ignore one specific errorcontextlib.suppress
Development sanity checksassert

The one-line summary

try runs risky code. except catches specific failures and handles them gracefully. finally always cleans up. raise lets you create your own errors. Custom exceptions give your code a clear, specific vocabulary for what went wrong. Master these and your programs stop crashing and start communicating.