Try / Except
try/except lets a program catch and handle errors at runtime instead of crashing.
Exception handling is one of those topics that looks simple until an interview asks why
finallystill runs afterreturn, or why a bareexceptis dangerous. The details here are exactly what separates surface-level knowledge from real understanding.
The Exception Model
In Python, errors during execution are represented as exception objects. When something goes wrong (dividing by zero, accessing a missing key, calling a method on the wrong type), Python creates an exception object and raises it. If nothing catches the exception, the program prints a traceback and exits.
The try block marks the code that might raise. The except block defines what to do when a specific exception occurs.
try:
result = 8 / 0
except ZeroDivisionError:
print("cannot divide by zero")The try block runs normally until an exception is raised. At that point, Python stops executing the try block and looks for a matching except clause. If found, the handler runs. Code after the try/except continues normally.
Basic Syntax
The minimal form is one try and one except.
try:
# code that might raise
...
except SomeException:
# handler
...To capture the exception object itself, use as:
try:
x = int("abc")
except ValueError as e:
print(type(e)) # <class 'ValueError'>
print(e) # invalid literal for int() with base 10: 'abc'The name e is bound only inside the except block. After the block ends, it is deleted (more on this in Tricky Behaviors).
Built-in Exception Hierarchy
All exceptions form a class hierarchy rooted at BaseException. Most exceptions inherit from Exception, which is the base class for all non-system-exiting errors.
BaseException
SystemExit
KeyboardInterrupt
GeneratorExit
Exception
ValueError
TypeError
AttributeError
KeyError
IndexError
NameError
UnboundLocalError
OSError
FileNotFoundError
PermissionError
ArithmeticError
ZeroDivisionError
OverflowError
RuntimeError
RecursionError
StopIteration
LookupError
KeyError
IndexError
...This hierarchy matters because except SomeBase catches any subclass of SomeBase. except Exception catches everything derived from Exception, which covers almost all application errors.
SystemExit, KeyboardInterrupt, and GeneratorExit inherit from BaseException directly, not from Exception. This is intentional: they represent signals to stop the program, not program errors.
Common Built-in Exceptions
| Exception | Raised when |
|---|---|
ValueError | Right type but wrong value (int("abc")) |
TypeError | Wrong type (2 + "four") |
AttributeError | Attribute does not exist on an object |
KeyError | Dict key is missing |
IndexError | Sequence index out of range |
NameError | Name is not defined |
UnboundLocalError | Local variable used before assignment |
ZeroDivisionError | Division or modulo by zero |
FileNotFoundError | File or directory not found |
PermissionError | Insufficient permissions to access a file |
RecursionError | Maximum recursion depth exceeded |
StopIteration | Iterator is exhausted |
OverflowError | Arithmetic result too large for the type |
MemoryError | Not enough memory to allocate an object |
OSError | General OS-level error (parent of many I/O errors) |
RuntimeError | An error that does not fit another category |
NotImplementedError | Abstract method not implemented in a subclass |
AssertionError | assert statement fails |
Multiple except Clauses
List multiple except clauses to handle different exceptions differently. Python checks them in order and runs the first matching one.
def parse_value(data, index):
try:
return int(data[index])
except IndexError:
print("index out of range")
except ValueError:
print("value cannot be converted to int")Order matters: Put more specific exceptions before their base classes. If a base class appears first, it swallows all subclasses before they get a chance to be matched by a more specific handler.
# Wrong order: Exception catches everything before ValueError can match
try:
int("abc")
except Exception:
print("general error") # always runs; ValueError never reached
except ValueError:
print("value error") # dead code
# Correct order: specific first
try:
int("abc")
except ValueError:
print("value error") # runs
except Exception:
print("general error")Catching Multiple Exceptions in One Clause
Pass a tuple of exception types to catch any of them with the same handler.
try:
data = [2, 4, 6]
print(data[8])
except (IndexError, KeyError) as e:
print(f"lookup failed: {e}")The parentheses are required. except IndexError, KeyError without parentheses is a SyntaxError in Python 3.
The else Clause
The else block runs only when the try block completes without raising any exception. It is skipped if any exception is raised (even one that is caught).
try:
result = 8 / 2
except ZeroDivisionError:
print("cannot divide")
else:
print(f"result is {result}") # runs here because no exception was raisedWhy use else instead of putting the code in try? Code in else is not protected by the except clauses above it. This makes the intent clear: the try block is the risky part; else is the success path. If else raises, it propagates normally rather than being silently caught.
try:
data = fetch_data() # might raise IOError
except IOError:
print("fetch failed")
else:
process(data) # runs only on success; not covered by the IOError handler aboveThe finally Clause
The finally block always runs: whether the try block succeeds, raises an exception that is caught, raises one that is not caught, or even executes a return statement. It is for cleanup that must happen regardless of outcome.
def read_file(path):
f = open(path)
try:
return f.read()
finally:
f.close() # always runs, even though try has a returnAll four clauses together:
try:
result = 8 / 2
except ZeroDivisionError:
print("division failed")
else:
print(f"success: {result}")
finally:
print("cleanup")| Scenario | try | except | else | finally |
|---|---|---|---|---|
| No exception raised | runs | skipped | runs | runs |
| Exception raised and caught | partial | runs | skipped | runs |
| Exception raised and not caught | partial | skipped | skipped | runs |
finally and return: If both try (or except) and finally contain return, the finally return wins and discards the other.
def demo():
try:
return 2
finally:
return 4 # overrides the return 2
demo() # 4Raising Exceptions
Use raise to trigger an exception manually.
def divide(a, b):
if b == 0:
raise ValueError("divisor cannot be zero")
return a / b
divide(8, 0) # raises ValueError: divisor cannot be zeroraise without arguments: Inside an except block, a bare raise re-raises the current exception unchanged, preserving its original traceback.
try:
int("abc")
except ValueError:
print("logging the error")
raise # re-raises the ValueError with original tracebackraise ExceptionType vs raise ExceptionType(): Both work. raise ValueError is shorthand for raise ValueError(). Using raise ValueError("message") is preferred because it attaches a message to the exception.
Exception Chaining
When one exception causes another, Python can chain them to preserve both tracebacks.
Implicit chaining
If an exception is raised inside an except block, Python automatically attaches the original exception as __context__.
try:
int("abc")
except ValueError:
raise RuntimeError("processing failed")
# Traceback shows: "During handling of the above exception, another exception occurred"Explicit chaining with raise ... from
Use raise NewException from original to explicitly declare the cause. The original is stored in __cause__. The traceback message changes to: “The above exception was the direct cause of the following exception.”
try:
int("abc")
except ValueError as e:
raise RuntimeError("processing failed") from eSuppressing context with raise ... from None
Use raise NewException from None to suppress the original exception from the traceback entirely. Useful when the original exception is an implementation detail.
try:
data = {"a": 2}
value = data["b"]
except KeyError:
raise ValueError("key 'b' is required") from None
# Traceback only shows ValueError; the KeyError is hidden| Form | __cause__ | __context__ | Traceback message |
|---|---|---|---|
raise E | None | original | ”During handling of…” |
raise E from original | original | original | ”The direct cause of…” |
raise E from None | None | original (suppressed) | Only new exception shown |
Custom Exceptions
Define custom exception classes by inheriting from Exception (or a more specific subclass).
class InsufficientFundsError(Exception):
"""Raised when a withdrawal exceeds the available balance."""
def __init__(self, amount, balance):
self.amount = amount
self.balance = balance
super().__init__(f"tried to withdraw {amount} but only {balance} available")
def withdraw(balance, amount):
if amount > balance:
raise InsufficientFundsError(amount, balance)
return balance - amount
try:
withdraw(20, 80)
except InsufficientFundsError as e:
print(e.amount) # 80
print(e.balance) # 20
print(e) # tried to withdraw 80 but only 20 availableWhy inherit from Exception, not BaseException? Code that does except Exception catches all application errors. If a custom exception inherits from BaseException directly, it escapes those generic handlers, which is almost never the intent.
Hierarchy of custom exceptions: For a library or module, create a base exception class and have all specific exceptions inherit from it. Callers can catch the base to handle all errors from the module, or catch a specific subclass for fine-grained control.
class AppError(Exception):
"""Base for all application errors."""
class DatabaseError(AppError):
"""Database-related errors."""
class NetworkError(AppError):
"""Network-related errors."""
# Catch all application errors at once:
try:
...
except AppError as e:
print(f"app error: {e}")
# Or catch specifically:
try:
...
except DatabaseError as e:
print(f"db error: {e}")contextlib.suppress
contextlib.suppress is a context manager that silently ignores specified exceptions. It replaces a try/except that does nothing in the handler.
from contextlib import suppress
# Instead of:
try:
data = {}
del data["x"]
except KeyError:
pass
# Use:
with suppress(KeyError):
del data["x"]suppress accepts multiple exception types: with suppress(KeyError, IndexError):. Use it only when silently ignoring the exception is truly correct behavior, not just to hide errors.
Exception Groups and except* (Python 3.11+)
Python 3.11 introduced ExceptionGroup to raise multiple exceptions at once, and except* to handle subsets of them. This is mainly relevant for concurrent code (e.g., asyncio.TaskGroup).
try:
raise ExceptionGroup("multiple errors", [
ValueError("bad value"),
TypeError("wrong type"),
])
except* ValueError as eg:
print(f"value errors: {eg.exceptions}")
except* TypeError as eg:
print(f"type errors: {eg.exceptions}")except* does not exclude other handlers; all matching except* clauses run. This is unlike regular except, where only the first match runs.
Tricky Behaviors
except Exception does not catch everything
SystemExit, KeyboardInterrupt, and GeneratorExit are subclasses of BaseException, not Exception. An except Exception clause will not catch them. Use except BaseException or a specific except KeyboardInterrupt if those need to be handled.
try:
raise KeyboardInterrupt
except Exception:
print("caught") # does NOT run
except KeyboardInterrupt:
print("interrupt") # runsBare except catches truly everything
except: without a type catches every exception, including SystemExit and KeyboardInterrupt. This can make a program impossible to terminate with Ctrl+C and mask serious failures. Use except Exception as the widest meaningful catch.
The exception variable is deleted after the block
The name bound in except SomeError as e is deleted at the end of the except block, even if the block runs successfully. Accessing e after the block raises NameError.
try:
int("abc")
except ValueError as e:
msg = str(e) # save it before the block ends
# e is now deleted
print(msg) # works
print(e) # NameError: name 'e' is not definedCatching a broad exception hides bugs
Using except Exception with a pass or a vague message can swallow unexpected errors and make debugging very difficult. Always be as specific as possible, and at minimum log unexpected exceptions.
# Dangerous pattern
try:
process()
except Exception:
pass # errors silently ignored; bugs become invisibleelse is not the same as code after try/except
Code placed after the try/except block always runs (if no exception propagates). Code in else only runs when no exception was raised. Also, exceptions raised in else are not caught by the except clauses above it.
try:
result = 8 / 2
except ZeroDivisionError:
result = 0
else:
print("success") # only here if no ZeroDivisionError
print("always") # runs regardlessfinally runs even when return is in try
This surprises many developers. The finally block runs before any return in the try or except block is actually returned to the caller. If finally itself contains return, that return overrides any earlier one.
def f():
try:
return 2
finally:
print("cleanup") # prints before 2 is returned
f() # prints "cleanup", then returns 2finally - exception raised inside it overrides the original
If finally itself raises an exception, that new exception replaces any exception already being propagated from try or except. The original exception is silently lost unless it was saved before finally ran.
def f():
try:
raise ValueError("original")
finally:
raise RuntimeError("from finally") # replaces the ValueError
f() # RuntimeError: from finally; ValueError is gonefinally - runs when break or continue exits a try block
finally is not limited to exception paths. It also runs when break or continue exits a try block inside a loop, making it reliable for any kind of exit.
for i in [2, 4, 6]:
try:
if i == 4:
break
finally:
print(f"cleanup for {i}")
# cleanup for 2
# cleanup for 4 -- finally runs even though break exits the loop heretry/finally - valid without an except clause
A try block can pair with finally alone, without any except. This is useful when the goal is to guarantee cleanup while still letting exceptions propagate to the caller unchanged.
def process(f):
try:
return f.read()
finally:
f.close() # always runs; any exception from f.read() still propagatesThis is exactly what context managers (with statements) do internally.
raise vs raise e in re-raising
A bare raise re-raises the current exception and preserves the original traceback. raise e re-raises but resets the traceback to the current line, making debugging harder.
try:
int("abc")
except ValueError as e:
raise # preserves original traceback
# raise e # traceback points to THIS line insteadRaising - StopIteration inside a generator becomes RuntimeError
Since Python 3.7 (PEP 479), if StopIteration propagates out of a generator function unhandled, Python replaces it with RuntimeError. This prevents a helper function’s StopIteration from accidentally terminating the generator. Inside a generator, use return to signal completion, not raise StopIteration.
def gen():
next([]) # raises StopIteration internally
list(gen()) # RuntimeError: generator raised StopIterationEAFP - try/except as normal control flow is idiomatic
Python favors EAFP (Easier to Ask Forgiveness than Permission) over LBYL (Look Before You Leap). LBYL checks a condition before attempting an operation; EAFP just tries it and handles the failure. EAFP is the idiomatic Python style and avoids race conditions where the check and the operation can be separated by other code.
# LBYL -- check before acting
if "key" in d:
value = d["key"]
# EAFP -- try and handle the failure
try:
value = d["key"]
except KeyError:
value = defaulttry/except has negligible overhead when no exception is raised. It only costs more when an exception actually occurs.
assert raises AssertionError and is disabled with -O
assert condition, message raises AssertionError if condition is falsy. When Python is run with the -O (optimize) flag, all assert statements are stripped. Never use assert for runtime validation in production code.
assert 2 + 2 == 4, "math is broken" # fine
assert 2 + 2 == 6, "math is broken" # AssertionError: math is brokenException objects retain references after handling
Exception objects can hold tracebacks, which hold references to local variables in all frames on the stack. In a long-running program, catching an exception and storing the object can cause memory to be held longer than expected. Using del e or keeping only the message string avoids the issue.
try/except in loops
Placing a try/except inside a loop to handle per-iteration errors is fine and idiomatic. However, a broad catch inside a loop can mask bugs that affect every iteration silently.
data = ["2", "abc", "4", "xyz", "6"]
results = []
for item in data:
try:
results.append(int(item))
except ValueError:
pass # skip non-numeric strings
print(results) # [2, 4, 6]Interview Questions
What is the difference between except Exception and bare except:?
except Exception catches all exceptions that inherit from Exception, which covers virtually all application errors. Bare except: catches absolutely everything, including SystemExit, KeyboardInterrupt, and GeneratorExit, which inherit from BaseException directly. Bare except: is considered bad practice because it can prevent the program from being interrupted and mask serious system-level signals.
What happens to the exception variable after the except block ends?
The name bound with as e is deleted at the end of the except block. Trying to access e outside the block raises NameError. The reason is CPython’s cleanup of the reference cycle that an exception object creates. Save any needed information to a different name before the block ends.
What is the difference between ValueError and TypeError?
TypeError is raised when an operation is applied to an object of the wrong type, such as adding a string and an integer. ValueError is raised when the type is correct but the value is not acceptable, such as calling int("abc") where the argument is a string but not a numeric one.
Why should specific exceptions be listed before base classes in except clauses?
Python checks except clauses in order. If a base class appears first, it matches any subclass, and the more specific clause below it is never reached. Specific exceptions must come first so they get a chance to match before the broader handler.
What does except (TypeError, ValueError) as e do? Why are the parentheses required?
The parentheses create a tuple of exception types. Any of the listed types will match the handler, and the exception object is bound to e. The parentheses are required because except TypeError, ValueError without parentheses is a SyntaxError in Python 3 (it was valid Python 2 syntax with different semantics).
What does the else clause in a try block do?
The else block runs only when the try block completes without raising any exception. It is distinct from code placed after the try/except block, which always runs. Exceptions raised inside else are not caught by the except clauses above it, which makes the success path explicit and keeps the try block focused on only the risky code.
What does the finally clause guarantee?
finally always executes, regardless of whether the try block succeeds, raises a caught exception, raises an uncaught exception, or executes a return. It is used for cleanup: closing files, releasing locks, or clearing resources. If both try and finally have return statements, the finally return overrides the other.
Why does finally run even when return is in the try block?
Python’s exception handling is designed so cleanup always happens. When a return is reached in try, Python notes the return value but still runs finally before handing that value back to the caller. If finally has its own return, that value is what the caller receives.
What happens if an exception is raised inside finally?
The new exception replaces whatever exception was already propagating from try or except. The original exception is discarded. This is a subtle source of bugs: a cleanup error in finally can silently swallow the original error that caused the cleanup to run in the first place.
Does finally run when break or continue exits a try block?
Yes. finally runs on every exit from the try block, including break, continue, return, and exceptions. It is not just for error paths.
Can you use try/finally without except?
Yes, and it is a common pattern. try/finally without except guarantees cleanup (closing a file, releasing a lock) while letting exceptions propagate normally to the caller. This is essentially what the with statement does under the hood.
What is the difference between raise and raise e for re-raising?
Bare raise re-raises the current active exception and preserves its original traceback, pointing to where the error first occurred. raise e also re-raises, but resets the traceback to the current line, which makes it harder to trace the original source of the error. Always use bare raise when re-raising inside an except block.
What is exception chaining and what is raise ... from?
When a new exception is raised inside an except block, Python automatically stores the original as __context__ (implicit chaining). Using raise NewException from original explicitly stores the cause in __cause__ and changes the traceback message to say “the direct cause of.” Using raise NewException from None suppresses the original exception from appearing in the traceback at all.
What is the difference between __cause__ and __context__ on an exception?
__context__ is set automatically whenever a new exception is raised inside an except block; it holds the original exception (implicit chaining). __cause__ is set explicitly by raise NewException from original; it also sets __suppress_context__ = True, changing the traceback message to “the direct cause of.” Using raise E from None sets __cause__ = None and __suppress_context__ = True, suppressing the original from the traceback entirely.
When should a custom exception inherit from Exception vs BaseException?
Custom exceptions should inherit from Exception (or a subclass of it) in almost all cases. Inheriting from BaseException directly causes the exception to escape broad except Exception handlers, which is only appropriate for exceptions that signal program termination, like SystemExit.
What is the best practice for exception hierarchy in a library?
Define a base exception class for the library (e.g., LibraryError(Exception)) and have all specific exceptions inherit from it. Callers can then catch LibraryError to handle all errors from the library uniformly, or catch a specific subclass for more precise handling. This also means the library can be updated to add new exception types without breaking existing catch-all handlers.
What is contextlib.suppress and when should it be used?
contextlib.suppress(*exceptions) is a context manager that silently ignores the specified exceptions. It is a cleaner alternative to try/except with a bare pass. It should be used only when ignoring the exception is genuinely correct, not as a shortcut to hide unexpected errors.
What are ExceptionGroup and except*, and when are they used?
ExceptionGroup (Python 3.11+) wraps multiple exceptions into one, and except* handles subsets of them. Unlike regular except, all matching except* clauses run, not just the first. This is primarily useful in concurrent code where multiple tasks can fail simultaneously, as with asyncio.TaskGroup.
Why is assert not suitable for runtime input validation?
assert statements are removed when Python runs with the -O (optimize) flag, so they cannot be relied upon in production. Use explicit if checks with raise for validation that must always run. assert is appropriate for internal sanity checks during development, not for enforcing business rules or validating user input.
What is EAFP and how does it differ from LBYL?
EAFP (Easier to Ask Forgiveness than Permission) means attempting an operation and catching any failure. LBYL (Look Before You Leap) means checking a precondition before acting. Python favors EAFP. It is more idiomatic, avoids race conditions between the check and the operation, and has negligible overhead when no exception occurs. LBYL can lead to redundant checks and is more common in languages like C.
What happens when StopIteration is raised inside a generator?
Since Python 3.7 (PEP 479), StopIteration that propagates out of a generator function is converted to RuntimeError. This prevents a helper function’s StopIteration from accidentally stopping the generator silently. Use return inside a generator to signal completion, not raise StopIteration.
How can holding a caught exception object cause a memory issue?
Exception objects hold a reference to the traceback, which holds references to all local variables in every frame on the call stack at the time of the exception. Storing the exception object in a long-lived variable keeps all those frames alive in memory. Saving only the message string or using del e after handling avoids the reference cycle.