Python – Exception Handling

August 3, 20264 min readUpdated 8/20/2026

Python has no checked exceptions. Nothing forces you to handle anything, which makes the decisions about what to catch entirely yours. This post covers the mechanics and the judgement.

try and except

def parse_amount(text):
    try:
        return float(text)
    except ValueError:
        return None

print(parse_amount("15.50"))     # Output: 15.5
print(parse_amount("fifteen"))   # Output: None

Code that might fail goes in try; the recovery goes in except. Name the exception you expect — that is the whole discipline. If something other than ValueError happens, it propagates, which is what you want for a bug you have not thought about.

Never write a bare except

def total(rows):
    try:
        return sum(float(r) for r in rows)
    except:                      # catches EVERYTHING
        return 0

print(total(["1", "2"]))         # Output: 3.0
print(total(None))               # Output: 0

The second call did not fail because of bad data — None is not iterable, which is a programming mistake. The bare except turned it into 0 and the program carried on with a wrong total.

A bare except also catches KeyboardInterrupt, so Ctrl-C stops working. If you genuinely must catch broadly, use except Exception, which at least excludes the ones that mean "stop the program" — and log it rather than swallowing it.

Catching more than one

import json

def load(text):
    try:
        return json.loads(text)["balance"]
    except (json.JSONDecodeError, KeyError) as error:
        return f"bad input: {type(error).__name__}"

print(load('{"balance": 100}'))     # Output: 100
print(load('not json'))             # Output: bad input: JSONDecodeError
print(load('{"other": 1}'))         # Output: bad input: KeyError

A tuple handles several the same way; separate except blocks handle them differently. as error binds the exception object so you can inspect or log it.

Order matters: except blocks are tried top to bottom and the first matching one wins, so a broad type above a narrow one makes the narrow one unreachable.

else and finally

def withdraw(balance, amount):
    try:
        amount = float(amount)
    except ValueError:
        print("not a number")
    else:
        print(f"withdrawing {amount}")      # only if nothing raised
    finally:
        print("done")                       # always

withdraw(100, "25")
withdraw(100, "abc")

# Output: withdrawing 25.0
# Output: done
# Output: not a number
# Output: done

else runs only when the try succeeded. Its value is keeping the try block down to the one line that might fail — anything else in there could raise the same exception and be caught by mistake.

finally always runs, exception or not, return or not. It is for cleanup. In practice with replaces most uses of it, which is the next post.

Raising your own

class InsufficientFundsError(Exception):
    def __init__(self, requested, available):
        super().__init__(
            f"Insufficient funds: asked for ${requested:,.2f}, "
            f"only ${available:,.2f} available."
        )
        self.requested = requested
        self.available = available

    @property
    def shortfall(self):
        return self.requested - self.available

try:
    raise InsufficientFundsError(250, 100)
except InsufficientFundsError as error:
    print(error)             # Output: Insufficient funds: asked for $250.00, only $100.00 available.
    print(error.shortfall)   # Output: 150

Subclass Exception, never BaseException. Calling super().__init__(message) is what makes str(error) work — forget it and your exception prints as an empty string.

Carrying the numbers as attributes means a caller that wants them does not have to parse your message. That is the difference between an exception that reports and one that can be handled.

A base class per application

The bank app defines one root so a single except catches every rule it enforces:

class BankError(Exception):
    ...

class AuthenticationError(BankError):
    ...

class InsufficientFundsError(BankError):
    ...

except BankError at the menu catches every deliberate failure and nothing else. Without the shared base you either list every subclass or fall back to except Exception, which swallows your own bugs along with them.

raise ... from

When you catch a low-level error and re-raise a meaningful one, keep the original attached:

from decimal import Decimal, InvalidOperation

def parse_money(text):
    try:
        return Decimal(text)
    except InvalidOperation as error:
        raise ValueError(f"'{text}' is not an amount") from error

try:
    parse_money("abc")
except ValueError as error:
    print(error)                        # Output: 'abc' is not an amount
    print(type(error.__cause__).__name__)   # Output: InvalidOperation

The caller gets an error in their own vocabulary; the traceback still shows what really happened, under "The above exception was the direct cause of the following exception".

Leave out from error and Python prints "During handling of the above exception, another exception occurred" — a subtly different message meaning the second error was accidental. Say which one you meant.

Reading the traceback

An uncaught exception prints a traceback, and it is the most useful thing on your screen. Read it from the bottom up:

Traceback (most recent call last):
  File "bank.py", line 40, in <module>
    main()
  File "bank.py", line 31, in main
    withdraw(account, "abc")
  File "bank.py", line 22, in withdraw
    amount = float(text)
             ^^^^^^^^^^^
ValueError: could not convert string to float: 'abc'

The last line is what went wrong. The block above it is the call chain that got there, oldest at the top — so the frame just above the error is the line that actually failed, and the frames above that are how you arrived. From 3.11 the ^^^^ markers underline the exact expression, not just the line.

The habit worth building is to read the final line first, then scan upwards for the topmost frame that is your code rather than a library's. That is nearly always where the fix belongs.

What not to catch

The reflex to wrap everything in try is worth resisting. An exception you cannot do anything useful about should travel — the traceback that reaches you is far more informative than a None returned from three functions down.

Catch when you have a genuine recovery: a default to fall back on, a message to show the user, a retry to attempt. Otherwise let it raise.

Python's habit is to try the operation rather than check first — "easier to ask forgiveness than permission". float(text) in a try is more idiomatic than a regex that predicts whether it will work, because the conversion is the authority on that question.

Next: File (read/write).