Python exception handling, explained
Exceptions are how Python signals that something went wrong. The try statement has four
clauses — try, except, else, finally — and knowing exactly what each does, and in
what order they run, is what separates robust error handling from code that silently hides
bugs.
The four clauses and their order
try holds the risky code. except handles a matching exception. else runs only if no
exception was raised. finally runs always, exception or not.
try:
value = int(user_input) # might raise ValueError
except ValueError:
print("not a number")
else:
print(f"got {value}") # runs only if no exception
finally:
print("done") # always runs
The flow: try → if it raises, a matching except → otherwise else → and finally last,
no matter what. Putting the success-path code in else keeps the try block as small as
the operation that can actually fail.
Catch specific exceptions, not everything
A bare except: (or except Exception:) catches far too much and hides real bugs. Catch
the narrowest exception that you can actually handle.
# Bad — swallows typos, KeyboardInterrupt-adjacent bugs, everything
try:
risky()
except:
pass
# Good — only the error you expect and can recover from
try:
config = json.loads(text)
except json.JSONDecodeError as e:
print(f"bad config: {e}")
Letting an unexpected exception propagate is usually better than catching it — it gives you a traceback instead of a mysterious wrong result later.
Handling multiple exception types
You can list several types in one tuple, or use separate clauses when each needs different
handling. The first matching except wins, so order from specific to general.
try:
result = process(data)
except (KeyError, IndexError) as e:
print(f"missing data: {e}")
except ValueError as e:
print(f"bad value: {e}")
except Exception as e:
print(f"unexpected: {e}")
raise # re-raise what you can't handle
Because subclasses match parent clauses, a more general except placed first would shadow
the specific ones below it.
finally always runs — even on return
finally executes even if the try block returns, breaks, or raises. That makes it the
place for cleanup that must happen no matter what (though context managers are usually
cleaner).
def read():
f = open("data.txt")
try:
return f.read() # finally still runs before the return completes
finally:
f.close() # guaranteed, even on exception
A subtle trap: a return inside finally will override any return or exception from
the try block — avoid it.
Exception chaining with raise from
When you catch one error and raise another, use raise ... from to preserve the original
cause. This keeps the full story in the traceback ("during handling of the above, another
occurred").
try:
config = load(path)
except FileNotFoundError as e:
raise RuntimeError("config missing") from e # chains the cause
Without from, Python still shows the implicit context, but from e states the
relationship explicitly. Use from None to deliberately suppress a noisy original cause.
Accessing exception details
The as e binding gives you the exception object. You can read its args, its message, and
(3.11+) attach notes for richer diagnostics.
try:
int("abc")
except ValueError as e:
print(type(e).__name__, e) # ValueError invalid literal for int()...
e.add_note("while parsing user input") # 3.11+
raise
Note that e is deleted at the end of the except block, so assign it to another name if
you need it afterwards.
Recap
try/except/else/finally: risky code, the handler, the no-exception path, and the
always-runs cleanup — in that order. Catch the most specific exception you can actually
handle and let the rest propagate so you keep real tracebacks; never use a bare except: pass. finally runs even through return and exceptions (but don't return inside it).
Use raise NewError(...) from original to chain causes, and the as e binding to inspect
details. Good error handling is narrow, intentional, and never silent.