Exception Handling in Python: Complete Guide
Python exception handling: try, except, else, finally, the exception hierarchy, custom exception classes, raise from chaining, and ExceptionGroup (Python 3.11+).
Exception handling in Python is a runtime mechanism that lets your program respond to errors without crashing, using four keywords: try, except, else, and finally.
That is the one-sentence version. The full picture covers which errors to catch, how to build informative custom exceptions, how to chain errors with raise from, and two pitfalls that make debugging harder than it needs to be.
What Is an Exception in Python?
An exception is a runtime error. Python raises it when something unexpected happens during execution, halts the current call stack, and displays a traceback. Without exception handling, the program exits at the point of failure.
The standard traceback looks like this:
Traceback (most recent call last):
File "main.py", line 3, in <module>
result = 10 / 0
ZeroDivisionError: division by zero
The last line names the exception class and a brief message. The lines above trace the call chain from the outermost frame to the exact line where the error was raised.
Python’s official errors and exceptions tutorial distinguishes two categories: syntax errors (detected at parse time, before execution) and exceptions (detected at runtime). Exception handling deals with runtime errors only.
Common exceptions you will encounter in practice:
| Exception | Raised when |
|---|---|
ZeroDivisionError | Dividing by zero |
FileNotFoundError | Opening a file that does not exist |
ValueError | Passing the right type with a wrong value |
TypeError | Passing the wrong type to an operation |
KeyError | Accessing a missing key in a dictionary |
IndexError | Accessing an index outside a list’s range |
ImportError | Importing a module that cannot be found |
AttributeError | Accessing an attribute that does not exist on an object |
For general Python programs that trigger these exceptions in realistic scenarios, Python practice programs is a useful reference alongside this guide.
The try/except/else/finally Pattern
Python’s exception-handling syntax has four blocks. Only try and at least one except (or a finally) are required; else and finally are optional.
try:
result = 10 / int(input("Enter a divisor: "))
except ZeroDivisionError:
print("Divisor cannot be zero.")
except ValueError:
print("That is not a valid integer.")
else:
print(f"Result: {result}")
finally:
print("Done — this runs regardless.")
Each block has a specific job:
try: runs your risky code. If an exception is raised, Python jumps immediately to the matchingexceptblock and skips the rest oftry.except ExceptionType: handles a specific exception. Stack multipleexceptclauses for different error types, from most specific to least specific.else: runs only when no exception was raised insidetry. Use it for code that depends ontrysucceeding but is not itself the thing that might raise the caught exception.finally: runs regardless — whether an exception was raised, caught, re-raised, or never occurred. Use it for cleanup: closing files, releasing locks, flushing buffers.
You can capture the exception object to inspect its message:
try:
with open("data.txt") as f:
content = f.read()
except FileNotFoundError as e:
print(f"File error: {e}")
The as e binding gives you the exception instance. str(e) returns the human-readable error message; type(e).__name__ returns the class name as a string.
A calculator that accepts user input is a clean illustration of this pattern. The ZeroDivisionError case is the one exception to handle explicitly, with a ValueError guard for non-numeric input. The calculator program in Python walks through that exact pattern in full.
Python’s Exception Hierarchy
Python’s exception tree starts at BaseException at the root. All exceptions you would normally write except clauses for descend from Exception, one level below BaseException.
The hierarchy looks like this (simplified):
BaseException
├── SystemExit # raised by sys.exit()
├── KeyboardInterrupt # raised by Ctrl+C
├── GeneratorExit # raised when a generator is closed
└── Exception # all application-level exceptions
├── ArithmeticError
│ ├── ZeroDivisionError
│ └── OverflowError
├── LookupError
│ ├── IndexError
│ └── KeyError
├── OSError
│ ├── FileNotFoundError
│ └── PermissionError
├── ValueError
├── TypeError
└── ... (many subclasses beyond this)
The Python built-in exceptions reference documents the complete hierarchy. Two rules follow directly from the structure:
- Catch the most specific exception you can. Catching
LookupErrorwhen you only mean to handle a missing dictionary key also catchesIndexErroras a side effect. CatchKeyErrorinstead. - Never catch
BaseExceptiondirectly. That suppressesKeyboardInterruptandSystemExit, making your program non-interruptible and un-killable from the terminal.
Catching Exception is appropriate when you genuinely need a catch-all for a logging layer, provided you re-raise afterward or have a deliberate reason to suppress the error.
Raising Exceptions: raise and raise from
You can raise exceptions explicitly using the raise statement. Three forms cover most real-world needs.
Raising a new exception
def withdraw(balance, amount):
if amount > balance:
raise ValueError(f"Amount {amount} exceeds balance {balance}.")
return balance - amount
raise ExceptionType(message) instantiates the exception and immediately halts execution at that line. The message string should answer “what went wrong and with what values?”
Chaining exceptions with raise from
When you catch one exception and raise a different one, use raise X from Y to preserve the causal chain:
import json
try:
data = json.loads(raw_input)
except json.JSONDecodeError as e:
raise ValueError("Malformed configuration input.") from e
The traceback shows both exceptions, with the note “The above exception was the direct cause of the following exception.” Without from e, the original JSONDecodeError still appears (as implicit context), but the relationship is described as “During handling of the above exception” rather than a direct causal link, which is less explicit about intent.
To suppress the original exception context entirely, use raise X from None. This is a deliberate design choice for cases where the original error is an internal implementation detail that callers should not see.
ExceptionGroup in Python 3.11+
Python 3.11 introduced ExceptionGroup for situations where multiple exceptions happen simultaneously, most commonly in concurrent code using asyncio.TaskGroup or concurrent.futures. An ExceptionGroup holds a list of sub-exceptions as a single object.
The matching clause for ExceptionGroup is except* (with an asterisk). It filters sub-exceptions by type and runs separate handlers for each category:
import asyncio
async def main():
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(task_one())
tg.create_task(task_two())
except* ValueError as eg:
print(f"ValueErrors: {eg.exceptions}")
except* TypeError as eg:
print(f"TypeErrors: {eg.exceptions}")
asyncio.run(main())
ExceptionGroup and except* are Python 3.11+ only. If your placement technical round includes Python version questions, knowing the motivation (concurrent failures) and the syntax (except*) covers the expected answer.
Custom Exception Classes
Python’s built-in exceptions cover the common cases. For application-specific errors, define your own exception classes by inheriting from Exception or one of its subclasses.
A minimal custom exception:
class InsufficientFundsError(Exception):
pass
This is enough if raise InsufficientFundsError("Balance too low.") provides the debugging context you need. The string message is accessible via str(e) on the caught exception.
For structured error context, add an __init__:
class InsufficientFundsError(Exception):
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
super().__init__(
f"Cannot withdraw {amount}: balance is {balance}."
)
Now the caller can catch the exception and inspect .balance and .amount directly, without parsing a string:
try:
withdraw(account.balance, requested)
except InsufficientFundsError as e:
notify_user(shortfall=e.amount - e.balance)
Three conventions worth following in any codebase:
- Name custom exceptions with an
Errorsuffix by convention (FileParseError, notFileParse). - Inherit from the most specific built-in that fits. A custom network-related error should inherit from
OSError, not bareException. - Keep exception classes in a dedicated
exceptions.pymodule for any project larger than a single script.
For array-processing programs where IndexError and ValueError guards appear frequently, Python array sum programs shows those patterns in realistic context.
Common Pitfalls in Exception Handling
Two patterns appear in placement-round code reviews and real production bug reports with equal regularity.
Bare except clauses
# Problematic
try:
result = risky_operation()
except:
pass
A bare except: catches everything, including KeyboardInterrupt (Ctrl+C), SystemExit (from sys.exit()), and GeneratorExit. Your program becomes non-interruptible. Worse, it catches your bug too: any unanticipated TypeError or AttributeError raised inside risky_operation will be silently swallowed and the failure will be invisible.
The fix is to name the exception type:
# Correct
try:
result = risky_operation()
except ValueError:
handle_value_error()
If you genuinely need a catch-all for a top-level logging handler, use except Exception: and re-raise:
try:
result = risky_operation()
except Exception:
logger.exception("Unexpected error in risky_operation")
raise
Silently swallowing exceptions
# Problematic
try:
send_email(report)
except Exception:
pass
This is the most common source of “I have no idea why the report did not send” bugs. The exception happened; nothing logged it; the calling code had no way to know. The fix is to always log before suppressing:
# Correct
try:
send_email(report)
except Exception as e:
logger.warning("Email send failed: %s", e)
# Suppress only if you have an explicit reason to treat failure as non-fatal
Only suppress an exception when you have a deliberate business reason to treat the failure as non-fatal. Put a comment explaining that reason. A silent pass without a comment is a code smell in any review.
Writing clean error-handling code (specific exception types, contextual custom classes, chaining with raise from) is exactly the kind of skill that separates working scripts from production Python. TinkerLLM is a place to practice that kind of Python in real project contexts, starting at ₹299.
Primary sources
Frequently asked questions
What is the difference between except Exception and bare except?
A bare except: clause catches every possible exception, including SystemExit, KeyboardInterrupt, and GeneratorExit — events you almost never want to suppress. except Exception: catches application-level errors only and lets system signals propagate normally. Always use except Exception: or a more specific subclass.
When does the else block run in a try/except statement?
The else block runs only when no exception was raised inside the try block. It is useful for code that depends on the try block succeeding but does not itself raise the exception you are catching — keeping the happy path separate from the error-handling logic.
How do I create a custom exception class in Python?
Inherit from Exception or any of its subclasses. Override __init__ to store structured fields. For example: class InsufficientFundsError(Exception): def __init__(self, balance, amount): self.balance = balance; self.amount = amount; super().__init__(f'Balance {balance} is less than amount {amount}'). Raise it with raise InsufficientFundsError(balance, amount).
What does raise from do in Python exception chaining?
raise X from Y chains exception Y as the explicit cause of X. The traceback shows both exceptions with the note 'The above exception was the direct cause of the following exception.' Use it when translating a low-level error into a higher-level domain error so callers see a meaningful exception while the root cause stays visible in the traceback.
What is ExceptionGroup in Python 3.11?
ExceptionGroup is a built-in type that holds multiple exceptions simultaneously. It was introduced in Python 3.11 to handle errors from concurrent tasks where multiple failures can happen at once. The except* clause (also new in 3.11) filters ExceptionGroups by exception type, letting you handle each sub-exception category separately.
How do I re-raise an exception without changing its traceback?
Use a bare raise statement inside an except block: try your risky code, in the except block log the error and then write raise on its own line. This re-raises the original exception unchanged, preserving its full traceback. Avoid assigning to a variable and then raising that variable, as that can reset traceback information in some Python versions.
What happens if an exception is raised inside a finally block?
If the finally block itself raises an exception, that new exception replaces the original one and the original is lost unless you stored it separately. This is why finally blocks should contain only cleanup code that cannot itself fail — closing a file, releasing a lock, or logging an exit.
A self-paced playground for building with LLMs.
TinkerLLM is FACE Prep's sister property. A guided environment for shipping real LLM applications, the kind of project that earns a paragraph on your resume, not a line.
Try TinkerLLM (₹299 launch)