Catching Exceptions in Python: Complete Try-Except Error Handling and Debugging Guide

Catching Exceptions in python

I used to think good exception handling meant wrapping everything in a try/except Exception block and moving on. It took a production incident — where a swallowed exception hid a real bug for weeks until it silently corrupted data — to teach me that exception handling is a design decision, not a defensive reflex. This guide covers everything I’ve learned about catching exceptions properly in Python: the syntax, the internal mechanics of how exceptions actually propagate, and the judgment calls that separate robust error handling from code that just hides problems.

The Basic try/except Structure

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

Output:

Cannot divide by zero.

Python attempts to execute the code inside the try block. If an exception of the specified type is raised, execution jumps immediately to the matching except block, skipping any remaining code in the try block entirely.

Catching Multiple Exception Types

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

I can also catch multiple exception types in a single except clause using a tuple, when I want to handle them identically:

try:
    data = {"a": 1}
    value = data["b"] / 0
except (KeyError, ZeroDivisionError) as e:
    print(f"Something went wrong: {e}")

Why Catching Specific Exceptions Matters

This is the single habit that improved my error handling more than anything else. Catching a bare Exception (or worse, a bare except: with nothing specified) catches everything, including bugs I actually want to know about — typos causing NameError, logic errors causing TypeError, even KeyboardInterrupt in some cases (though except: alone actually catches BaseException, which includes SystemExit and KeyboardInterrupt too — a detail worth knowing since it can make a script un-interruptible with Ctrl+C).

# TOO BROAD - hides real bugs, catches things you probably don't want to catch
try:
    process_data(data)
except:
    pass  # dangerous - silently swallows literally everything, including typos and Ctrl+C

# BETTER - catch only what you actually expect and know how to handle
try:
    process_data(data)
except (ValueError, KeyError) as e:
    logger.error(f"Data processing failed: {e}")

I reserve broad exception catching for the outermost boundary of an application — like a top-level request handler in a web server that must not crash no matter what — and even there, I log the full exception rather than silently discarding it.

The Exception Hierarchy

Understanding how Python’s built-in exceptions relate to each other explains why catching a parent class catches its children too.

try:
    result = [1, 2, 3][10]
except LookupError:  # IndexError is a subclass of LookupError
    print("Caught via the parent class LookupError")

Output:

Caught via the parent class LookupError

IndexError and KeyError both inherit from LookupError, so an except LookupError: clause catches either one. This hierarchy is genuinely useful — I use it when I want to handle a category of related errors identically, without listing every specific subclass. The base of almost the entire hierarchy is Exception (itself a subclass of BaseException), which is why except Exception: catches nearly everything except a small set of special cases like SystemExit, KeyboardInterrupt, and GeneratorExit, which inherit directly from BaseException instead — a deliberate design choice so that broad exception handlers don’t accidentally swallow a user’s Ctrl+C or a legitimate sys.exit() call.

Accessing Exception Details

try:
    result = int("not a number")
except ValueError as e:
    print(f"Error type: {type(e).__name__}")
    print(f"Error message: {e}")
    print(f"Error args: {e.args}")

Output:

Error type: ValueError
Error message: invalid literal for int() with base 10: 'not a number'
Error args: ("invalid literal for int() with base 10: 'not a number'",)

I use as e constantly to capture the exception object itself, which lets me log meaningful details rather than just knowing that something failed, without knowing what specifically went wrong.

The else and finally Clauses

try:
    file = open('data.txt', 'r')
except FileNotFoundError:
    print("File not found.")
else:
    # runs only if the try block succeeded with no exception
    content = file.read()
    print(f"Read {len(content)} characters")
    file.close()
finally:
    # runs no matter what - exception or not, caught or uncaught
    print("Cleanup: attempt finished")

I use else specifically to separate “the code that might fail” from “the code that should only run if it didn’t fail” — putting the file.read() call inside else rather than inside try makes it explicit that I’m not trying to catch exceptions from the read operation itself, only from the open() call.

finally runs unconditionally — whether the try block succeeded, raised a caught exception, or even raised an uncaught exception that will propagate further up the call stack. This makes it the right place for cleanup code (closing files, releasing locks, closing network connections) that absolutely must happen regardless of outcome.

Raising Your Own Exceptions

def withdraw(balance, amount):
    if amount > balance:
        raise ValueError(f"Insufficient funds: balance is {balance}, requested {amount}")
    return balance - amount

try:
    withdraw(100, 150)
except ValueError as e:
    print(f"Transaction failed: {e}")

Creating Custom Exception Classes

For anything beyond a trivial script, I define custom exceptions specific to my application’s domain, which makes error handling both more precise and more self-documenting for anyone reading the code later.

class InsufficientFundsError(Exception):
    def __init__(self, balance, requested):
        self.balance = balance
        self.requested = requested
        super().__init__(f"Balance {balance} insufficient for withdrawal of {requested}")

class AccountFrozenError(Exception):
    pass

def withdraw(account, amount):
    if account.get('frozen'):
        raise AccountFrozenError("This account is frozen and cannot process transactions")
    if amount > account['balance']:
        raise InsufficientFundsError(account['balance'], amount)
    account['balance'] -= amount

account = {'balance': 100, 'frozen': False}

try:
    withdraw(account, 150)
except InsufficientFundsError as e:
    print(f"Handle insufficient funds: {e}")
except AccountFrozenError as e:
    print(f"Handle frozen account: {e}")

Custom exceptions inheriting from Exception (or a more specific built-in exception when appropriate) let calling code catch precisely the errors relevant to it, and the custom __init__ on InsufficientFundsError lets me attach structured data (balance, requested) to the exception object itself, not just a text message.

Internal Working: How Exception Propagation Actually Works

This is the mental model that made exceptions click for me conceptually. When Python raises an exception, it doesn’t just print an error and move on — it unwinds the call stack, searching each enclosing scope, from innermost to outermost, for a try block with a matching except clause. If a function call three levels deep raises an exception and none of those three levels has a matching handler, Python keeps propagating the exception upward through each returning function frame until it either finds a handler somewhere up the call chain, or reaches the top of the program with no handler at all, at which point Python prints a traceback and terminates the program.

def level_three():
    raise ValueError("Something went wrong deep in the call stack")

def level_two():
    level_three()

def level_one():
    level_two()

try:
    level_one()
except ValueError as e:
    print(f"Caught at the top level: {e}")

Output:

Caught at the top level: Something went wrong deep in the call stack

Even though the exception originates three function calls deep, it propagates cleanly up to the single try/except at the top, without me needing to add error handling at every intermediate level — this is exactly the mechanism that makes centralized error handling (like a single top-level handler in a web framework) practical.

Exception Chaining: Preserving Context

When I catch one exception and raise a different one in response, Python automatically preserves the original exception as context, which I’ve found invaluable for debugging.

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

try:
    load_config('missing_config.txt')
except RuntimeError as e:
    print(f"Error: {e}")
    print(f"Original cause: {e.__cause__}")

The from e syntax explicitly links the new exception to its original cause, and Python’s default traceback output shows both exceptions chained together — “the above exception was the direct cause of the following exception” — which has saved me significant debugging time compared to a traceback that only shows the outer, less specific error.

Common Mistakes I’ve Made

  • Catching bare Exception (or worse, bare except:) everywhere, silently hiding bugs I actually needed to know about.
  • Not logging caught exceptions, discovering only much later that something had been failing silently the whole time.
  • Catching an exception just to immediately re-raise it identically, adding no value and just cluttering the code.
  • Using exceptions for routine control flow where a simple conditional check would be clearer and faster.
  • Forgetting that finally runs even when a function returns from inside try, sometimes leading to surprising execution order when finally also contains a return (which actually overrides the try block’s return value — a genuine gotcha worth knowing).
def confusing():
    try:
        return "from try"
    finally:
        return "from finally"  # this silently overrides the try block's return value

print(confusing())  # prints "from finally" - a surprising, easy-to-miss gotcha

Debugging Tips

When an exception surfaces that I didn’t anticipate, I read the traceback from the bottom up — the last line shows the actual exception type and message, and the frames above it show the call chain that led there. I use logging.exception() inside except blocks rather than print(), since it automatically includes the full traceback in the log output, which is essential for diagnosing issues after the fact rather than only while actively watching the console.

import logging

logging.basicConfig(level=logging.ERROR)

try:
    result = 1 / 0
except ZeroDivisionError:
    logging.exception("Division failed")  # logs the full traceback automatically

Real-World Use Cases

  1. API request handling — catching network and parsing errors gracefully instead of crashing the whole service on one bad request.
  2. File and database operations — handling missing files, permission errors, or connection failures without losing the whole batch job.
  3. Input validation — raising and catching custom exceptions for domain-specific business rule violations.
  4. Retry logic — catching transient errors (like network timeouts) and retrying a limited number of times before giving up.

FAQs

What’s the difference between except Exception and bare except? except Exception: catches nearly all standard runtime errors but excludes SystemExit, KeyboardInterrupt, and GeneratorExit. A bare except: catches literally everything, including those, which can make a script frustratingly hard to interrupt with Ctrl+C.

Should I catch exceptions close to where they occur, or let them propagate? It depends on where you have enough context to handle the error meaningfully. If the immediate code can’t do anything useful with the failure, let it propagate to a level that can — catching too early often just means catching and immediately doing nothing useful.

What’s the point of custom exception classes if I could just use built-in ones? Custom exceptions make error handling more precise (calling code can catch exactly your domain’s error types) and self-documenting, and they let you attach structured, application-specific data to the exception object itself.

Does finally run if the try block has a return statement? Yes — finally always runs, even when try (or except) contains a return, break, or continue. If finally also has a return, it silently overrides the one from try, which is a common source of confusing bugs.

How do I preserve the original error when raising a new exception? Use raise NewException("message") from original_exception to explicitly chain them, preserving the full context for debugging.

Summary

Catching exceptions well in Python is less about syntax — which is genuinely simple — and more about judgment: catching specific exception types rather than everything, understanding how exceptions propagate up the call stack until something handles them, and using finally and exception chaining deliberately rather than by accident. The habits that took me the longest to build — never silently swallowing an exception, and always logging what actually went wrong — are the ones that have saved me the most debugging time on every project since.

References

Total
0
Shares

Leave a Reply

Previous Post
Performing a shallow copy in python

Performing a Shallow Copy in Python: Complete Object Copying and Memory Management Guide

Next Post
Scraping using the Scrapy framework in python

Scraping Using the Scrapy Framework in Python: Complete Web Crawling and Data Extraction Guide

Related Posts