Skip to Content
Try Except

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 finally still runs after return, or why a bare except is 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

ExceptionRaised when
ValueErrorRight type but wrong value (int("abc"))
TypeErrorWrong type (2 + "four")
AttributeErrorAttribute does not exist on an object
KeyErrorDict key is missing
IndexErrorSequence index out of range
NameErrorName is not defined
UnboundLocalErrorLocal variable used before assignment
ZeroDivisionErrorDivision or modulo by zero
FileNotFoundErrorFile or directory not found
PermissionErrorInsufficient permissions to access a file
RecursionErrorMaximum recursion depth exceeded
StopIterationIterator is exhausted
OverflowErrorArithmetic result too large for the type
MemoryErrorNot enough memory to allocate an object
OSErrorGeneral OS-level error (parent of many I/O errors)
RuntimeErrorAn error that does not fit another category
NotImplementedErrorAbstract method not implemented in a subclass
AssertionErrorassert 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 raised

Why 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 above

The 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 return

All four clauses together:

try: result = 8 / 2 except ZeroDivisionError: print("division failed") else: print(f"success: {result}") finally: print("cleanup")
Scenariotryexceptelsefinally
No exception raisedrunsskippedrunsruns
Exception raised and caughtpartialrunsskippedruns
Exception raised and not caughtpartialskippedskippedruns

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() # 4

Raising 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 zero

raise 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 traceback

raise 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 e

Suppressing 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 ENoneoriginal”During handling of…”
raise E from originaloriginaloriginal”The direct cause of…”
raise E from NoneNoneoriginal (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 available

Why 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") # runs

Bare 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 defined

Catching 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 invisible

else 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 regardless

finally 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 2

finally - 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 gone

finally - 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 here

try/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 propagates

This 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 instead

Raising - 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 StopIteration

EAFP - 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 = default

try/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 broken

Exception 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.

Last updated on