Python Error Handling: From try-except to Exception Groups

Every Python program will eventually encounter an error. A missing file, an unexpected input value, a network timeout -- these situations are inevitable. What separates fragile scripts from production-ready applications is how they respond when something goes wrong. Python's error handling system gives you precise control over those responses, and learning to use it well is one of the highest-leverage skills you can develop as a Python programmer.

When Python encounters a problem during execution, it creates an exception object that describes what went wrong. If that exception is not handled by your code, the program terminates and prints a traceback. Error handling is the practice of anticipating these situations and writing code that responds to them gracefully, whether that means retrying an operation, logging the failure, returning a default value, or shutting down cleanly.

This article walks through everything you need to know about Python's error handling system, from the fundamental try-except block all the way through the modern except* syntax introduced in Python 3.11 and the latest improvements in Python 3.14.

Exceptions and the try-except Block

At the core of Python's error handling is the try-except block. You place the code that might fail inside the try clause, and you define how to respond to the failure inside one or more except clauses.

try:
    result = 10 / 0
except ZeroDivisionError:
    print("Cannot divide by zero")

When Python executes the code inside the try block and no error occurs, the except block is skipped entirely. But if an exception is raised that matches the type listed in the except clause, Python jumps to that handler and runs the code inside it instead of crashing.

You can catch multiple exception types by using more than one except clause. At most, one handler will execute -- the first one whose exception type matches.

try:
    value = int(input("Enter a number: "))
    result = 100 / value
except ValueError:
    print("That is not a valid integer")
except ZeroDivisionError:
    print("You cannot divide by zero")

If you need to handle several exception types with the same logic, you can group them together in a tuple.

try:
    data = process_input(raw_data)
except (ValueError, TypeError, KeyError):
    print("Invalid input data")
Warning

Avoid writing a bare except: clause with no exception type specified. This catches everything, including system-level exceptions like KeyboardInterrupt and SystemExit, which can make your program impossible to stop cleanly. If you truly need a broad catch, use except Exception instead, as this skips the system-level exceptions that you almost certainly want to propagate.

Accessing the Exception Object

When you catch an exception, you often need information about what went wrong. The as keyword binds the exception object to a variable so you can inspect it.

try:
    with open("config.yaml") as f:
        config = f.read()
except FileNotFoundError as e:
    print(f"Configuration file missing: {e}")
    # Output: Configuration file missing: [Errno 2] No such file or directory: 'config.yaml'

The exception object carries attributes like the error message and, for OSError subclasses, system-level information such as the errno and filename attributes.

Common Built-in Exceptions

Python provides a rich hierarchy of built-in exception classes. Here are the ones you will encounter frequently:

  • ValueError -- Raised when a function receives an argument with the right type but an inappropriate value, such as passing a negative number where only positives are expected.
  • TypeError -- Raised when an operation is applied to an object of an inappropriate type, such as trying to add a string and an integer.
  • KeyError -- Raised when a dictionary lookup fails because the key does not exist.
  • IndexError -- Raised when a sequence index is out of range.
  • FileNotFoundError -- Raised when trying to open a file that does not exist.
  • AttributeError -- Raised when an attribute reference or assignment fails on an object.
  • ImportError -- Raised when an import statement cannot find the specified module.
  • ZeroDivisionError -- Raised when dividing by zero.

All of these inherit from Exception, which itself inherits from BaseException. Understanding this hierarchy helps you write targeted exception handlers that catch exactly what you intend.

The Full try Statement: else and finally

The try-except block has two optional clauses that give you finer control over execution flow: else and finally.

The else Clause

The else block runs only if the try block completes without raising any exceptions. This is useful for separating the code that might fail from the code that should run only on success.

try:
    value = int(user_input)
except ValueError:
    print("Please enter a valid number")
else:
    print(f"You entered: {value}")
    process_number(value)

Placing the success logic inside else rather than at the end of the try block is a meaningful distinction. If process_number(value) were inside the try block and it happened to raise a ValueError, that error would be caught by the except clause and masked as an input problem. Using else keeps your error boundaries clean.

The finally Clause

The finally block runs no matter what -- whether the try block succeeded, an exception was caught, or an exception was raised and not caught. This makes it the right place for cleanup operations like closing files, releasing database connections, or resetting state.

connection = None
try:
    connection = open_database()
    data = connection.query("SELECT * FROM users")
except DatabaseError as e:
    print(f"Query failed: {e}")
finally:
    if connection is not None:
        connection.close()
        print("Database connection closed")
Pro Tip

For resource management, Python's with statement (context managers) is often a better choice than manually writing finally blocks. Context managers guarantee cleanup and make the code more readable. For example, with open("file.txt") as f: automatically closes the file when the block exits, even if an exception occurs.

Here is the complete structure of a try statement with all four clauses:

try:
    # Code that might raise an exception
    risky_operation()
except SpecificError as e:
    # Handle the specific error
    log_error(e)
else:
    # Runs only if no exception occurred
    confirm_success()
finally:
    # Always runs, regardless of outcome
    cleanup()

Raising Exceptions and Custom Exception Classes

Python does not limit you to handling exceptions that the interpreter raises. You can raise exceptions yourself using the raise keyword, and you can define your own exception types to represent error conditions specific to your application.

Raising Exceptions

The raise statement lets you signal that something has gone wrong. This is essential for validating inputs, enforcing preconditions, and communicating errors up the call stack.

def set_age(age):
    if not isinstance(age, int):
        raise TypeError("Age must be an integer")
    if age < 0 or age > 150:
        raise ValueError("Age must be between 0 and 150")
    return age

When choosing which exception type to raise, select the one that best describes the error. Use ValueError when the type is correct but the value is inappropriate. Use TypeError when the argument is the wrong type entirely. Being specific helps callers write targeted exception handlers.

Re-raising Exceptions

Sometimes you need to catch an exception, do something with it (like logging), and then let it continue propagating. A bare raise inside an except block re-raises the current exception with its original traceback intact.

import logging

try:
    process_payment(order)
except PaymentError as e:
    logging.error(f"Payment failed for order {order.id}: {e}")
    raise  # Re-raises the original PaymentError

Custom Exception Classes

For anything beyond trivial scripts, defining custom exceptions makes your error handling far more expressive. Custom exceptions let you communicate domain-specific error conditions and let callers distinguish between different failure modes.

class AppError(Exception):
    """Base exception for the application."""
    pass

class AuthenticationError(AppError):
    """Raised when user authentication fails."""
    pass

class InsufficientFundsError(AppError):
    """Raised when an account lacks sufficient balance."""
    def __init__(self, balance, amount):
        self.balance = balance
        self.amount = amount
        super().__init__(
            f"Cannot withdraw {amount}: only {balance} available"
        )

Notice the hierarchy. AuthenticationError and InsufficientFundsError both inherit from AppError. This means a caller can catch AppError to handle any application error broadly, or catch the specific subclass for targeted handling.

# Caller can be broad...
try:
    transfer_funds(from_acct, to_acct, 500)
except AppError as e:
    notify_user(str(e))

# ...or specific
try:
    transfer_funds(from_acct, to_acct, 500)
except InsufficientFundsError as e:
    suggest_lower_amount(e.balance)
except AuthenticationError:
    redirect_to_login()

Exception Chaining with raise...from

When an exception occurs while handling another exception, Python automatically chains them together, showing both tracebacks. But sometimes you want to be explicit about the relationship between the original error and the new one you are raising. That is where raise...from comes in.

class ConfigError(Exception):
    pass

def load_config(path):
    try:
        with open(path) as f:
            return parse_config(f.read())
    except FileNotFoundError as e:
        raise ConfigError(f"Config file not found: {path}") from e
    except ValueError as e:
        raise ConfigError(f"Invalid config format in {path}") from e

The from e clause sets the __cause__ attribute on the new exception, explicitly marking the original error as the cause. This gives you a clean traceback that shows both the high-level error (what went wrong from the caller's perspective) and the underlying error (why it went wrong at the implementation level).

If you want to suppress the original exception's traceback entirely, you can use from None. This is useful when the original exception is an implementation detail that would only confuse the caller.

def get_user(user_id):
    try:
        return db.query(f"SELECT * FROM users WHERE id = {user_id}")
    except DatabaseError:
        raise UserNotFoundError(f"User {user_id} not found") from None
Note

As a general guideline, prefer using raise ... from e over plain raise when you are transforming one exception into another. Preserving the cause makes debugging significantly easier. Reserve from None for cases where the original error would genuinely mislead the caller, such as when wrapping internal library exceptions behind a public API.

Exception Groups and except* (Python 3.11+)

Python 3.11 introduced two powerful features for handling multiple simultaneous errors: the ExceptionGroup class and the except* syntax. These were designed primarily for concurrent programming scenarios where several tasks can fail at the same time, but they are useful in any situation where you need to represent and handle multiple errors as a group.

What is an ExceptionGroup?

An ExceptionGroup bundles multiple exceptions together into a single object. It takes a descriptive message and a list of exception instances.

errors = ExceptionGroup(
    "validation failed",
    [
        ValueError("Name cannot be empty"),
        ValueError("Email format is invalid"),
        TypeError("Age must be an integer"),
    ]
)
raise errors

Exception groups can also be nested. An ExceptionGroup can contain other ExceptionGroup objects, forming a tree of errors. Python's traceback formatting renders this tree in a structured, readable way.

Handling Exception Groups with except*

The except* syntax is specifically designed to work with exception groups. Unlike regular except, which stops at the first matching handler, except* checks every handler against the group and runs all that match.

try:
    raise ExceptionGroup(
        "multiple failures",
        [
            ValueError("bad value"),
            TypeError("wrong type"),
            ValueError("another bad value"),
        ]
    )
except* ValueError as eg:
    print(f"Value errors ({len(eg.exceptions)}):")
    for e in eg.exceptions:
        print(f"  - {e}")
except* TypeError as eg:
    print(f"Type errors ({len(eg.exceptions)}):")
    for e in eg.exceptions:
        print(f"  - {e}")

In this example, the first except* block catches both ValueError instances, and the second catches the TypeError. The variable eg is always an ExceptionGroup -- never a plain exception -- even if only one exception of that type is in the group.

Real-World Use: asyncio TaskGroup

The asyncio.TaskGroup, also introduced in Python 3.11, uses exception groups under the hood. When multiple tasks fail inside a task group, all their exceptions are collected into an ExceptionGroup.

import asyncio

async def fetch_url(url):
    # Simulate a network request that might fail
    if "bad" in url:
        raise ConnectionError(f"Failed to reach {url}")
    return f"Data from {url}"

async def main():
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(fetch_url("https://api.example.com/good"))
            tg.create_task(fetch_url("https://api.example.com/bad1"))
            tg.create_task(fetch_url("https://api.example.com/bad2"))
    except* ConnectionError as eg:
        print(f"Connection failures: {len(eg.exceptions)}")
        for e in eg.exceptions:
            print(f"  - {e}")

asyncio.run(main())
Important

You cannot mix except and except* in the same try block. Python will raise a SyntaxError if you attempt to combine them. Choose one style per try statement based on whether you are handling individual exceptions or exception groups.

Best Practices for Error Handling

Knowing the syntax is only half the battle. How you use error handling determines whether your code is resilient or whether it just hides bugs. Here are the practices that make the difference.

Catch Specific Exceptions

Always catch the narrowest exception type that covers your use case. Catching Exception or using a bare except: might prevent a crash, but it also swallows errors you never anticipated, making bugs extremely hard to track down.

# Avoid this
try:
    result = complex_calculation(data)
except Exception:
    result = 0  # Silently hides any bug in complex_calculation

# Prefer this
try:
    result = complex_calculation(data)
except (ValueError, ArithmeticError) as e:
    logging.warning(f"Calculation failed: {e}")
    result = 0

Keep try Blocks Small

The more code you put inside a try block, the harder it is to know which line caused the exception. Keep the try block focused on the specific operation that might fail.

# Too broad
try:
    user = get_user_from_db(user_id)
    profile = process_user_data(user)
    send_notification(profile)
except Exception as e:
    logger.error(f"Operation failed: {e}")

# Focused and clear
try:
    user = get_user_from_db(user_id)
except DatabaseError as e:
    logger.error(f"Database lookup failed: {e}")
    return

try:
    profile = process_user_data(user)
except ValidationError as e:
    logger.error(f"User data invalid: {e}")
    return

try:
    send_notification(profile)
except NotificationError as e:
    logger.error(f"Notification failed: {e}")

EAFP over LBYL

Python has a cultural preference for "Easier to Ask Forgiveness than Permission" (EAFP) over "Look Before You Leap" (LBYL). Instead of checking whether an operation will succeed before attempting it, try the operation and handle the exception if it fails.

# LBYL style -- fragile and verbose
import os

if os.path.exists(filepath):
    if os.access(filepath, os.R_OK):
        with open(filepath) as f:
            data = f.read()
    else:
        print("File is not readable")
else:
    print("File does not exist")

# EAFP style -- Pythonic and robust
try:
    with open(filepath) as f:
        data = f.read()
except FileNotFoundError:
    print("File does not exist")
except PermissionError:
    print("File is not readable")

The EAFP approach is not just more concise. It is also safer because it avoids race conditions. With the LBYL approach, the file could be deleted between your existence check and your read operation. The EAFP approach handles this atomically.

Use Context Managers for Resource Cleanup

Whenever you work with resources that need to be released -- files, network connections, database cursors, locks -- use the with statement. You can also create your own context managers using the contextlib module.

from contextlib import contextmanager

@contextmanager
def db_transaction(connection):
    cursor = connection.cursor()
    try:
        yield cursor
        connection.commit()
    except Exception:
        connection.rollback()
        raise
    finally:
        cursor.close()

# Usage
with db_transaction(conn) as cursor:
    cursor.execute("UPDATE accounts SET balance = balance - 100 WHERE id = 1")
    cursor.execute("UPDATE accounts SET balance = balance + 100 WHERE id = 2")

Log Exceptions Properly

When you catch an exception, log it with enough context to diagnose the problem later. The logging module's exception() method automatically includes the full traceback.

import logging

logger = logging.getLogger(__name__)

try:
    process_batch(items)
except BatchProcessingError as e:
    logger.exception(f"Batch processing failed for {len(items)} items")
    # logger.exception() includes the full traceback automatically

What's New in Python 3.14

Python 3.14, released in October 2025, brought two notable improvements to exception handling.

Parentheses No Longer Required for Multiple Exception Types

In previous Python versions, catching multiple exception types required wrapping them in parentheses. Python 3.14 removes this requirement when you are not using the as keyword.

# Before Python 3.14 -- parentheses required
try:
    value = int(data)
except (ValueError, TypeError):
    print("Invalid input")

# Python 3.14+ -- parentheses optional
try:
    value = int(data)
except ValueError, TypeError:
    print("Invalid input")

This is a small quality-of-life improvement that brings the syntax in line with how many developers intuitively expect it to work. Note that if you use the as keyword to bind the exception, the parentheses are still needed.

SyntaxWarning for Control Flow in finally Blocks

Python 3.14 now emits a SyntaxWarning if you use return, break, or continue inside a finally block. These statements silently override any exception that was being propagated, which is almost always a bug rather than intentional behavior.

# Python 3.14 warns about this
def risky():
    try:
        raise ValueError("Something went wrong")
    finally:
        return "Suppressed"  # SyntaxWarning in 3.14+
        # The ValueError is silently swallowed!

In earlier versions of Python, this function would return the string without any warning, completely discarding the ValueError. The new warning helps catch this class of subtle bugs before they reach production.

Key Takeaways

  1. Catch specific exceptions. Using broad except Exception or bare except: hides bugs. Target the exact exception types you expect and know how to handle.
  2. Keep try blocks focused. Wrap only the line or lines that might fail. The less code inside a try block, the easier it is to diagnose failures.
  3. Use else for success logic. The else clause cleanly separates code that depends on the try block succeeding from code that might raise a matching exception of its own.
  4. Prefer EAFP over LBYL. Try the operation first and handle the failure. This avoids race conditions and produces more idiomatic Python.
  5. Chain exceptions with raise...from. When transforming exceptions, preserve the original cause using from e so that tracebacks remain useful for debugging.
  6. Define custom exceptions for your domain. A well-designed exception hierarchy makes error handling expressive and gives callers the ability to respond at the right level of granularity.
  7. Use exception groups for concurrent errors. When multiple tasks can fail simultaneously, ExceptionGroup and except* give you the tools to handle each failure type independently.
  8. Clean up resources with context managers. The with statement ensures cleanup happens reliably, which is almost always preferable to writing finally blocks by hand.

Error handling is not an afterthought you bolt on once your code works. It is a fundamental part of designing reliable software. The patterns covered in this article -- from basic try-except blocks to exception chaining and exception groups -- give you a complete toolkit for building Python applications that handle failure with clarity and confidence.

back to articles