try, except, finally in Python

Errors happen — a file might be missing, user input might be malformed, a network call might fail. python try except finally is how you anticipate and gracefully respond to those errors, rather than letting your entire program crash the moment something goes wrong. This article covers python exception handling from the basic try/except block through else, finally, and the best practices that keep error handling genuinely useful rather than a source of hidden bugs.

Introduction: Why Exception Handling Matters

The problem

By default, an unhandled error stops your program immediately and prints a traceback:

result = 10 / 0
# ZeroDivisionError: division by zero
# (program halts here — nothing after this line runs)

Every line after that error simply never executes. For a huge number of real-world programs, that's genuinely too fragile — a single unexpected condition shouldn't necessarily bring the whole program down.

try/except: anticipating and responding gracefully
try:
    result = 10 / 0
except ZeroDivisionError:
    print("Cannot divide by zero")
    result = None

print("Program continues running")
# Cannot divide by zero
# Program continues running

The exact same error occurred — but instead of crashing, the program caught it, handled it gracefully, and kept running.

A real-world framing

Imagine a script processing a hundred files. If file #47 happens to be missing or malformed, that shouldn't necessarily halt the entire batch — a well-designed script catches that specific error, logs it, and moves on to process the remaining 53 files, rather than losing all the work already done and everything still left to process.

The Basic try/except Block

Syntax

try:
    # code that might fail
except SomeExceptionType:
    # runs only if that specific exception occurs in the try block

Code that might raise an exception goes inside try. The except block only runs if an exception actually occurs somewhere within that try block.

Catching a specific exception type
try:
    result = 10 / 0
except ZeroDivisionError:
    print("Cannot divide by zero")

Catching a specific exception type, like ZeroDivisionError here, is the recommended, precise approach — you're explicitly stating exactly which error you're prepared to handle.

Bare except: — generally discouraged
try:
    result = 10 / 0
except:   # catches literally everything — a bare except
    print("Something went wrong")

This works, but it's widely considered bad practice, for a genuinely important reason: a bare except: catches everything — including exceptions you almost certainly didn't intend to catch, like KeyboardInterrupt (raised when a user presses Ctrl+C to stop the program) or SystemExit. Silently swallowing these makes your program harder to interrupt or stop cleanly, which is rarely what you actually want. If you genuinely need to catch a broad category of errors, except Exception: is a meaningfully safer choice — it still catches most everyday errors, but deliberately excludes those system-level signals.

Multiple except blocks

You can chain multiple except blocks to handle different exception types differently — Python checks them in order, top to bottom, and runs the first one that matches:

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

If the user types something non-numeric, int() raises ValueError, and the first except block handles it. If they type 0, the division raises ZeroDivisionError, and the second block handles that instead. Only one of these blocks will ever run for a given call, since only one exception can actually occur.

The else Clause: Separating the Happy Path

What it does

An else clause on a try statement runs only if the try block completed without raising any exception at all — mirroring the same "no interruption" philosophy covered for loop else clauses in an earlier article, just applied to exceptions instead of break.

try:
    result = 10 / 2
except ZeroDivisionError:
    print("Cannot divide by zero")
else:
    print(f"Success: {result}")
# Success: 5.0
Why this is useful

This might look like it could just as easily go directly inside try, and functionally, in this simple example, it could. But else genuinely earns its place for a real readability reason: it keeps the "this is what happens on success" logic visually and logically separate from the risky, potentially-failing code — rather than burying success-path logic inside the same block as the operation that might fail, where it becomes harder to tell, at a glance, which specific line is actually the risky one.

A practical example
try:
    value = int(input("Enter a number: "))
except ValueError:
    print("That wasn't a valid number")
else:
    squared = value ** 2
    print(f"The square of {value} is {squared}")

Here, the try block contains only the risky operation (int(input(...))). Everything that depends on that operation having succeeded — computing and displaying the square — lives clearly inside else, making it immediately obvious that this logic only runs when the conversion genuinely worked.

The finally Clause: Guaranteed Cleanup

finally always runs

A finally block runs unconditionally — whether an exception occurred, was successfully handled, or wasn't raised at all. It even runs if the function containing the try statement returns early from inside the try or except block:

def process():
    try:
        print("Trying...")
        return "success"
    finally:
        print("Cleanup always happens")

result = process()
# Trying...
# Cleanup always happens
print(result)   # success

Even though return "success" exits the function immediately, finally still runs before that return actually completes — there's genuinely no way to skip a finally block through a normal return.

The classic use case: resource cleanup
file = open("data.txt")
try:
    c file.read()
    # process contents...
finally:
    file.close()   # guaranteed to run, whether reading succeeded or failed

finally guarantees the file gets closed regardless of what happens inside try — a genuinely important pattern for anything acquiring a resource (a file, a network connection, a database cursor) that needs to be reliably released, no matter what.

A caveat worth flagging

Here's a subtlety worth understanding: if the risky operation itself fails to complete — say, open() itself raises an error, rather than something failing after the file was successfully opened — a variable referenced inside finally might not actually exist:

try:
    file = open("nonexistent.txt")   # this itself fails
    c file.read()
finally:
    file.close()   # NameError: name 'file' is not defined — 'file' was never assigned!

Because open() itself raised the exception, file was never successfully assigned at all — and finally still runs regardless, attempting to call .close() on a variable that doesn't exist, producing a second, unrelated error on top of the original one.

The safer modern alternative: context managers

This is precisely the kind of situation Python's with statement (a context manager) exists to handle more safely and concisely:

try:
    with open("data.txt") as file:
        c file.read()
except FileNotFoundError:
    print("File not found")

with open(...) as file: guarantees the file gets closed automatically once the block exits — successfully or via an exception — without you needing to write a manual finally: file.close() at all, and without the risk of the variable-doesn't-exist problem shown above, since the context manager itself handles cleanup correctly regardless of exactly where inside the block something might fail. For file handling, database connections, and most other resource-management situations, with is now the standard, preferred approach over manually managing cleanup with finally.

Putting It Together and Best Practices

A full example
import json

def load_config(filepath):
    try:
        with open(filepath) as file:
            c json.load(file)
    except FileNotFoundError:
        print(f"Config file not found: {filepath}")
        return None
    except json.JSONDecodeError:
        print(f"Config file contains invalid JSON: {filepath}")
        return None
    else:
        print("Config loaded successfully")
        return config
    finally:
        print("Finished attempting to load config")

c load_config("settings.json")

This example combines everything covered in this article: try contains only the genuinely risky operations (opening the file, parsing JSON), two separate except blocks handle two distinct, plausible failure modes differently, else handles the success path cleanly and separately, and finally guarantees a log message regardless of what happened above.

Best practices

Keep try blocks as small as possible. The narrower your try block, the more obvious it is exactly which line could actually fail, and the less risk of accidentally catching an exception from an entirely unrelated piece of code that just happened to be sitting inside the same block.

# Less ideal — unclear exactly which line might raise ValueError
try:
    value = int(input("Enter a number: "))
    doubled = value * 2
    print(f"Doubled: {doubled}")
except ValueError:
    print("Invalid input")

# Better — the risky operation is isolated
try:
    value = int(input("Enter a number: "))
except ValueError:
    print("Invalid input")
else:
    doubled = value * 2
    print(f"Doubled: {doubled}")

Avoid catching broader exception types than necessary. Catching Exception (or worse, a bare except:) when you really only expect a ValueError can silently swallow genuine bugs elsewhere in that same block — an unrelated TypeError or AttributeError, caused by an actual mistake in your code, gets caught and hidden right alongside the error you actually meant to handle, making real bugs much harder to notice and diagnose.

raise: deliberately throwing an exception

You're not limited to just catching exceptions Python raises automatically — raise lets you deliberately throw one yourself, useful for enforcing your own validation rules:

def set_age(age):
    if age < 0:
        raise ValueError("Age cannot be negative")
    return age

set_age(-5)
# ValueError: Age cannot be negative

This is exactly the same mechanism used throughout earlier articles in this series — the __post_init__ validation in the dataclasses article, and the setter validation in the @property article, both rely on raise to enforce their own rules.

Python's EAFP philosophy

Python generally favors a style called EAFP — "Easier to Ask Forgiveness than Permission." Rather than carefully checking every precondition before attempting an operation, idiomatic Python code often just attempts the operation directly, and handles the exception if it fails:

# EAFP style — attempt directly, handle failure if it happens
try:
    value = my_dict["key"]
except KeyError:
    value = "default"

# The alternative style (sometimes called LBYL — "Look Before You Leap")
if "key" in my_dict:
    value = my_dict["key"]
else:
    value = "default"

Both approaches work here, and for this particular simple case, .get("key", "default") (covered in the earlier dictionaries article) would actually be the cleanest choice of all. But in more complex situations — especially ones involving multiple steps where checking every precondition upfront would be awkward or genuinely impossible to do reliably — the EAFP style, backed by try/except, is generally considered more idiomatic, more readable Python than exhaustively validating every condition in advance.